Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
70e23ca15e test: repro for watcher ALS context bug
The @parcel/watcher native callback fires outside the Instance
AsyncLocalStorage context. Bus.publish silently throws because
Instance.directory is unavailable, so watcher events never reach
subscribers. This breaks live branch detection in the TUI.
2026-03-15 11:24:57 -04:00

View File

@@ -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"))
}, 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")
})