@gotcos/glasses-server 6.53.0 → 6.53.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,8 +65,8 @@
65
65
  // nothing may be sent again by COS on its own.
66
66
  //
67
67
  // Only the first two are provably safe to report as a clean failure. `cancelled` is not
68
- // a clean failure either, but it is not a fence: a fence exists to stop COS resending a
69
- // turn nobody knows the fate of, and a cancelled turn is one the user chose to end.
68
+ // a clean failure either. It avoids a fence only when the whole spawned process tree was
69
+ // positively reaped; doubt or a surviving member is ambiguous and fences fail-closed.
70
70
  // `attachedDeliveryAmbiguous` makes that mapping total, and treats any state it
71
71
  // does not recognise as ambiguous, so adding a state later cannot silently open
72
72
  // the replay path.
@@ -93,12 +93,12 @@
93
93
  import { accessSync, constants as fsConstants, statSync } from 'node:fs'
94
94
  import { delimiter, isAbsolute, join } from 'node:path'
95
95
  import { homedir } from 'node:os'
96
- import { spawn as nodeSpawn } from 'node:child_process'
96
+ import { execFile, execFileSync, spawn as nodeSpawn } from 'node:child_process'
97
97
 
98
98
  import { isValidNativeThreadId } from './native-thread-id.js'
99
99
  import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
100
100
  import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
101
- import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
101
+ import { interpretPsLstart, processStartMs as realProcessStartMs } from './occupancy-probes.js'
102
102
  import { getCodexTrustMode } from './codex-run-ledger.js'
103
103
  import { CURSOR_SLOT_MODEL_IDS } from './cursor-model-catalog.js'
104
104
 
@@ -316,6 +316,17 @@ export interface AttachedTurnDeps {
316
316
  * process group on the developer's machine.
317
317
  */
318
318
  terminate: (child: AttachedChildProcess, signal: NodeJS.Signals) => void
319
+ /**
320
+ * True while any verified member of the provider process tree is still live.
321
+ * Unknown/probe failure MUST read as true: a cancellation may only be reported
322
+ * reaped after the whole tree is positively gone.
323
+ *
324
+ * 6.53.3: may answer with a promise, and the production one does: it reads the process
325
+ * table with an async `ps`, so the poll that waits for a cancelled tree no longer stalls
326
+ * the event loop ~43 ms per read. A plain boolean is still accepted. Anything but an
327
+ * exact `false` (a rejection, `undefined`, a throw) reads as alive.
328
+ */
329
+ processTreeAlive: (child: AttachedChildProcess) => boolean | PromiseLike<boolean>
319
330
  /**
320
331
  * Argv builder. Optional, defaults to the real one.
321
332
  *
@@ -395,6 +406,26 @@ export const CANCEL_KILL_GRACE_MS = 5_000
395
406
  */
396
407
  export const FORCE_SETTLE_MS = 2_000
397
408
 
409
+ /**
410
+ * 6.53.3: how often a cancelled or timed-out turn re-reads the process table after the CLI
411
+ * closed while part of its tree still lives.
412
+ *
413
+ * Polling, not waiting out the grace, is the point: a tool that exits one second after
414
+ * SIGTERM used to hold the result for the whole five-second cancel grace (measured 5.3 s).
415
+ * 200 ms keeps that to about one second while costing one async `ps` per tick; 6.53.2 ran
416
+ * a blocking `ps` every 25 ms, up to ~29 reads in the two seconds after SIGKILL.
417
+ */
418
+ export const TREE_POLL_MS = 200
419
+
420
+ /** 6.53.3: the first re-read after SIGKILL. The kernel needs a moment to tear the tree down. */
421
+ const TREE_POLL_AFTER_KILL_MS = 25
422
+
423
+ /**
424
+ * 6.53.3: the last read at the force-settle deadline gets this long to answer. A probe that
425
+ * never answers must not wedge the route's claim, so the cap settles unreaped instead.
426
+ */
427
+ export const FINAL_PROBE_CAP_MS = 1_500
428
+
398
429
  /**
399
430
  * Prompt ceiling. Generous — this is a guard against a pathological or
400
431
  * corrupted value reaching a pipe, not a product limit.
@@ -806,8 +837,10 @@ async function run(
806
837
  }
807
838
  // 6.53.0: a cancel that landed before the spawn. No process is created, so nothing can
808
839
  // have been delivered; the turn ends `cancelled`, which the route never fences.
840
+ // 6.53.3: and `reaped: true`, because no process exists to survive. A reader that asks
841
+ // "is anything of this turn still running?" gets the true answer, not the fail() default.
809
842
  if (request.abortSignal?.aborted === true) {
810
- return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', durationMs: duration() })
843
+ return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', reaped: true, durationMs: duration() })
811
844
  }
812
845
  let child: AttachedChildProcess
813
846
  try {
@@ -878,6 +911,25 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
878
911
  }
879
912
  }
880
913
 
914
+ /**
915
+ * 6.53.3: resolves true unless the probe positively answered `false`.
916
+ *
917
+ * Never rejects. A failed ownership probe is not proof that the process tree is gone, and
918
+ * neither is a malformed answer: 6.53.2 read anything but `true` as dead, so a probe that
919
+ * returned `undefined` would have reported a reaped tree.
920
+ */
921
+ function probeProcessTree(deps: AttachedTurnDeps, child: AttachedChildProcess): Promise<boolean> {
922
+ try {
923
+ const answer = deps.processTreeAlive(child)
924
+ if (answer !== null && typeof answer === 'object' && typeof (answer as PromiseLike<boolean>).then === 'function') {
925
+ return Promise.resolve(answer).then(alive => alive !== false, () => true)
926
+ }
927
+ return Promise.resolve(answer !== false)
928
+ } catch {
929
+ return Promise.resolve(true)
930
+ }
931
+ }
932
+
881
933
  function safeRelease(deps: AttachedTurnDeps, pid: number): void {
882
934
  try {
883
935
  deps.releaseSpawn(pid)
@@ -913,17 +965,28 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
913
965
  let timedOut = false
914
966
  /** 6.53.0: the caller's abort fired after the child existed. Read before `timedOut`. */
915
967
  let cancelled = false
968
+ /**
969
+ * 6.53.3: that abort landed before the prompt write. The result keeps `delivery:
970
+ * 'aborted'` (no byte exists) but still waits for the tree like any other cancel.
971
+ */
972
+ let abortedBeforeWrite = false
916
973
  let spawnErrored = false
917
974
  let onAbort: (() => void) | null = null
975
+ /** 6.53.3: one async process-table read at a time; a request during one reruns after it. */
976
+ let probeInFlight = false
977
+ let probeAgain = false
918
978
 
919
979
  let deadline: ReturnType<typeof setTimeout> | null = null
920
980
  let graceTimer: ReturnType<typeof setTimeout> | null = null
921
981
  let forceTimer: ReturnType<typeof setTimeout> | null = null
982
+ let treePollTimer: ReturnType<typeof setTimeout> | null = null
983
+ let terminalObserved = false
922
984
 
923
985
  const clearTimers = () => {
924
986
  if (deadline) { clearTimeout(deadline); deadline = null }
925
987
  if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
926
988
  if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
989
+ if (treePollTimer) { clearTimeout(treePollTimer); treePollTimer = null }
927
990
  }
928
991
 
929
992
  const duration = () => readDuration(deps, startedAt)
@@ -948,8 +1011,9 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
948
1011
  nativeThreadId,
949
1012
  returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
950
1013
  exitCode,
951
- // Every settle but the force-settle below is reached from `finishTerminal`,
952
- // which only runs from the `close`/`error` handlers — so the child was
1014
+ // Every settle but the force-settle below is reached from `settleTerminal`,
1015
+ // which only runs after the `close`/`error` handlers (directly, or from the
1016
+ // 6.53.3 tree poll once a read shows nothing owned) — so the child was
953
1017
  // reaped. The one exception overrides this explicitly.
954
1018
  reaped: true,
955
1019
  stderrClass: classifyStderr(stderrSample),
@@ -993,7 +1057,79 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
993
1057
  }
994
1058
  }
995
1059
 
1060
+ // A child that survived SIGKILL cannot be reached from here, and blocking forever
1061
+ // would wedge the coordinator and any Control drain behind it. A cancel that landed
1062
+ // during the timeout's escalation still reads as the user's (6.53.0).
1063
+ const settleUnreaped = () => {
1064
+ // 6.53.3: no prompt byte was written, so the delivery stays `aborted`; `reaped: false`
1065
+ // is what tells the route a process of ours may still be alive.
1066
+ if (abortedBeforeWrite) return settleFailure('cancelled', { detail: 'unreaped', reaped: false })
1067
+ if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1068
+ else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1069
+ }
1070
+
1071
+ // --- 6.53.3: wait for the WHOLE tree, by polling ------------------------------
1072
+ // The CLI wrapper may close before a tool in a separate process group. 6.53.2 then
1073
+ // parked the result until the SIGKILL grace ran out and only polled after it, so a
1074
+ // tool that stopped one second after SIGTERM still held the turn ~5.3 s. Now the
1075
+ // table is re-read from `close` on, and the turn settles on the first read that
1076
+ // positively shows no owned process. The grace timers stay armed throughout: this
1077
+ // only ever settles EARLIER, never skips the SIGKILL a stubborn tool needs.
1078
+ const runTreeProbe = () => {
1079
+ treePollTimer = null
1080
+ if (settled) return
1081
+ if (probeInFlight) {
1082
+ probeAgain = true
1083
+ return
1084
+ }
1085
+ probeInFlight = true
1086
+ void probeProcessTree(deps, child).then(alive => {
1087
+ probeInFlight = false
1088
+ if (settled) return
1089
+ // The probe already proved the tree gone. Judge the outcome without a second read.
1090
+ if (!alive) return settleTerminal()
1091
+ if (probeAgain) {
1092
+ probeAgain = false
1093
+ return runTreeProbe()
1094
+ }
1095
+ if (!treePollTimer) treePollTimer = setTimeout(runTreeProbe, TREE_POLL_MS)
1096
+ })
1097
+ }
1098
+
1099
+ // The force deadline after SIGKILL. One last read first, so a tree that died since
1100
+ // the previous poll is not reported unreaped; the cap keeps a probe that never
1101
+ // answers from wedging the claim.
1102
+ const forceSettle = () => {
1103
+ forceTimer = null
1104
+ if (settled) return
1105
+ if (!terminalObserved) return settleUnreaped()
1106
+ forceTimer = setTimeout(settleUnreaped, FINAL_PROBE_CAP_MS)
1107
+ void probeProcessTree(deps, child).then(alive => {
1108
+ if (settled) return
1109
+ if (alive) settleUnreaped()
1110
+ else settleTerminal()
1111
+ })
1112
+ }
1113
+
1114
+ const escalateToKill = () => {
1115
+ graceTimer = null
1116
+ if (settled) return
1117
+ safeTerminate(deps, child, 'SIGKILL')
1118
+ if (terminalObserved) {
1119
+ if (treePollTimer) clearTimeout(treePollTimer)
1120
+ treePollTimer = setTimeout(runTreeProbe, TREE_POLL_AFTER_KILL_MS)
1121
+ }
1122
+ forceTimer = setTimeout(forceSettle, FORCE_SETTLE_MS)
1123
+ }
1124
+
996
1125
  const finishTerminal = () => {
1126
+ // The CLI wrapper may close before a tool in a separate process group.
1127
+ // Keep the escalation timers alive until the verified whole tree is gone.
1128
+ if (cancelled || timedOut) return runTreeProbe()
1129
+ settleTerminal()
1130
+ }
1131
+
1132
+ const settleTerminal = () => {
997
1133
  // Drain whatever sat in the trailing partial line before judging.
998
1134
  if (stdoutTail.length > 0) {
999
1135
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
@@ -1007,6 +1143,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1007
1143
  // clean exit 0 that echoed our exact id: that turn finished before the stop landed,
1008
1144
  // and reporting it cancelled would hide a delivered turn.
1009
1145
  if (cancelled && !(exitCode === 0 && delivery === 'ambiguous' && observedIds.length === 1 && observedIds[0] === nativeThreadId)) {
1146
+ // 6.53.3: stopped before the write, and the tree is gone. `delivery` is still
1147
+ // `aborted` here, and with `reaped: true` the route ends the turn cancelled
1148
+ // with no fence, which is what "not one byte was written" means.
1149
+ if (abortedBeforeWrite) return settleFailure('cancelled', { detail: 'before_write' })
1010
1150
  return settleFailure('cancelled', { delivery: 'cancelled' })
1011
1151
  }
1012
1152
  if (timedOut) return settleFailure('timeout')
@@ -1064,6 +1204,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1064
1204
  })
1065
1205
  child.on('close', (code: number | null) => {
1066
1206
  if (typeof code === 'number') exitCode = code
1207
+ terminalObserved = true
1067
1208
  finishTerminal()
1068
1209
  })
1069
1210
  } catch {
@@ -1095,17 +1236,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1095
1236
  deadline = setTimeout(() => {
1096
1237
  timedOut = true
1097
1238
  safeTerminate(deps, child, 'SIGTERM')
1098
- graceTimer = setTimeout(() => {
1099
- safeTerminate(deps, child, 'SIGKILL')
1100
- forceTimer = setTimeout(() => {
1101
- // A child that survived SIGKILL cannot be reached from here, and
1102
- // blocking forever would wedge the coordinator and any Control drain
1103
- // behind it. A cancel that landed during this escalation still reads
1104
- // as the user's (6.53.0).
1105
- if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1106
- else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1107
- }, FORCE_SETTLE_MS)
1108
- }, KILL_GRACE_MS)
1239
+ graceTimer = setTimeout(escalateToKill, KILL_GRACE_MS)
1109
1240
  }, timeoutMs)
1110
1241
 
1111
1242
  // --- the cancel (6.53.0) ---------------------------------------------------
@@ -1122,10 +1253,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1122
1253
  if (deadline) { clearTimeout(deadline); deadline = null }
1123
1254
  safeTerminate(deps, child, 'SIGTERM')
1124
1255
  graceTimer = setTimeout(() => {
1125
- safeTerminate(deps, child, 'SIGKILL')
1126
- forceTimer = setTimeout(() => {
1127
- settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1128
- }, FORCE_SETTLE_MS)
1256
+ escalateToKill()
1129
1257
  }, CANCEL_KILL_GRACE_MS)
1130
1258
  }
1131
1259
  try {
@@ -1156,10 +1284,21 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1156
1284
  // 6.53.0: a cancel that landed between the spawn check and here (a dependency that
1157
1285
  // yielded, or one that aborted synchronously). No prompt byte is written: the child is
1158
1286
  // killed and the turn is provably undelivered.
1287
+ //
1288
+ // 6.53.3: and REAPED like any other cancel before it settles. 6.53.2 returned at once
1289
+ // with `reaped: false`, so the route fenced a thread no byte had reached while an
1290
+ // adapter test called the same result unambiguous. Now `close` plus a tree read that
1291
+ // shows nothing owned settles `cancelled / aborted / reaped: true` (no fence), and
1292
+ // only a tree that outlives SIGKILL by FORCE_SETTLE_MS settles `reaped: false`.
1159
1293
  if (abortSignal?.aborted === true) {
1160
1294
  safeTerminate(deps, child, 'SIGKILL')
1161
1295
  delivery = 'aborted'
1162
- return settleFailure('cancelled', { detail: 'before_write', reaped: false })
1296
+ cancelled = true
1297
+ abortedBeforeWrite = true
1298
+ if (deadline) { clearTimeout(deadline); deadline = null }
1299
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
1300
+ forceTimer = setTimeout(forceSettle, FORCE_SETTLE_MS)
1301
+ return
1163
1302
  }
1164
1303
 
1165
1304
  try {
@@ -1181,6 +1320,423 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1181
1320
  // Production wiring
1182
1321
  // ---------------------------------------------------------------------------
1183
1322
 
1323
+ /** One row of ONE atomic `ps` table read. */
1324
+ export interface AttachedProcessRow {
1325
+ pid: number
1326
+ ppid: number
1327
+ pgid: number
1328
+ startMs: number | null
1329
+ }
1330
+
1331
+ /**
1332
+ * 6.53.3: is Node still holding the provider leader?
1333
+ *
1334
+ * unreaped `exit` has not fired, so the pid is alive or a zombie and CANNOT have been
1335
+ * recycled. Its process group is ours whatever a table read says.
1336
+ * reaped Node collected it. The pid is free, and a row carrying it is a stranger.
1337
+ * unknown a test double without the ChildProcess fields. Judged by remembered
1338
+ * identity, as 6.53.2 judged every leader.
1339
+ */
1340
+ export type AttachedLeaderState = 'unreaped' | 'reaped' | 'unknown'
1341
+
1342
+ export function attachedLeaderState(child: AttachedChildProcess): AttachedLeaderState {
1343
+ const status = child as { exitCode?: unknown; signalCode?: unknown }
1344
+ if (status.exitCode === null && status.signalCode === null) return 'unreaped'
1345
+ if (typeof status.exitCode === 'number' || typeof status.signalCode === 'string') return 'reaped'
1346
+ return 'unknown'
1347
+ }
1348
+
1349
+ /**
1350
+ * What one turn has learned about its own process tree, keyed by the leader pid.
1351
+ * `realAttachedTurnDeps` builds one per turn, so nothing crosses turns.
1352
+ */
1353
+ export interface AttachedTreeMemory {
1354
+ /** pid -> start of every process seen in the tree. Absent from a complete read = gone. */
1355
+ rememberedByRoot: Map<number, Map<number, number>>
1356
+ /**
1357
+ * 6.53.3: pgid -> leader start (null until the leader row is seen) of every process
1358
+ * group a member of the tree has sat in. The leader's own group is in it from the spawn.
1359
+ */
1360
+ groupsByRoot: Map<number, Map<number, number | null>>
1361
+ /**
1362
+ * 6.53.3: lower bound on the start of anything the turn owns: the wall clock just
1363
+ * before the spawn call, less OWNED_START_SLACK_MS.
1364
+ */
1365
+ floorByRoot: Map<number, number>
1366
+ /** Roots whose tree a failed or malformed read may have hidden. Latched for the turn. */
1367
+ uncertainRoots: Set<number>
1368
+ /**
1369
+ * 6.53.3: bumped by every `terminate`. A liveness read that was already in flight when
1370
+ * a terminate re-read the table is older than what memory now holds, so it answers
1371
+ * "alive" and changes nothing rather than forgetting what the newer read learned.
1372
+ */
1373
+ terminationsByRoot: Map<number, number>
1374
+ }
1375
+
1376
+ export function createAttachedTreeMemory(): AttachedTreeMemory {
1377
+ return {
1378
+ rememberedByRoot: new Map(),
1379
+ groupsByRoot: new Map(),
1380
+ floorByRoot: new Map(),
1381
+ uncertainRoots: new Set(),
1382
+ terminationsByRoot: new Map(),
1383
+ }
1384
+ }
1385
+
1386
+ /**
1387
+ * 6.53.3: `lstart` is truncated to the second, so a process started in the spawn's own
1388
+ * second reads up to 999 ms before the spawn call. Nothing of this turn can start earlier.
1389
+ */
1390
+ const OWNED_START_SLACK_MS = 1_000
1391
+
1392
+ const ATTACHED_PS_ARGS: readonly string[] = ['-axo', 'pid=,ppid=,pgid=,lstart=']
1393
+
1394
+ function attachedPsOptions() {
1395
+ return {
1396
+ encoding: 'utf8' as const,
1397
+ timeout: 1_000,
1398
+ maxBuffer: 2 * 1024 * 1024,
1399
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' },
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * One malformed row voids the whole read (null). The row we cannot parse might be the
1405
+ * member that escaped, and a partial table cannot prove anything is absent.
1406
+ */
1407
+ function parseAttachedProcessRows(output: string, now: number): AttachedProcessRow[] | null {
1408
+ const rows: AttachedProcessRow[] = []
1409
+ try {
1410
+ for (const line of output.split('\n')) {
1411
+ if (line.trim().length === 0) continue
1412
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)(?:\s+(.*?))?\s*$/)
1413
+ if (!match) return null
1414
+ const pid = Number(match[1])
1415
+ const ppid = Number(match[2])
1416
+ const pgid = Number(match[3])
1417
+ if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return null
1418
+ const startMs = interpretPsLstart(match[4] ?? '', now)
1419
+ if (startMs === null) return null
1420
+ rows.push({ pid, ppid, pgid, startMs })
1421
+ }
1422
+ } catch {
1423
+ return null
1424
+ }
1425
+ return rows
1426
+ }
1427
+
1428
+ /**
1429
+ * Read the live parent and process-group graph for an attached provider, SYNCHRONOUSLY.
1430
+ *
1431
+ * Codex may put a shell tool in its OWN process group. Signalling only the
1432
+ * detached CLI group then kills Codex while the tool is re-parented to pid 1.
1433
+ * This read is synchronous because termination is a one-shot ownership
1434
+ * boundary: the parent graph must be captured before the CLI can exit and erase
1435
+ * it. A failed read still signals the root group while Node holds the leader, but
1436
+ * latches uncertainty so the adapter cannot later report a false whole-tree reap.
1437
+ *
1438
+ * 6.53.3: only `terminate` uses this (twice per cancel at most: SIGTERM, SIGKILL). The
1439
+ * liveness poll reads through `readAttachedProcessRowsAsync`.
1440
+ */
1441
+ function readAttachedProcessRows(): AttachedProcessRow[] | null {
1442
+ if (process.platform === 'win32') return []
1443
+ try {
1444
+ const output = execFileSync('/bin/ps', [...ATTACHED_PS_ARGS], attachedPsOptions())
1445
+ return parseAttachedProcessRows(output, Date.now())
1446
+ } catch {
1447
+ return null
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * 6.53.3: the same read without blocking the event loop.
1453
+ *
1454
+ * One `ps -axo` over ~1,400 processes takes ~43 ms. 6.53.2 ran it synchronously every
1455
+ * 25 ms while it waited out a cancelled tree, stalling every request on the server
1456
+ * (review A7). A liveness answer is only ever acted on after it arrives, so nothing is
1457
+ * lost by waiting for it. Every failure (spawn, timeout, non-zero exit, oversized or
1458
+ * malformed output) resolves null, never a partial table.
1459
+ */
1460
+ function readAttachedProcessRowsAsync(): Promise<AttachedProcessRow[] | null> {
1461
+ if (process.platform === 'win32') return Promise.resolve([])
1462
+ return new Promise(resolve => {
1463
+ try {
1464
+ execFile('/bin/ps', [...ATTACHED_PS_ARGS], attachedPsOptions(), (error, stdout) => {
1465
+ if (error) return resolve(null)
1466
+ resolve(parseAttachedProcessRows(String(stdout), Date.now()))
1467
+ })
1468
+ } catch {
1469
+ resolve(null)
1470
+ }
1471
+ })
1472
+ }
1473
+
1474
+ /**
1475
+ * Which rows of ONE atomic table read belong to this turn. Updates `memory`.
1476
+ *
1477
+ * A row is owned when it is
1478
+ * 1. a remembered identity (pid AND start) that still names the same kernel process;
1479
+ * 2. the leader, while Node has not reaped it (the pid cannot be recycled before that);
1480
+ * 3. a descendant, by ppid, of anything owned; or
1481
+ * 4. (6.53.3) a member of a remembered process group that started no earlier than the
1482
+ * spawn floor. The leader's own group counts from the spawn; a tool's group counts
1483
+ * from the first read that saw one of our processes in it. Either stops counting the
1484
+ * first time a complete read shows it empty (its id may be recycled from then on) or
1485
+ * shows its leader pid carrying a different start.
1486
+ *
1487
+ * Rule 4 is the 6.53.3 change. A background job already re-parented to launchd keeps its
1488
+ * process group but has no ppid path back to us: 6.53.2 sent that group one SIGTERM,
1489
+ * never tracked it, and reported `reaped: true` 3/3 while the job lived (review A2). And
1490
+ * once the leader had exited, a same-group process holding the CLI's stdout was owned by
1491
+ * nothing, so it was never signalled at all (review A3, a regression from 6.52.3).
1492
+ *
1493
+ * WHY GROUP MEMBERSHIP CAN BE TRUSTED. The leader is spawned `detached`, i.e. through
1494
+ * `setsid`, so the provider runs in its own session, and a process can only join a group
1495
+ * inside its own session. Every group a member of the tree sits in was therefore made
1496
+ * inside the tree. The exceptions, the group this server runs in (a leader spawned
1497
+ * without `setsid`) and launchd's, are refused outright.
1498
+ */
1499
+ export function ownedAttachedProcessRows(
1500
+ rows: readonly AttachedProcessRow[],
1501
+ rootPid: number,
1502
+ leader: AttachedLeaderState,
1503
+ memory: AttachedTreeMemory,
1504
+ ): AttachedProcessRow[] {
1505
+ const { uncertainRoots } = memory
1506
+ const remembered = memory.rememberedByRoot.get(rootPid) ?? new Map<number, number>()
1507
+ memory.rememberedByRoot.set(rootPid, remembered)
1508
+ const groups = memory.groupsByRoot.get(rootPid) ?? new Map<number, number | null>([[rootPid, null]])
1509
+ memory.groupsByRoot.set(rootPid, groups)
1510
+
1511
+ const rowsByPid = new Map(rows.map(row => [row.pid, row]))
1512
+ const membersByGroup = new Map<number, AttachedProcessRow[]>()
1513
+ for (const row of rows) {
1514
+ const members = membersByGroup.get(row.pgid)
1515
+ if (members) members.push(row)
1516
+ else membersByGroup.set(row.pgid, [row])
1517
+ }
1518
+ const serverGroup = rowsByPid.get(process.pid)?.pgid ?? null
1519
+ const foreignGroup = (pgid: number) => pgid <= 1 || pgid === serverGroup
1520
+
1521
+ // Only identities that still name the same kernel process may seed a fresh
1522
+ // descendant scan. This is what makes the five-second SIGKILL safe after the
1523
+ // provider leader has already disappeared. The leader itself is judged below,
1524
+ // by Node's own reap state, never by a remembered identity.
1525
+ const knownRootStart = remembered.get(rootPid)
1526
+ const rememberedRoots: number[] = []
1527
+ for (const [pid, startedAt] of remembered) {
1528
+ if (pid === rootPid) continue
1529
+ const row = rowsByPid.get(pid)
1530
+ if (!row) {
1531
+ // Absence from a successful full process-table snapshot is the only
1532
+ // positive proof this identity is gone.
1533
+ remembered.delete(pid)
1534
+ } else if (row.startMs === startedAt) {
1535
+ rememberedRoots.push(pid)
1536
+ } else if (row.startMs === null) {
1537
+ // The pid still exists but its start could not be read. That is doubt,
1538
+ // not death; retain the identity and fail this turn toward the fence.
1539
+ uncertainRoots.add(rootPid)
1540
+ } else {
1541
+ // A different start means PID reuse. The original identity is gone and
1542
+ // the replacement may never be signalled from remembered state.
1543
+ remembered.delete(pid)
1544
+ }
1545
+ }
1546
+
1547
+ const rootRow = rowsByPid.get(rootPid)
1548
+ let rootOwned = false
1549
+ if (rootRow && leader !== 'reaped') {
1550
+ if (rootRow.startMs === null) uncertainRoots.add(rootPid)
1551
+ else if (leader === 'unreaped' || knownRootStart === undefined || knownRootStart === rootRow.startMs) rootOwned = true
1552
+ }
1553
+ if (rootOwned && rootRow && rootRow.startMs !== null) {
1554
+ if (knownRootStart === undefined) remembered.set(rootPid, rootRow.startMs)
1555
+ // Only for a leader spawned outside realAttachedTurnDeps: the kernel start is a
1556
+ // floor for everything the leader went on to create.
1557
+ if (!memory.floorByRoot.has(rootPid)) memory.floorByRoot.set(rootPid, rootRow.startMs - OWNED_START_SLACK_MS)
1558
+ }
1559
+ const floor = memory.floorByRoot.get(rootPid)
1560
+
1561
+ for (const [pgid, leaderStart] of groups) {
1562
+ const leaderRow = rowsByPid.get(pgid)
1563
+ if (!membersByGroup.has(pgid)) {
1564
+ // Empty in a complete read: gone, and its id may be recycled from here on. Only
1565
+ // the leader's group cannot be empty while Node still holds the leader.
1566
+ if (!(pgid === rootPid && leader === 'unreaped')) groups.delete(pgid)
1567
+ continue
1568
+ }
1569
+ if (pgid === rootPid && leader === 'reaped' && leaderRow) {
1570
+ // The leader pid is back in the table after Node reaped it. POSIX does not reuse
1571
+ // a pid that is still a live group id, so the root group emptied in between.
1572
+ groups.delete(pgid)
1573
+ continue
1574
+ }
1575
+ if (leaderRow && leaderStart !== null && leaderRow.startMs !== leaderStart) {
1576
+ // A tool group's leader pid carrying another start: the same inference.
1577
+ groups.delete(pgid)
1578
+ continue
1579
+ }
1580
+ if (leaderStart === null && leaderRow && leaderRow.pgid === pgid && leaderRow.startMs !== null
1581
+ && !(pgid === rootPid && leader === 'reaped')) {
1582
+ groups.set(pgid, leaderRow.startMs)
1583
+ }
1584
+ }
1585
+
1586
+ const groupOwns = (row: AttachedProcessRow): boolean => {
1587
+ if (foreignGroup(row.pgid)) return false
1588
+ // While Node holds the leader, its group cannot have been recycled: all of it is ours.
1589
+ if (row.pgid === rootPid && leader === 'unreaped') return true
1590
+ if (!groups.has(row.pgid)) return false
1591
+ return floor !== undefined && row.startMs !== null && row.startMs >= floor
1592
+ }
1593
+
1594
+ const roots = [...rememberedRoots]
1595
+ if (rootOwned && !roots.includes(rootPid)) roots.push(rootPid)
1596
+ const owned = new Set<number>(roots)
1597
+ let changed = true
1598
+ while (changed) {
1599
+ changed = false
1600
+ for (const row of rows) {
1601
+ if (owned.has(row.pid)) continue
1602
+ if (owned.has(row.ppid) || groupOwns(row)) {
1603
+ owned.add(row.pid)
1604
+ changed = true
1605
+ }
1606
+ }
1607
+ // Any group an owned process sits in was made inside the tree (see above). Remember
1608
+ // it from this read on; its other members join in the next pass of this same read.
1609
+ for (const pid of owned) {
1610
+ const row = rowsByPid.get(pid)
1611
+ if (!row || foreignGroup(row.pgid) || groups.has(row.pgid)) continue
1612
+ const groupLeader = rowsByPid.get(row.pgid)
1613
+ groups.set(row.pgid, groupLeader && groupLeader.pgid === row.pgid ? groupLeader.startMs : null)
1614
+ changed = true
1615
+ }
1616
+ }
1617
+
1618
+ const tree = rows.filter(row => owned.has(row.pid))
1619
+ for (const row of tree) {
1620
+ if (row.startMs !== null) remembered.set(row.pid, row.startMs)
1621
+ else uncertainRoots.add(rootPid)
1622
+ }
1623
+ return tree
1624
+ }
1625
+
1626
+ /** Which signals one `terminate` sends. Pure, so every rule is testable without a kill. */
1627
+ export interface AttachedTerminationPlan {
1628
+ /** Tool process groups, signalled whole and FIRST. Only groups with no member we do not own. */
1629
+ groups: number[]
1630
+ /** Every owned pid but the leader, signalled one by one. */
1631
+ pids: number[]
1632
+ /** Whether the leader's own group is signalled. */
1633
+ rootGroup: boolean
1634
+ }
1635
+
1636
+ export function planAttachedTermination(
1637
+ rows: readonly AttachedProcessRow[] | null,
1638
+ rootPid: number,
1639
+ leader: AttachedLeaderState,
1640
+ memory: AttachedTreeMemory,
1641
+ ): AttachedTerminationPlan {
1642
+ if (rows === null) {
1643
+ memory.uncertainRoots.add(rootPid)
1644
+ // 6.53.3 (A3): a failed read no longer skips the root group. While Node holds the
1645
+ // leader, `-rootPid` names our group and nothing else, exactly as 6.52.3 assumed.
1646
+ return { groups: [], pids: [], rootGroup: leader === 'unreaped' }
1647
+ }
1648
+ const tree = ownedAttachedProcessRows(rows, rootPid, leader, memory)
1649
+ const ownedPids = new Set(tree.map(row => row.pid))
1650
+ // A group holding any process we do not own is signalled member by member instead.
1651
+ const wholeGroupOwned = (pgid: number) => rows.every(row => row.pgid !== pgid || ownedPids.has(row.pid))
1652
+ const toolGroups = new Set(tree.filter(row => row.pgid !== rootPid).map(row => row.pgid))
1653
+ return {
1654
+ groups: [...toolGroups].filter(wholeGroupOwned),
1655
+ pids: tree.filter(row => row.pid !== rootPid).map(row => row.pid),
1656
+ // 6.53.3 (A3): unconditional while Node holds the leader. After that, only while the
1657
+ // group still holds processes we own and none we do not.
1658
+ rootGroup: leader === 'unreaped' || (tree.some(row => row.pgid === rootPid) && wholeGroupOwned(rootPid)),
1659
+ }
1660
+ }
1661
+
1662
+ function signalPid(pid: number, signal: NodeJS.Signals): void {
1663
+ try { process.kill(pid, signal) } catch { /* already terminal or unavailable */ }
1664
+ }
1665
+
1666
+ function signalGroup(pgid: number, signal: NodeJS.Signals): void {
1667
+ if (process.platform === 'win32') return
1668
+ try { process.kill(-pgid, signal) } catch { /* already terminal or unavailable */ }
1669
+ }
1670
+
1671
+ /**
1672
+ * Stop the provider plus every process this turn owns (see `ownedAttachedProcessRows`).
1673
+ * Captured pid/start identities and group ids survive the SIGTERM grace, so SIGKILL can
1674
+ * still reach an uncooperative tool after the CLI has exited and the tool has been
1675
+ * re-parented to launchd. Identities are re-checked before reuse; a recycled pid or
1676
+ * group id is never signalled from remembered state.
1677
+ */
1678
+ function terminateAttachedProcessTree(
1679
+ child: AttachedChildProcess,
1680
+ signal: NodeJS.Signals,
1681
+ memory: AttachedTreeMemory,
1682
+ ): void {
1683
+ const rootPid = child.pid
1684
+ if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
1685
+ try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
1686
+ return
1687
+ }
1688
+ memory.terminationsByRoot.set(rootPid, (memory.terminationsByRoot.get(rootPid) ?? 0) + 1)
1689
+ // Read before the table: Node cannot reap while this synchronous call holds the loop,
1690
+ // so the leader state and the read agree.
1691
+ const plan = planAttachedTermination(readAttachedProcessRows(), rootPid, attachedLeaderState(child), memory)
1692
+
1693
+ // A child in a separate group must be signalled BEFORE the CLI group. If the
1694
+ // CLI exits first, macOS reparents that child to pid 1 and the relationship is
1695
+ // no longer discoverable by ppid.
1696
+ for (const pgid of plan.groups) signalGroup(pgid, signal)
1697
+ for (const pid of plan.pids) signalPid(pid, signal)
1698
+ if (plan.rootGroup) signalGroup(rootPid, signal)
1699
+
1700
+ // Direct-child fallback covers a spawn that failed before setsid established
1701
+ // the detached group, and is harmless after a successful group signal.
1702
+ try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
1703
+ }
1704
+
1705
+ async function attachedProcessTreeAlive(
1706
+ child: AttachedChildProcess,
1707
+ memory: AttachedTreeMemory,
1708
+ ): Promise<boolean> {
1709
+ const { uncertainRoots } = memory
1710
+ const rootPid = child.pid
1711
+ if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
1712
+ const terminationsBefore = memory.terminationsByRoot.get(rootPid) ?? 0
1713
+ // 6.53.3 (A7): once doubt is latched the answer is "alive" whatever the table says, so
1714
+ // the table is not read again. 6.53.2 kept reading it every 25 ms until force-settle.
1715
+ const rows = uncertainRoots.has(rootPid) ? null : await readAttachedProcessRowsAsync()
1716
+ // A failed global process-table read cannot prove closure. Keep the record so
1717
+ // the SIGKILL escalation can still target identities captured before TERM.
1718
+ if (rows === null) uncertainRoots.add(rootPid)
1719
+ // A terminate re-read the table while this one was in flight: this read is the older
1720
+ // one. Inconclusive, and it must not overwrite what the newer read remembered.
1721
+ if (rows !== null && (memory.terminationsByRoot.get(rootPid) ?? 0) !== terminationsBefore) return true
1722
+ // The leader state is read AFTER the await: Node may have reaped it while `ps` ran.
1723
+ const live = rows === null ? [] : ownedAttachedProcessRows(rows, rootPid, attachedLeaderState(child), memory)
1724
+ let anyLive = live.length > 0
1725
+
1726
+ // Once a snapshot/start probe missed part of the tree, later ancestry cannot
1727
+ // reconstruct a process that may already have been re-parented. Never convert
1728
+ // that uncertainty into reaped:true; the route will fence after force-settle.
1729
+ if (uncertainRoots.has(rootPid)) anyLive = true
1730
+
1731
+ try {
1732
+ process.kill(-rootPid, 0)
1733
+ anyLive = true
1734
+ } catch (error: any) {
1735
+ if (error?.code !== 'ESRCH') anyLive = true
1736
+ }
1737
+ return anyLive
1738
+ }
1739
+
1184
1740
  /**
1185
1741
  * The real dependency set.
1186
1742
  *
@@ -1190,38 +1746,32 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1190
1746
  * which is the one answer this module must never invent.
1191
1747
  */
1192
1748
  export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
1749
+ const treeMemory = createAttachedTreeMemory()
1193
1750
  return {
1194
1751
  now: () => Date.now(),
1195
1752
  preflight,
1196
1753
  resolveBinary: provider => resolveProviderBinary(provider),
1197
- spawn: request => nodeSpawn(request.binaryPath, [...request.args], {
1198
- stdio: ['pipe', 'pipe', 'pipe'],
1199
- cwd: request.cwd,
1200
- env: request.env,
1201
- // Group leader, so the whole provider tree can be signalled on timeout —
1202
- // matching what both ordinary bridges do.
1203
- detached: true,
1204
- }) as unknown as AttachedChildProcess,
1754
+ spawn: request => {
1755
+ // 6.53.3: read BEFORE the spawn call, so every process of this turn starts at or
1756
+ // after it. This is the floor that keeps group ownership (rule 4 above) from ever
1757
+ // claiming a process that predates the turn.
1758
+ const floor = Date.now() - OWNED_START_SLACK_MS
1759
+ const child = nodeSpawn(request.binaryPath, [...request.args], {
1760
+ stdio: ['pipe', 'pipe', 'pipe'],
1761
+ cwd: request.cwd,
1762
+ env: request.env,
1763
+ // Group leader, so the whole provider tree can be signalled on timeout —
1764
+ // matching what both ordinary bridges do.
1765
+ detached: true,
1766
+ })
1767
+ const pid = child.pid
1768
+ if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) treeMemory.floorByRoot.set(pid, floor)
1769
+ return child as unknown as AttachedChildProcess
1770
+ },
1205
1771
  processStartMs: pid => realProcessStartMs(pid),
1206
1772
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
1207
1773
  releaseSpawn: pid => releaseCosSpawn(pid),
1208
- terminate: (child, signal) => {
1209
- const pid = child.pid
1210
- if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) {
1211
- try {
1212
- // Negative pid = process group, reachable because we spawned
1213
- // detached. Kills the CLI's own children too.
1214
- process.kill(-pid, signal)
1215
- return
1216
- } catch {
1217
- /* fall through to the direct signal */
1218
- }
1219
- }
1220
- try {
1221
- ;(child as any).kill?.(signal)
1222
- } catch {
1223
- /* nothing further is available */
1224
- }
1225
- },
1774
+ terminate: (child, signal) => terminateAttachedProcessTree(child, signal, treeMemory),
1775
+ processTreeAlive: child => attachedProcessTreeAlive(child, treeMemory),
1226
1776
  }
1227
1777
  }