Skip to content

Commit cc1f635

Browse files
committed
Serialize run.json writes per session to survive stragglers
Concurrent saveState calls for one session had no ordering guarantee on their underlying rename()s, so a late progress snapshot could land after the terminal finalize write and flip a finished session back to status: "running" with no finishedAt. Chain writes per sessionId so they always apply in call order. Documents why runner.ts's finalized flag still earns its place now that saveState serializes writes per session: the write chain only orders writes that are already issued, it has no way to know a stale post-finalize snapshot shouldn't be issued at all.
1 parent cf3bb84 commit cc1f635

3 files changed

Lines changed: 114 additions & 3 deletions

File tree

src/session/state.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
2+
import * as realFs from "node:fs/promises";
3+
import { mkdir, rm } from "node:fs/promises";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
7+
// Simulates the straggler write's real await point (e.g. cycleRecorder.dispose
8+
// during the terminal path) landing its writeFile after a later-issued
9+
// terminal write's writeFile, so rename-order alone would let it win.
10+
const realWriteFile = realFs.writeFile;
11+
let delayNextWrite = false;
12+
mock.module("node:fs/promises", () => ({
13+
...realFs,
14+
writeFile: async (path: string, data: string) => {
15+
if (delayNextWrite) {
16+
delayNextWrite = false;
17+
await new Promise((resolve) => setTimeout(resolve, 30));
18+
}
19+
return realWriteFile(path, data);
20+
},
21+
}));
22+
23+
const { loadState, saveState } = await import("./state.js");
24+
type RunState = Awaited<ReturnType<typeof loadState>>;
25+
26+
let cwd = "";
27+
let home = "";
28+
29+
beforeEach(async () => {
30+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
31+
cwd = join(tmpdir(), `corbits-state-${stamp}`);
32+
home = join(tmpdir(), `corbits-state-home-${stamp}`);
33+
await mkdir(cwd, { recursive: true });
34+
await mkdir(home, { recursive: true });
35+
});
36+
37+
afterEach(async () => {
38+
await rm(cwd, { recursive: true, force: true });
39+
await rm(home, { recursive: true, force: true });
40+
});
41+
42+
function state(overrides: Partial<NonNullable<RunState>>): NonNullable<RunState> {
43+
return {
44+
status: "running",
45+
turnsUsed: 0,
46+
task: "task",
47+
startedAt: 1,
48+
...overrides,
49+
};
50+
}
51+
52+
test("a straggler snapshot started before a terminal write does not overwrite it", async () => {
53+
const sessionId = "sess-race";
54+
55+
delayNextWrite = true;
56+
const straggler = saveState(cwd, sessionId, state({ status: "running" }), home);
57+
const terminal = saveState(cwd, sessionId, state({ status: "done", finishedAt: 999 }), home);
58+
59+
await Promise.all([straggler, terminal]);
60+
61+
const final = await loadState(cwd, sessionId, home);
62+
expect(final?.status).toBe("done");
63+
expect(final?.finishedAt).toBe(999);
64+
});
65+
66+
test("saveState calls for different sessions do not block each other", async () => {
67+
await Promise.all([
68+
saveState(cwd, "session-a", state({ task: "a" }), home),
69+
saveState(cwd, "session-b", state({ task: "b" }), home),
70+
]);
71+
72+
const a = await loadState(cwd, "session-a", home);
73+
const b = await loadState(cwd, "session-b", home);
74+
expect(a?.task).toBe("a");
75+
expect(b?.task).toBe("b");
76+
});

src/session/state.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,40 @@ export function warnUnreadableState(path: string, reason: string): void {
5555
process.stderr.write(`${COMMAND_NAME}: ignoring unreadable state at ${path} (${reason}); starting fresh\n`);
5656
}
5757

58+
// Concurrent saveState calls for the same session (a straggler progress
59+
// snapshot racing a terminal finalize write) have no ordering guarantee
60+
// between their underlying rename()s — the later call could still finish
61+
// first and resurrect a closed run.json as "running". Chaining each session's
62+
// writes onto the previous one forces them to apply in call order, so a
63+
// write issued after another always lands after it regardless of how long
64+
// either write's fs calls take. Keyed by sessionId, not path, since callers
65+
// only ever address one file per session.
66+
const writeChains = new Map<string, Promise<void>>();
67+
5868
export async function saveState(
5969
cwd: string,
6070
sessionId: string,
6171
state: RunState,
6272
home?: string,
6373
): Promise<void> {
64-
await atomicWrite(statePath(cwd, sessionId, home), JSON.stringify(state, null, 2));
74+
const path = statePath(cwd, sessionId, home);
75+
const content = JSON.stringify(state, null, 2);
76+
const previous = writeChains.get(sessionId) ?? Promise.resolve();
77+
const write = previous.then(
78+
() => atomicWrite(path, content),
79+
() => atomicWrite(path, content),
80+
);
81+
// Swallow the error in the chain tail (not in `write`, which still rejects
82+
// for this caller) so one failed save doesn't permanently wedge later
83+
// saves for the same session.
84+
const tail = write.catch(() => {});
85+
writeChains.set(sessionId, tail);
86+
// Once this is the last write for the session, drop the entry so a
87+
// long-lived process doesn't retain a chain per session forever.
88+
void tail.then(() => {
89+
if (writeChains.get(sessionId) === tail) writeChains.delete(sessionId);
90+
});
91+
return write;
6592
}
6693

6794

src/tui/runner.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
447447
// out run.json so status and finishedAt never disagree. Declared before the
448448
// try so every fallible step after the minimal write above is covered.
449449
// `finalized` is set by the normal finalize path so this never double-writes
450-
// on a clean exit; it also gates straggler snapshot writes (see
451-
// persistRunSnapshot) from resurrecting a closed record.
450+
// on a clean exit. It also gates persistRunSnapshot (below) from *issuing*
451+
// a straggler write at all once the run is closed — a different job from
452+
// saveState's per-session write ordering in state.ts. That ordering only
453+
// decides which already-issued write lands last; it has no way to know a
454+
// "running" snapshot fired after finalize is stale and should never be
455+
// written in the first place. Without this flag such a snapshot would
456+
// still queue behind the terminal write and legitimately "win" the
457+
// ordering, resurrecting a closed run.json. Two different constraints
458+
// (don't issue a stale write vs. order the writes you do issue), each
459+
// owned by its own layer — not a duplicate check.
452460
let finalized = false;
453461
// Bound after the cycle recorder exists (it needs the session workdir); the
454462
// crash guard is declared first so it covers every fallible step below.

0 commit comments

Comments
 (0)