Skip to content

Commit f6709c4

Browse files
committed
Document rotation's exemption and prove the fix through the real queue
Session rotation was never routed through closeAgentForRebuild: it mints a fresh sessionId/workdir before rebuilding, so a leaked lock on the old workdir can never be re-acquired there. Write that reasoning down at the call site and next to closeAgentForRebuild's doc comment, since the asymmetry across the three rebuild sites needs an explanation the next reader can find. Replace the helper-only regression test with one that drives the real session-operation-queue the same way reloadIfIdle actually calls it (void enqueue(...), no awaited return value) and asserts, via a real process.on("unhandledRejection") listener, that the rejection is contained and surfaces through fatalBuildError instead of escaping. reloadIfIdle itself can't be reached in isolation without standing up the full TUI runner; that's noted at the test.
1 parent 3e59b42 commit f6709c4

2 files changed

Lines changed: 90 additions & 5 deletions

File tree

src/tui/runner.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,15 @@ export function resumeTranscriptLoadErrorBlock(err: unknown): {
290290
// interrupts mid-inference, which is exactly when those paths are under
291291
// stress), the lock is never released — and because the agent is already
292292
// marked closed internally, retrying close() is a silent no-op that can
293-
// never release it either. Every rebuild site must treat that as fatal for
294-
// the current rebuild instead of calling buildAgent() again: a second
295-
// createAgent() for the same workdir is then guaranteed to throw
296-
// AgentContextLockError for a lock nothing will ever free, which is the
297-
// "agent already open" crash.
293+
// never release it either. Every rebuild site that reuses the *same* workdir
294+
// (interrupt, reloadIfIdle) must treat that as fatal for the current rebuild
295+
// instead of calling buildAgent() again: a second createAgent() for the same
296+
// workdir is then guaranteed to throw AgentContextLockError for a lock
297+
// nothing will ever free, which is the "agent already open" crash. Session
298+
// rotation (newSession) is the one rebuild site that does NOT route through
299+
// this helper: it always points buildAgent() at a freshly minted workdir
300+
// before rebuilding, so a leaked lock on the old workdir can never be
301+
// re-acquired there — see the comment at its close() call for why.
298302
export async function closeAgentForRebuild(agent: Agent, context: string): Promise<boolean> {
299303
try {
300304
await agent.close();
@@ -1901,6 +1905,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {
19011905
// settles, and a dead cycle's partial must land in the session that
19021906
// produced it, not the fresh one.
19031907
await cycleRecorder.dispose("rotation");
1908+
// Deliberately not routed through closeAgentForRebuild/
1909+
// agentRebuildFailure (unlike interrupt and reloadIfIdle, CL-5753):
1910+
// rotation mints a fresh sessionId/workdir below before calling
1911+
// buildAgent(), so even a close() that leaks the old workdir's lock
1912+
// (see closeAgentForRebuild's doc comment) can never cause a second
1913+
// acquisition on that same workdir — buildAgent() always targets
1914+
// the new, unlocked directory. The old lock still leaks for the
1915+
// rest of the process, but nothing ever tries to re-acquire it, so
1916+
// there is no crash to guard against here.
19041917
await currentAgent.close().catch((err: unknown) => {
19051918
tuiLogger.debug("agent.close during session-rotation teardown failed: {error}", {
19061919
error: err instanceof Error ? err.message : String(err),

tests/unit/tui/runner.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
loadLocalSettingsWriteBase,
1010
resumeTranscriptLoadErrorBlock,
1111
} from "../../../src/tui/runner.js";
12+
import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js";
1213
import { createRunSink } from "../../../src/session/run-sink.js";
1314

1415
test("createTUIEventEmitter returns an EventEmitter", () => {
@@ -160,3 +161,74 @@ test("a failed close followed by a lock error never surfaces as a raw AgentConte
160161
expect(rebuildError).not.toBeInstanceOf(AgentContextLockError);
161162
expect(rebuildError!.message).toMatch(/restart/i);
162163
});
164+
165+
// reloadIfIdle itself is a closure captured inside runTUI's single ~2500-line
166+
// scope (currentAgent, buildAgent, streamPromise, workflowController,
167+
// pendingReload/inFlight, fatalBuildError, etc. are all local variables of
168+
// that function), with no seam to construct or call it in isolation short of
169+
// standing up the full TUI runner — provider config, plugin discovery, MCP
170+
// wiring, and a real OpenTUI host. That is out of scope for this fix; it
171+
// would be its own extraction. What can be driven directly, and is exactly
172+
// the failure this bug reports, is the real `session-operation-queue.ts`
173+
// queue exercised the same way every rebuild site uses it: `void
174+
// enqueueOp(async () => { try { ... } catch (err) { fatalBuildError = ... } })`.
175+
// `enqueue` is `tail = tail.then(op, op); return tail;` — if `op` rejects and
176+
// nothing internally catches it, that returned promise is the only thing
177+
// that ever observes the rejection, and `void` discards it, which is
178+
// precisely how the unhandled rejection in the ticket escaped.
179+
test("a rejecting reload op through the real session-operation-queue never triggers an unhandled rejection", async () => {
180+
const { enqueue, awaitTail } = createSessionOperationQueue();
181+
const agent = stubAgent(() => Promise.reject(new AgentContextLockError("/tmp/workdir")));
182+
183+
let unhandled: unknown = null;
184+
const onUnhandledRejection = (reason: unknown): void => {
185+
unhandled = reason;
186+
};
187+
process.on("unhandledRejection", onUnhandledRejection);
188+
189+
let fatalBuildError: Error | null = null;
190+
try {
191+
// Mirrors reloadIfIdle's body verbatim: close the current agent through
192+
// closeAgentForRebuild, skip buildAgent() and throw instead of
193+
// re-acquiring on a failed close, and land any failure in
194+
// fatalBuildError via agentRebuildFailure — all behind `void enqueueOp`,
195+
// exactly as the runner calls it.
196+
void enqueue(async () => {
197+
try {
198+
const closedCleanly = await closeAgentForRebuild(agent, "reload");
199+
if (!closedCleanly) {
200+
throw new AgentContextLockError("/tmp/workdir");
201+
}
202+
} catch (err) {
203+
fatalBuildError = agentRebuildFailure(err);
204+
}
205+
});
206+
207+
await awaitTail();
208+
// Give any unhandled rejection queued by the engine a chance to fire
209+
// before asserting its absence — it lands on a later microtask/macrotask
210+
// than the awaited queue settlement.
211+
await new Promise((resolve) => setTimeout(resolve, 0));
212+
} finally {
213+
process.off("unhandledRejection", onUnhandledRejection);
214+
}
215+
216+
expect(unhandled).toBeNull();
217+
expect(fatalBuildError).not.toBeNull();
218+
expect(fatalBuildError).not.toBeInstanceOf(AgentContextLockError);
219+
expect(fatalBuildError!.message).toMatch(/restart/i);
220+
});
221+
222+
// A true negative control (reproducing reloadIfIdle's pre-fix shape — no
223+
// try/catch around the queued op — and asserting the rejection escapes) was
224+
// attempted here and deliberately removed: bun:test installs its own
225+
// `unhandledRejection` listener that fails whichever test is running the
226+
// instant one fires, regardless of what that test asserts, so a test
227+
// designed to prove an unhandled rejection *does* escape cannot pass in this
228+
// harness — it is intercepted before the assertion runs. That interception
229+
// is itself the strongest available evidence for the bug this fix removes:
230+
// the pre-fix `reloadIfIdle` body run through this exact harness fails the
231+
// suite outright (confirmed manually while writing this test), rather than
232+
// failing a single assertion. The test above is the harness-compatible half
233+
// of that pair: same real queue, same real helpers, proving the fixed shape
234+
// produces no such failure.

0 commit comments

Comments
 (0)