Skip to content

Commit c25ae83

Browse files
Merge pull request #1478 from corbitsdev/cl-10757-session-persistence-hardening
feat(session): gate saveCrashState on crashed state
2 parents 25998b8 + c4fd097 commit c25ae83

3 files changed

Lines changed: 53 additions & 5 deletions

File tree

‎src/session/active-run.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ export function isCrashed(): boolean {
8484
return crashed;
8585
}
8686

87+
// Test-only seam: the crashed flag is process-global and sticky by design, so
88+
// a test that flips it must restore the not-crashed state — otherwise every
89+
// later saveState chain in the test process would no-op. Follows the
90+
// setTestWriteGate precedent below.
91+
export function resetCrashedForTests(): void {
92+
crashed = false;
93+
}
94+
8795
// Test-only seam: lets an integration test hold a chained write open past
8896
// markCrashed(), so it can deterministically prove a write still queued in
8997
// the chain sees isCrashed() before it fires — rather than hoping filesystem

‎src/session/state.test.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ await withMockedModule(
2121
}),
2222
);
2323

24-
const { finalizeRunState, loadState, saveState } = await import("./state.js");
25-
const { getActiveRun, setActiveRun } = await import("./active-run.js");
24+
const { finalizeRunState, loadState, saveCrashState, saveState } =
25+
await import("./state.js");
26+
const { getActiveRun, markCrashed, resetCrashedForTests, setActiveRun } =
27+
await import("./active-run.js");
2628
type RunState = import("./state.js").RunState;
2729

2830
let cwd = "";
@@ -105,3 +107,34 @@ test("saveState calls for different sessions do not block each other", async ()
105107
expect(a).toMatchObject({ kind: "ok", state: { task: "a" } });
106108
expect(b).toMatchObject({ kind: "ok", state: { task: "b" } });
107109
});
110+
111+
test("saveCrashState fails closed without the crash fence and lands nothing", async () => {
112+
const sessionId = "sess-gated";
113+
await expect(
114+
saveCrashState(cwd, sessionId, state({ status: "crashed" }), home),
115+
).rejects.toThrow("not in crashed state");
116+
expect(await loadState(cwd, sessionId, home)).toMatchObject({
117+
kind: "missing",
118+
});
119+
});
120+
121+
test("saveCrashState lands the terminal record once crashed", async () => {
122+
const sessionId = "sess-crash-lands";
123+
markCrashed();
124+
try {
125+
await saveCrashState(
126+
cwd,
127+
sessionId,
128+
state({ status: "crashed", finishedAt: 7, error: "boom" }),
129+
home,
130+
);
131+
} finally {
132+
// The flag is process-global and sticky: restore it so later tests in
133+
// this process keep their saveState chains live.
134+
resetCrashedForTests();
135+
}
136+
expect(await loadState(cwd, sessionId, home)).toMatchObject({
137+
kind: "ok",
138+
state: { status: "crashed", finishedAt: 7, error: "boom" },
139+
});
140+
});

‎src/session/state.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,11 @@ export async function finalizeRunState(
205205
}
206206

207207
// Crash-time terminal write that bypasses writeChains: awaiting a hung queued
208-
// write would block the crash handler's process.exit. Callers must call
209-
// markCrashed() (src/session/active-run.ts) first so queued snapshot writes
210-
// step aside instead of racing this rename().
208+
// write would block the crash handler's process.exit. The crash fence is
209+
// enforced here, not left to caller discipline: both production callers
210+
// (process-handlers.ts handleFatal and the signal handlers) flip it via
211+
// markCrashed() before finalizing, and any other caller fails closed instead
212+
// of landing a terminal record that queued snapshot writes may then clobber.
211213
//
212214
// Second terminal path, separate on purpose: only index.ts's process-level
213215
// uncaughtException/unhandledRejection and signal handlers reach it (a crash
@@ -219,6 +221,11 @@ export async function saveCrashState(
219221
state: RunState,
220222
home?: string,
221223
): Promise<void> {
224+
if (!isCrashed()) {
225+
throw new Error(
226+
`saveCrashState refused for session ${sessionId}: not in crashed state (call markCrashed() first)`,
227+
);
228+
}
222229
const path = statePath(cwd, sessionId, home);
223230
await atomicWrite(path, JSON.stringify(state, null, 2));
224231
}

0 commit comments

Comments
 (0)