Skip to content

Commit 00e6738

Browse files
schickling-assistantmyobie
authored andcommitted
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 7534050 commit 00e6738

5 files changed

Lines changed: 289 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
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. Bundled CLI respawns inherit the already-held
27+
creation lock until the replacement publishes its socket.
28+
1929
### Attach-only CLI policy
2030

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

src/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
validateName,
2121
validateDisplayName,
2222
acquireLock,
23+
isLockOwnedByPid,
2324
releaseLock,
2425
updateTags,
2526
setDisplayName,
@@ -1546,7 +1547,13 @@ async function cmdRun(
15461547
process.exit(1);
15471548
}
15481549

1549-
if (!acquireLock(name)) {
1550+
const delegatedOwner = Number(process.env.PTY_CREATION_LOCK_OWNER_PID);
1551+
const inheritedCreationLock =
1552+
Number.isSafeInteger(delegatedOwner) &&
1553+
isLockOwnedByPid(name, delegatedOwner);
1554+
// This is a one-hop control value for the CLI process, not session env.
1555+
delete process.env.PTY_CREATION_LOCK_OWNER_PID;
1556+
if (!inheritedCreationLock && !acquireLock(name)) {
15501557
console.error(
15511558
`Session "${name}" is being created by another process. Try again.`
15521559
);
@@ -1578,7 +1585,7 @@ async function cmdRun(
15781585
...(extraEnvOpt && Object.keys(extraEnvOpt).length > 0 ? { extraEnv: extraEnvOpt } : {}),
15791586
});
15801587
} finally {
1581-
releaseLock(name);
1588+
if (!inheritedCreationLock) releaseLock(name);
15821589
}
15831590

15841591
console.log(`Session "${name}" created.`);

src/sessions.ts

Lines changed: 99 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
@@ -981,8 +1025,9 @@ export async function gc(
9811025
}
9821026

9831027
try {
984-
await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping);
985-
respawned.push({ name: s.name, ptyfileReread });
1028+
if (await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping)) {
1029+
respawned.push({ name: s.name, ptyfileReread });
1030+
}
9861031
} catch (err: any) {
9871032
respawnFailed.push({ name: s.name, error: err?.message ?? String(err) });
9881033
}
@@ -1007,8 +1052,11 @@ export async function gc(
10071052
kept.push(s.name);
10081053
continue;
10091054
}
1010-
if (!dryRun) cleanupAll(s.name);
1011-
removed.push(s.name);
1055+
if (dryRun) {
1056+
removed.push(s.name);
1057+
} else if (await cleanupObservedSession(s)) {
1058+
removed.push(s.name);
1059+
}
10121060
}
10131061

10141062
return {
@@ -1208,12 +1256,14 @@ function classifyFlapping(
12081256
*
12091257
* Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't
12101258
* bite at module-init time. After spawn, appends a `session_respawn`
1211-
* event to the session's event log so consumers see the restart. */
1212-
async function respawnPermanent(
1259+
* event to the session's event log so consumers see the restart.
1260+
*
1261+
* @internal Generation-CAS primitive; not part of client-api. */
1262+
export async function respawnPermanent(
12131263
name: string,
12141264
metadata: SessionMetadata,
12151265
bookkeepingOverlay: Record<string, string> = {},
1216-
): Promise<void> {
1266+
): Promise<boolean> {
12171267
let command = metadata.command;
12181268
let args = metadata.args;
12191269
let displayCommand = metadata.displayCommand;
@@ -1262,22 +1312,36 @@ async function respawnPermanent(
12621312
delete tags["strategy.status"];
12631313
}
12641314

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

12821346
// Best-effort event; respawn already succeeded if we got here.
12831347
try {
@@ -1287,6 +1351,7 @@ async function respawnPermanent(
12871351
ts: new Date().toISOString(),
12881352
});
12891353
} catch {}
1354+
return true;
12901355
}
12911356

12921357
/**
@@ -1519,6 +1584,17 @@ function getLockPath(name: string): string {
15191584
return path.join(getSessionDir(), `${name}.lock`);
15201585
}
15211586

1587+
/** @internal Verify an explicitly delegated creation lock without acquiring it. */
1588+
export function isLockOwnedByPid(name: string, ownerPid: number): boolean {
1589+
if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return false;
1590+
try {
1591+
return parseInt(fs.readFileSync(getLockPath(name), "utf-8").trim(), 10) === ownerPid &&
1592+
isProcessAlive(ownerPid);
1593+
} catch {
1594+
return false;
1595+
}
1596+
}
1597+
15221598
/**
15231599
* Acquire an exclusive lock for a session name. Prevents concurrent
15241600
* `pty run` calls from racing to create the same session.

src/spawn.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ export interface SpawnDaemonOptions {
9393
* path can't express it (a bundled consumer that hits the fallback also
9494
* isn't the operator-restart context this guards). */
9595
scrubEnv?: string[];
96+
/** @internal PID owning an already-held per-name creation lock. Used only
97+
* when a lifecycle operation must keep its CAS lock across CLI fallback. */
98+
creationLockOwnerPid?: number;
9699
}
97100

98101
/** Default time we wait for a daemon's Unix socket to appear after
@@ -236,9 +239,17 @@ function spawnViaCli(options: SpawnDaemonOptions): Promise<void> {
236239
}
237240
cliArgs.push("--", options.command, ...options.args);
238241

242+
const env = { ...process.env };
243+
if (options.creationLockOwnerPid !== undefined) {
244+
env.PTY_CREATION_LOCK_OWNER_PID = String(options.creationLockOwnerPid);
245+
} else {
246+
delete env.PTY_CREATION_LOCK_OWNER_PID;
247+
}
248+
239249
const result = spawnSync("pty", cliArgs, {
240250
stdio: ["ignore", "pipe", "pipe"],
241251
encoding: "utf-8",
252+
env,
242253
});
243254
if (result.error !== undefined) {
244255
const err = result.error as NodeJS.ErrnoException;

0 commit comments

Comments
 (0)