fix(agent-loop): one quiescence boundary across owner unload and handle.dispose

Cordis effect disposers are single-shot but not await-idempotent: when the
owning fiber's unload invokes the raw wrapper first, a concurrent
handle.dispose() got an immediate undefined and resolved before teardown
finished — violating the driver's stated one-boundary contract (Codex
implementation-review finding). The teardown chain's FIRST-yielded (so
disposed-last) disposer now resolves a shared completion promise; the
handle path awaits it after the wrapper, so tool-finally, parent-teardown,
and owner-unload all observe the same fully-torn-down state. Regression
test: owner unload begins first, concurrent handle.dispose still awaits
unregistration + session detach.
This commit is contained in:
Tianyi Cui
2026-07-09 03:58:15 +08:00
parent e7b712453a
commit 513ba2716d
2 changed files with 35 additions and 1 deletions
+14 -1
View File
@@ -303,7 +303,20 @@ export class AgentLoop extends Service implements AgentFactory {
setup?: (agentCtx: Context) => void,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
// The ONE quiescence boundary every disposal path observes. Cordis effect
// disposers are single-shot but not await-idempotent: when the OWNING
// fiber's unload invokes the raw wrapper first, a concurrent
// `handle.dispose()` calling the same wrapper gets an immediate undefined
// (epoch already cleared) — so the handle path must await THIS promise,
// resolved by the teardown chain's final disposer, not the wrapper's
// return. Every disposer in the chain is deliberately infallible (stop()
// is infallible by contract, unregister/detach contain their listeners,
// the scope unwind is cordis-contained), so the final disposer always
// runs — a throwing link would skip the rest of a cordis dispose chain.
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(function* (this: AgentLoop) {
// First-yielded ⇒ disposed LAST: marks true teardown completion.
yield () => { markTorndown() }
// Mint the agent's scope (key = the agent) and wire the two-phase
// reference: the scope context tags registrations + filters dispatch;
// the extend adds the `ctx.agent` DX own-property on top. The raw
@@ -349,7 +362,7 @@ export class AgentLoop extends Service implements AgentFactory {
// disposed later) is still attached.
yield async () => { stop(); await agent.done }
}.bind(this), 'agentLoop.start()')
return { agent, disposeAgent: async () => { await dispose() } }
return { agent, disposeAgent: async () => { await dispose(); await torndown } }
}
/**