Skip to content

Commit 340bbe8

Browse files
committed
fix(tui): keep a snapshot write failure off the crash handler
1 parent fbd385c commit 340bbe8

2 files changed

Lines changed: 79 additions & 1 deletion

File tree

‎src/tui/runner/exit.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, spyOn, test } from "bun:test";
22
import { EventEmitter } from "node:events";
33
import { AgentContextLockError, type Agent } from "@intx/agent";
4+
import { getLogger } from "@intx/log";
45
import type { InferenceSource } from "@intx/types/runtime";
56

67
import * as codexSession from "../../auth/codex/session.js";
@@ -17,6 +18,7 @@ import {
1718
} from "../../subagent/session-store.js";
1819
import type { ReactorInboundEvent } from "@intx/types/runtime";
1920
import { defined } from "../../../testkit/defined.js";
21+
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
2022
import {
2123
stubReactorCapabilities,
2224
stubReactorState,
@@ -31,10 +33,12 @@ import {
3133
createDeliveryGeneration,
3234
createSessionOperationQueue,
3335
} from "../delivery-queue.js";
36+
import * as sessionState from "../../session/state.js";
3437
import {
3538
agentRebuildFailure,
3639
closeAgentForRebuild,
3740
createRunLifecycle,
41+
createRunPersistence,
3842
finalizeTUIRun,
3943
observeRecoveryAttempts,
4044
resetSessionForRotation,
@@ -976,6 +980,62 @@ describe("rebuild close helpers", () => {
976980
).toMatch(/restart/i);
977981
});
978982

983+
test("a rejecting saveState on the turn-boundary snapshot does not emit unhandledRejection", async () => {
984+
const save = spyOn(sessionState, "saveState").mockRejectedValue(
985+
new Error("disk full"),
986+
);
987+
const warn = spyOn(getLogger([LOG_NAMESPACE_ROOT, "tui"]), "warn");
988+
const state = {
989+
runTaskTitle: "t",
990+
liveSource: { id: "p", model: "m", credentialId: "cred" },
991+
startedAt: 1,
992+
connectedMcpServers: [],
993+
config: { cwd: "/tmp" },
994+
sessionId: "sess-boundary",
995+
} as unknown as RunnerState;
996+
const services = {
997+
runSink: { getTurnCount: () => 2 },
998+
activatedToolNames: { list: () => [] },
999+
activeRunHandle: {
1000+
sessionId: "sess-boundary",
1001+
cwd: "/tmp",
1002+
task: "t",
1003+
startedAt: 1,
1004+
turnsUsed: 0,
1005+
},
1006+
crashGuard: { isFinalized: () => false },
1007+
} as unknown as RunnerServices;
1008+
state.persistRunSnapshot = createRunPersistence(
1009+
state,
1010+
services,
1011+
).persistRunSnapshot;
1012+
1013+
let unhandled: unknown = null;
1014+
const onUnhandledRejection = (reason: unknown): void => {
1015+
unhandled = reason;
1016+
};
1017+
process.on("unhandledRejection", onUnhandledRejection);
1018+
try {
1019+
// session.ts onTurnBoundarySnapshot: fire-and-forget running snapshot.
1020+
void state.persistRunSnapshot?.("running");
1021+
await new Promise((resolve) => setTimeout(resolve, 0));
1022+
expect(unhandled).toBeNull();
1023+
expect(save).toHaveBeenCalled();
1024+
expect(warn).toHaveBeenCalledWith(
1025+
"saveState failed for session {sessionId} status={status}: {error}",
1026+
{
1027+
sessionId: "sess-boundary",
1028+
status: "running",
1029+
error: "disk full",
1030+
},
1031+
);
1032+
} finally {
1033+
process.off("unhandledRejection", onUnhandledRejection);
1034+
save.mockRestore();
1035+
warn.mockRestore();
1036+
}
1037+
});
1038+
9791039
// Overlay accept/decline tests stub bump() inside resolveSuspended, so
9801040
// deleting the interrupt-site bump would not fail them. Drive the interrupt
9811041
// helper itself.

‎src/tui/runner/exit.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
liveAgent,
5757
recordRunError,
5858
runWhileAgentBusy,
59+
sanitizeRunnerDiagnostic,
5960
type RunnerServices,
6061
type RunnerState,
6162
type SnapshotExtra,
@@ -251,7 +252,24 @@ export function createRunPersistence(
251252
kind: Exclude<SnapshotKind, "run-end"> = "progress",
252253
): Promise<void> => {
253254
if (services.crashGuard.isFinalized()) return;
254-
await writeRunSnapshot(status, extra, kind);
255+
try {
256+
await writeRunSnapshot(status, extra, kind);
257+
} catch (err: unknown) {
258+
// Turn boundaries and other progress sites fire this with `void`. A
259+
// rejecting saveState must not surface as unhandledRejection — the
260+
// process crash handler treats that as fatal.
261+
tuiLogger.warn(
262+
"saveState failed for session {sessionId} status={status}: {error}",
263+
{
264+
sessionId: state.sessionId,
265+
status,
266+
error: sanitizeRunnerDiagnostic(
267+
state,
268+
err instanceof Error ? err.message : String(err),
269+
),
270+
},
271+
);
272+
}
255273
};
256274

257275
// Heartbeat refreshes run.json mtime so a quiet live turn is not aged to

0 commit comments

Comments
 (0)