|
9 | 9 | loadLocalSettingsWriteBase, |
10 | 10 | resumeTranscriptLoadErrorBlock, |
11 | 11 | } from "../../../src/tui/runner.js"; |
| 12 | +import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js"; |
12 | 13 | import { createRunSink } from "../../../src/session/run-sink.js"; |
13 | 14 |
|
14 | 15 | test("createTUIEventEmitter returns an EventEmitter", () => { |
@@ -160,3 +161,74 @@ test("a failed close followed by a lock error never surfaces as a raw AgentConte |
160 | 161 | expect(rebuildError).not.toBeInstanceOf(AgentContextLockError); |
161 | 162 | expect(rebuildError!.message).toMatch(/restart/i); |
162 | 163 | }); |
| 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