Skip to content

Commit 1bcc72b

Browse files
fix(gc): guard cleanup by observed generation
agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f
1 parent e0db781 commit 1bcc72b

3 files changed

Lines changed: 183 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
when metadata is missing or malformed, and revalidates it while holding the
1717
per-name creation lock before removal.
1818

19+
### Generation-guarded gc cleanup and respawn
20+
21+
- Residual sweep and permanent respawn now compare the generation observed by
22+
gc with the current metadata while holding the per-name creation lock.
23+
Cleanup is skipped when another process replaced or edited the record after
24+
gc's snapshot, preventing a stale pass from unlinking or respawning over the
25+
replacement. Legacy generation-less records use exact metadata equality as a
26+
conservative fallback.
27+
1928
### Attach-only CLI policy
2029

2130
- `pty attach --no-restart <ref>` attaches only to a currently running

src/sessions.ts

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,50 @@ export async function cleanupRawCandidateGuarded(
449449
}
450450
}
451451

452+
function metadataMatchesObservation(
453+
observed: SessionMetadata,
454+
current: SessionMetadata,
455+
): boolean {
456+
if (observed.generation !== undefined || current.generation !== undefined) {
457+
return observed.generation !== undefined &&
458+
observed.generation === current.generation;
459+
}
460+
// Legacy records have no generation token. Exact structural equality is a
461+
// conservative fallback: any intervening tag/launch/exit update makes the
462+
// observation stale and suppresses cleanup.
463+
return JSON.stringify(observed) === JSON.stringify(current);
464+
}
465+
466+
function cleanupAllWhileLocked(name: string): void {
467+
cleanupSocket(name);
468+
try {
469+
fs.unlinkSync(getMetadataPath(name));
470+
} catch {}
471+
try {
472+
fs.unlinkSync(getEventsPath(name));
473+
} catch {}
474+
}
475+
476+
/** @internal Generation-CAS cleanup primitive; not part of client-api. */
477+
export async function cleanupObservedSession(
478+
session: SessionInfo,
479+
): Promise<boolean> {
480+
if (!session.metadata || !acquireLock(session.name)) return false;
481+
try {
482+
const current = readMetadata(session.name);
483+
if (
484+
!current ||
485+
!metadataMatchesObservation(session.metadata, current)
486+
) {
487+
return false;
488+
}
489+
cleanupAllWhileLocked(session.name);
490+
return true;
491+
} finally {
492+
releaseLock(session.name);
493+
}
494+
}
495+
452496
/** Return one bounded, read-only observation of the session registry.
453497
*
454498
* This function deliberately performs no lifecycle work: it does not create
@@ -980,8 +1024,9 @@ export async function gc(
9801024
}
9811025

9821026
try {
983-
await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping);
984-
respawned.push({ name: s.name, ptyfileReread });
1027+
if (await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping)) {
1028+
respawned.push({ name: s.name, ptyfileReread });
1029+
}
9851030
} catch (err: any) {
9861031
respawnFailed.push({ name: s.name, error: err?.message ?? String(err) });
9871032
}
@@ -1006,8 +1051,11 @@ export async function gc(
10061051
kept.push(s.name);
10071052
continue;
10081053
}
1009-
if (!dryRun) cleanupAll(s.name);
1010-
removed.push(s.name);
1054+
if (dryRun) {
1055+
removed.push(s.name);
1056+
} else if (await cleanupObservedSession(s)) {
1057+
removed.push(s.name);
1058+
}
10111059
}
10121060

10131061
return {
@@ -1207,12 +1255,14 @@ function classifyFlapping(
12071255
*
12081256
* Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't
12091257
* bite at module-init time. After spawn, appends a `session_respawn`
1210-
* event to the session's event log so consumers see the restart. */
1211-
async function respawnPermanent(
1258+
* event to the session's event log so consumers see the restart.
1259+
*
1260+
* @internal Generation-CAS primitive; not part of client-api. */
1261+
export async function respawnPermanent(
12121262
name: string,
12131263
metadata: SessionMetadata,
12141264
bookkeepingOverlay: Record<string, string> = {},
1215-
): Promise<void> {
1265+
): Promise<boolean> {
12161266
let command = metadata.command;
12171267
let args = metadata.args;
12181268
let displayCommand = metadata.displayCommand;
@@ -1261,22 +1311,35 @@ async function respawnPermanent(
12611311
delete tags["strategy.status"];
12621312
}
12631313

1264-
// Wipe stale socket/pid/events before respawn so spawnDaemon doesn't
1265-
// trip over leftovers from the dead daemon. Metadata is recreated by
1266-
// spawnDaemon.
1267-
cleanupAll(name);
1268-
1269-
const { spawnDaemon } = await import("./spawn.ts");
1270-
await spawnDaemon({
1271-
name, command, args, displayCommand, cwd, tags,
1272-
...(displayName ? { displayName } : {}),
1273-
...(metadata.rows !== undefined ? { rows: metadata.rows } : {}),
1274-
...(metadata.cols !== undefined ? { cols: metadata.cols } : {}),
1275-
...(metadata.ephemeral !== undefined ? { ephemeral: metadata.ephemeral } : {}),
1276-
...(metadata.isolateEnv ? { isolateEnv: true } : {}),
1277-
...(extraEnv && Object.keys(extraEnv).length > 0 ? { extraEnv } : {}),
1278-
...(exactEnv ? { env: exactEnv } : {}),
1279-
});
1314+
// Serialize compare-and-swap cleanup + replacement creation under the same
1315+
// per-name lock. If the observed generation changed while gc was planning,
1316+
// this tick is stale and must not touch the replacement.
1317+
if (!acquireLock(name)) return false;
1318+
try {
1319+
const current = readMetadata(name);
1320+
if (!current || !metadataMatchesObservation(metadata, current)) {
1321+
return false;
1322+
}
1323+
1324+
// Wipe stale socket/pid/events before respawn so spawnDaemon doesn't trip
1325+
// over leftovers from the dead daemon. Keep the creation lock held until
1326+
// the replacement has published its socket.
1327+
cleanupAllWhileLocked(name);
1328+
1329+
const { spawnDaemon } = await import("./spawn.ts");
1330+
await spawnDaemon({
1331+
name, command, args, displayCommand, cwd, tags,
1332+
...(displayName ? { displayName } : {}),
1333+
...(metadata.rows !== undefined ? { rows: metadata.rows } : {}),
1334+
...(metadata.cols !== undefined ? { cols: metadata.cols } : {}),
1335+
...(metadata.ephemeral !== undefined ? { ephemeral: metadata.ephemeral } : {}),
1336+
...(metadata.isolateEnv ? { isolateEnv: true } : {}),
1337+
...(extraEnv && Object.keys(extraEnv).length > 0 ? { extraEnv } : {}),
1338+
...(exactEnv ? { env: exactEnv } : {}),
1339+
});
1340+
} finally {
1341+
releaseLock(name);
1342+
}
12801343

12811344
// Best-effort event; respawn already succeeded if we got here.
12821345
try {
@@ -1286,6 +1349,7 @@ async function respawnPermanent(
12861349
ts: new Date().toISOString(),
12871350
});
12881351
} catch {}
1352+
return true;
12891353
}
12901354

12911355
/**

tests/gc-generation-guard.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
cleanupObservedSession,
7+
respawnPermanent,
8+
type SessionInfo,
9+
type SessionMetadata,
10+
} from "../src/sessions.ts";
11+
12+
const roots: string[] = [];
13+
14+
const makeRoot = () => {
15+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gc-generation-"));
16+
roots.push(root);
17+
process.env.PTY_ROOT = root;
18+
return root;
19+
};
20+
21+
const metadata = (
22+
root: string,
23+
generation: string,
24+
tags?: Record<string, string>,
25+
): SessionMetadata => ({
26+
generation,
27+
daemonPid: 2147483647,
28+
command: "true",
29+
args: [],
30+
displayCommand: "true",
31+
cwd: root,
32+
createdAt: "2026-01-01T00:00:00.000Z",
33+
exitedAt: "2026-01-01T00:00:01.000Z",
34+
exitCode: 0,
35+
tags,
36+
});
37+
38+
afterEach(() => {
39+
delete process.env.PTY_ROOT;
40+
for (const root of roots.splice(0)) {
41+
fs.rmSync(root, { recursive: true, force: true });
42+
}
43+
});
44+
45+
describe("gc generation compare-and-swap", () => {
46+
it("does not residual-sweep a replacement generation", async () => {
47+
const root = makeRoot();
48+
const name = "residual";
49+
const observed = metadata(root, "old");
50+
fs.writeFileSync(
51+
path.join(root, `${name}.json`),
52+
JSON.stringify(metadata(root, "replacement")),
53+
);
54+
const session: SessionInfo = {
55+
name,
56+
socketPath: path.join(root, `${name}.sock`),
57+
pid: null,
58+
status: "exited",
59+
metadata: observed,
60+
};
61+
62+
const removed = await cleanupObservedSession(session);
63+
64+
expect(removed).toBe(false);
65+
expect(JSON.parse(
66+
fs.readFileSync(path.join(root, `${name}.json`), "utf-8"),
67+
).generation).toBe("replacement");
68+
});
69+
70+
it("does not respawn over a replacement generation", async () => {
71+
const root = makeRoot();
72+
const name = "permanent";
73+
const tags = { strategy: "permanent" };
74+
const observed = metadata(root, "old", tags);
75+
fs.writeFileSync(
76+
path.join(root, `${name}.json`),
77+
JSON.stringify(metadata(root, "replacement", tags)),
78+
);
79+
80+
const respawned = await respawnPermanent(name, observed);
81+
82+
expect(respawned).toBe(false);
83+
expect(JSON.parse(
84+
fs.readFileSync(path.join(root, `${name}.json`), "utf-8"),
85+
).generation).toBe("replacement");
86+
});
87+
});

0 commit comments

Comments
 (0)