@@ -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/**
0 commit comments