diff --git a/packages/opencode/test/file/watcher-als-bug.test.ts b/packages/opencode/test/file/watcher-als-bug.test.ts new file mode 100644 index 000000000000..ed1aaa5fbdb1 --- /dev/null +++ b/packages/opencode/test/file/watcher-als-bug.test.ts @@ -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((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") +})