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