Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/opencode/test/file/watcher-als-bug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Repro for: @parcel/watcher native callback loses AsyncLocalStorage context
*
* Background:
* opencode uses AsyncLocalStorage (ALS) to track which project directory
* is active. Bus.publish reads Instance.directory from ALS to route events
* to the right instance. This works for normal JS async code (setTimeout,
* Promises, etc.) because Node propagates ALS through those.
*
* But @parcel/watcher is a native C++ addon. Its callback re-enters JS
* from C++ via libuv, bypassing Node's async hooks — so ALS is empty.
* Bus.publish silently throws Context.NotFound, and the event vanishes.
*
* What this breaks:
* The git HEAD watcher (always active, no experimental flag) should detect
* branch switches and update the TUI. But because events never arrive,
* the branch indicator never live-updates.
*
* This test:
* 1. Creates a tmp git repo and boots an instance with the file watcher
* 2. Listens on GlobalBus for watcher events (Bus.publish emits to GlobalBus)
* 3. Runs `git checkout -b` to change .git/HEAD — the watcher WILL detect
* this change and fire the callback, but Bus.publish will fail silently
* 4. Times out after 5s because the event never reaches GlobalBus
*
* Fix: Instance.bind(fn) captures ALS context at subscription time and
* restores it in the callback. See #17601.
*/
import { $ } from "bun"
import { afterEach, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"

async function load() {
const { FileWatcher } = await import("../../src/file/watcher")
const { GlobalBus } = await import("../../src/bus/global")
const { Instance } = await import("../../src/project/instance")
return { FileWatcher, GlobalBus, Instance }
}

afterEach(async () => {
const { Instance } = await load()
await Instance.disposeAll()
})

test("git HEAD watcher publishes events via Bus (ALS context test)", async () => {
const { FileWatcher, GlobalBus, Instance } = await load()

// 1. Create a temp git repo and start the file watcher inside an instance.
// The watcher subscribes to .git/HEAD changes via @parcel/watcher.
// At this point we're inside Instance.provide, so ALS is active.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileWatcher.init()
await Bun.sleep(200) // wait for native watcher to finish subscribing
},
})

// 2. Listen on GlobalBus and trigger a branch switch.
// When .git/HEAD changes, @parcel/watcher fires our callback from C++.
// The callback calls Bus.publish, which needs ALS to read Instance.directory.
// Without Instance.bind, ALS is empty → Bus.publish throws → event never arrives.
const got = await new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out — native callback likely lost ALS context"))

Check failure on line 67 in packages/opencode/test/file/watcher-als-bug.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: native callback likely lost ALS context

native callback likely lost ALS context at <anonymous> (/home/runner/_work/opencode/opencode/packages/opencode/test/file/watcher-als-bug.test.ts:67:18)
}, 5000)

function on(evt: any) {
if (evt.directory !== tmp.path) return
if (evt.payload?.type !== FileWatcher.Event.Updated.type) return
clearTimeout(timeout)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}

GlobalBus.on("event", on)

// This changes .git/HEAD, which the native watcher will detect
$`git checkout -b test-branch`.cwd(tmp.path).quiet().nothrow()
})

// 3. If we get here, the event arrived — ALS context was preserved.
// On the unfixed code, we never get here (the promise rejects with timeout).
expect(got).toBeDefined()
expect(got.event).toBe("change")
})
Loading