Skip to content

Commit cc1873d

Browse files
committed
Abort TUI quit before awaiting the session-op tail
Stop workers first so a hung session-op cannot delay abort and reap. Log shutdown failures at error while still mapping teardown failure to exit 1.
1 parent a3cc170 commit cc1873d

2 files changed

Lines changed: 119 additions & 4 deletions

File tree

src/tui/runner/exit.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, spyOn, test } from "bun:test";
2+
import { getLogger } from "@intx/log";
3+
4+
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
5+
import { finalizeTUIRun } from "./exit.js";
6+
import type { RunnerServices, RunnerState } from "./state.js";
7+
8+
function stubQuit(args: { awaitTail: () => Promise<void>; shutdownRuntime: () => Promise<void> }): {
9+
state: RunnerState;
10+
services: RunnerServices;
11+
} {
12+
const state = {
13+
host: {
14+
waitUntilExit: async () => undefined,
15+
},
16+
shutdownRuntime: args.shutdownRuntime,
17+
runError: undefined,
18+
streamPromise: Promise.resolve(),
19+
config: { cwd: "/tmp", task: "t" },
20+
sessionId: "s",
21+
startedAt: 1,
22+
runTaskTitle: "t",
23+
connectedMcpServers: [],
24+
liveSource: { id: "p", model: "m" },
25+
} as unknown as RunnerState;
26+
const services = {
27+
sessionOps: {
28+
enqueue: async () => undefined,
29+
awaitTail: args.awaitTail,
30+
},
31+
cycleRecorder: { dispose: async () => "" },
32+
mcpConnectController: new AbortController(),
33+
runSink: {
34+
getTurnCollector: () => null,
35+
getRunError: () => undefined,
36+
getStatus: () => "done",
37+
getTurnCount: () => 0,
38+
getTokenUsage: () => ({
39+
input: 0,
40+
output: 0,
41+
cacheRead: 0,
42+
cacheWrite: 0,
43+
thinking: 0,
44+
}),
45+
getToolCallCount: () => 0,
46+
},
47+
crashGuard: { markFinalized: () => undefined, isFinalized: () => false },
48+
activeRunHandle: { task: "", startedAt: 0, model: "" },
49+
hookManager: { dispatchPostRun: async () => undefined },
50+
liveSessionMode: "orchestrator",
51+
} as unknown as RunnerServices;
52+
return { state, services };
53+
}
54+
55+
describe("finalizeTUIRun quit order", () => {
56+
test("starts runtime shutdown without waiting on a hung session-op tail", async () => {
57+
const order: string[] = [];
58+
let settleTail!: (err: Error) => void;
59+
const hungTail = new Promise<void>((_, reject) => {
60+
settleTail = reject;
61+
});
62+
const { state, services } = stubQuit({
63+
awaitTail: async () => {
64+
order.push("tail");
65+
await hungTail;
66+
},
67+
shutdownRuntime: async () => {
68+
order.push("shutdown");
69+
},
70+
});
71+
72+
const pending = finalizeTUIRun(state, services);
73+
try {
74+
await new Promise((resolve) => setTimeout(resolve, 50));
75+
expect(order[0]).toBe("shutdown");
76+
} finally {
77+
settleTail(new Error("stop"));
78+
}
79+
await expect(pending).rejects.toThrow("stop");
80+
});
81+
82+
test("logs a runtime shutdown failure instead of swallowing it", async () => {
83+
const logger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
84+
const errorSpy = spyOn(logger, "error");
85+
let settleTail!: (err: Error) => void;
86+
const hungTail = new Promise<void>((_, reject) => {
87+
settleTail = reject;
88+
});
89+
const { state, services } = stubQuit({
90+
awaitTail: () => hungTail,
91+
shutdownRuntime: async () => {
92+
throw new Error("plugin dispose failed");
93+
},
94+
});
95+
96+
const pending = finalizeTUIRun(state, services);
97+
try {
98+
await new Promise((resolve) => setTimeout(resolve, 50));
99+
expect(errorSpy).toHaveBeenCalled();
100+
const logged = errorSpy.mock.calls as unknown as readonly (readonly unknown[])[];
101+
const first = logged[0];
102+
expect(first).toBeDefined();
103+
expect(String(first?.[0])).toMatch(/shutdown/i);
104+
expect(first?.[1]).toEqual({ error: "plugin dispose failed" });
105+
} finally {
106+
errorSpy.mockRestore();
107+
settleTail(new Error("stop"));
108+
}
109+
await expect(pending).rejects.toThrow("stop");
110+
});
111+
});

src/tui/runner/exit.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -525,17 +525,21 @@ export async function finalizeTUIRun(
525525
services: RunnerServices,
526526
): Promise<number> {
527527
await hostOf(state).waitUntilExit();
528-
await services.sessionOps.awaitTail();
529-
// Stop inference and every worker before persistence, hooks, or telemetry can
530-
// delay process exit. Closing the terminal is a process-lifetime boundary.
528+
// Stop workers before awaiting the session-op tail so a hung enqueue cannot
529+
// delay abort/reap. Persistence, hooks, and telemetry stay after stop.
531530
// Toolset dispose lives inside shutdownRuntime so quit, crash, and signals
532531
// share one owner.
533532
let teardownFailed = false;
534533
try {
535534
await state.shutdownRuntime?.();
536-
} catch {
535+
} catch (err) {
537536
teardownFailed = true;
537+
tuiLogger.error("runtime shutdown failed: {error}", {
538+
error: err instanceof Error ? err.message : String(err),
539+
});
538540
}
541+
await services.sessionOps.awaitTail();
542+
539543
state.stopFleetReporting?.();
540544
// Quitting mid-stream is an abnormal end for the in-flight cycle: nothing
541545
// downstream delivers its terminal event once the app is gone.

0 commit comments

Comments
 (0)