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.pull/17615/head
parent
9c00669927
commit
70e23ca15e
|
|
@ -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")
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue