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