@gotcos/glasses-server 6.53.1 → 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.
@@ -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 { execFileSync, 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
 
@@ -320,8 +320,13 @@ export interface AttachedTurnDeps {
320
320
  * True while any verified member of the provider process tree is still live.
321
321
  * Unknown/probe failure MUST read as true: a cancellation may only be reported
322
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.
323
328
  */
324
- processTreeAlive: (child: AttachedChildProcess) => boolean
329
+ processTreeAlive: (child: AttachedChildProcess) => boolean | PromiseLike<boolean>
325
330
  /**
326
331
  * Argv builder. Optional, defaults to the real one.
327
332
  *
@@ -401,6 +406,26 @@ export const CANCEL_KILL_GRACE_MS = 5_000
401
406
  */
402
407
  export const FORCE_SETTLE_MS = 2_000
403
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
+
404
429
  /**
405
430
  * Prompt ceiling. Generous — this is a guard against a pathological or
406
431
  * corrupted value reaching a pipe, not a product limit.
@@ -812,8 +837,10 @@ async function run(
812
837
  }
813
838
  // 6.53.0: a cancel that landed before the spawn. No process is created, so nothing can
814
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.
815
842
  if (request.abortSignal?.aborted === true) {
816
- 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() })
817
844
  }
818
845
  let child: AttachedChildProcess
819
846
  try {
@@ -884,12 +911,22 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
884
911
  }
885
912
  }
886
913
 
887
- function safeProcessTreeAlive(deps: AttachedTurnDeps, child: AttachedChildProcess): boolean {
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> {
888
922
  try {
889
- return deps.processTreeAlive(child) === true
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)
890
928
  } catch {
891
- // A failed ownership probe is not proof that the process tree is gone.
892
- return true
929
+ return Promise.resolve(true)
893
930
  }
894
931
  }
895
932
 
@@ -928,8 +965,16 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
928
965
  let timedOut = false
929
966
  /** 6.53.0: the caller's abort fired after the child existed. Read before `timedOut`. */
930
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
931
973
  let spawnErrored = false
932
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
933
978
 
934
979
  let deadline: ReturnType<typeof setTimeout> | null = null
935
980
  let graceTimer: ReturnType<typeof setTimeout> | null = null
@@ -966,8 +1011,9 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
966
1011
  nativeThreadId,
967
1012
  returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
968
1013
  exitCode,
969
- // Every settle but the force-settle below is reached from `finishTerminal`,
970
- // 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
971
1017
  // reaped. The one exception overrides this explicitly.
972
1018
  reaped: true,
973
1019
  stderrClass: classifyStderr(stderrSample),
@@ -1011,11 +1057,79 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1011
1057
  }
1012
1058
  }
1013
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
+
1014
1125
  const finishTerminal = () => {
1015
1126
  // The CLI wrapper may close before a tool in a separate process group.
1016
1127
  // Keep the escalation timers alive until the verified whole tree is gone.
1017
- if ((cancelled || timedOut) && safeProcessTreeAlive(deps, child)) return
1128
+ if (cancelled || timedOut) return runTreeProbe()
1129
+ settleTerminal()
1130
+ }
1018
1131
 
1132
+ const settleTerminal = () => {
1019
1133
  // Drain whatever sat in the trailing partial line before judging.
1020
1134
  if (stdoutTail.length > 0) {
1021
1135
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
@@ -1029,6 +1143,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1029
1143
  // clean exit 0 that echoed our exact id: that turn finished before the stop landed,
1030
1144
  // and reporting it cancelled would hide a delivered turn.
1031
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' })
1032
1150
  return settleFailure('cancelled', { delivery: 'cancelled' })
1033
1151
  }
1034
1152
  if (timedOut) return settleFailure('timeout')
@@ -1118,25 +1236,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1118
1236
  deadline = setTimeout(() => {
1119
1237
  timedOut = true
1120
1238
  safeTerminate(deps, child, 'SIGTERM')
1121
- graceTimer = setTimeout(() => {
1122
- safeTerminate(deps, child, 'SIGKILL')
1123
- const forceAt = Date.now() + FORCE_SETTLE_MS
1124
- const pollForTreeExit = () => {
1125
- if (settled) return
1126
- if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
1127
- if (Date.now() < forceAt) {
1128
- treePollTimer = setTimeout(pollForTreeExit, 25)
1129
- return
1130
- }
1131
- // A child that survived SIGKILL cannot be reached from here, and
1132
- // blocking forever would wedge the coordinator and any Control drain
1133
- // behind it. A cancel that landed during this escalation still reads
1134
- // as the user's (6.53.0).
1135
- if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1136
- else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1137
- }
1138
- forceTimer = setTimeout(pollForTreeExit, 25)
1139
- }, KILL_GRACE_MS)
1239
+ graceTimer = setTimeout(escalateToKill, KILL_GRACE_MS)
1140
1240
  }, timeoutMs)
1141
1241
 
1142
1242
  // --- the cancel (6.53.0) ---------------------------------------------------
@@ -1153,18 +1253,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1153
1253
  if (deadline) { clearTimeout(deadline); deadline = null }
1154
1254
  safeTerminate(deps, child, 'SIGTERM')
1155
1255
  graceTimer = setTimeout(() => {
1156
- safeTerminate(deps, child, 'SIGKILL')
1157
- const forceAt = Date.now() + FORCE_SETTLE_MS
1158
- const pollForTreeExit = () => {
1159
- if (settled) return
1160
- if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
1161
- if (Date.now() < forceAt) {
1162
- treePollTimer = setTimeout(pollForTreeExit, 25)
1163
- return
1164
- }
1165
- settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1166
- }
1167
- forceTimer = setTimeout(pollForTreeExit, 25)
1256
+ escalateToKill()
1168
1257
  }, CANCEL_KILL_GRACE_MS)
1169
1258
  }
1170
1259
  try {
@@ -1195,10 +1284,21 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1195
1284
  // 6.53.0: a cancel that landed between the spawn check and here (a dependency that
1196
1285
  // yielded, or one that aborted synchronously). No prompt byte is written: the child is
1197
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`.
1198
1293
  if (abortSignal?.aborted === true) {
1199
1294
  safeTerminate(deps, child, 'SIGKILL')
1200
1295
  delivery = 'aborted'
1201
- 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
1202
1302
  }
1203
1303
 
1204
1304
  try {
@@ -1220,57 +1320,343 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1220
1320
  // Production wiring
1221
1321
  // ---------------------------------------------------------------------------
1222
1322
 
1223
- interface AttachedProcessRow {
1323
+ /** One row of ONE atomic `ps` table read. */
1324
+ export interface AttachedProcessRow {
1224
1325
  pid: number
1225
1326
  ppid: number
1226
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
1227
1426
  }
1228
1427
 
1229
1428
  /**
1230
- * Read the live parent and process-group graph for an attached provider.
1429
+ * Read the live parent and process-group graph for an attached provider, SYNCHRONOUSLY.
1231
1430
  *
1232
1431
  * Codex may put a shell tool in its OWN process group. Signalling only the
1233
1432
  * detached CLI group then kills Codex while the tool is re-parented to pid 1.
1234
- * This probe is synchronous because termination is a one-shot ownership
1433
+ * This read is synchronous because termination is a one-shot ownership
1235
1434
  * boundary: the parent graph must be captured before the CLI can exit and erase
1236
- * it. A failed probe still signals the original root group below, but latches
1237
- * uncertainty so the adapter cannot later report a false whole-tree reap.
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`.
1238
1440
  */
1239
1441
  function readAttachedProcessRows(): AttachedProcessRow[] | null {
1240
1442
  if (process.platform === 'win32') return []
1241
1443
  try {
1242
- const output = execFileSync('/bin/ps', ['-axo', 'pid=,ppid=,pgid='], {
1243
- encoding: 'utf8',
1244
- timeout: 1_000,
1245
- maxBuffer: 2 * 1024 * 1024,
1246
- })
1247
- return output.split('\n').flatMap(line => {
1248
- const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/)
1249
- if (!match) return []
1250
- const pid = Number(match[1])
1251
- const ppid = Number(match[2])
1252
- const pgid = Number(match[3])
1253
- if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return []
1254
- return [{ pid, ppid, pgid }]
1255
- })
1444
+ const output = execFileSync('/bin/ps', [...ATTACHED_PS_ARGS], attachedPsOptions())
1445
+ return parseAttachedProcessRows(output, Date.now())
1256
1446
  } catch {
1257
1447
  return null
1258
1448
  }
1259
1449
  }
1260
1450
 
1261
- function attachedProcessTree(rows: AttachedProcessRow[], roots: Iterable<number>): AttachedProcessRow[] {
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)
1262
1596
  const owned = new Set<number>(roots)
1263
1597
  let changed = true
1264
1598
  while (changed) {
1265
1599
  changed = false
1266
1600
  for (const row of rows) {
1267
- if (!owned.has(row.pid) && owned.has(row.ppid)) {
1601
+ if (owned.has(row.pid)) continue
1602
+ if (owned.has(row.ppid) || groupOwns(row)) {
1268
1603
  owned.add(row.pid)
1269
1604
  changed = true
1270
1605
  }
1271
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)),
1272
1659
  }
1273
- return rows.filter(row => owned.has(row.pid))
1274
1660
  }
1275
1661
 
1276
1662
  function signalPid(pid: number, signal: NodeJS.Signals): void {
@@ -1283,124 +1669,59 @@ function signalGroup(pgid: number, signal: NodeJS.Signals): void {
1283
1669
  }
1284
1670
 
1285
1671
  /**
1286
- * Stop the provider plus every descendant visible before its parent link is
1287
- * lost. Captured pid/start identities survive the SIGTERM grace, so SIGKILL can
1288
- * still reach an uncooperative tool after the CLI has exited and the tool has
1289
- * been re-parented to launchd. Identities are re-checked before reuse; a recycled
1290
- * pid is never signalled from remembered state.
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.
1291
1677
  */
1292
1678
  function terminateAttachedProcessTree(
1293
1679
  child: AttachedChildProcess,
1294
1680
  signal: NodeJS.Signals,
1295
- rememberedByRoot: Map<number, Map<number, number>>,
1296
- uncertainRoots: Set<number>,
1681
+ memory: AttachedTreeMemory,
1297
1682
  ): void {
1298
1683
  const rootPid = child.pid
1299
1684
  if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
1300
1685
  try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
1301
1686
  return
1302
1687
  }
1303
-
1304
- const remembered = rememberedByRoot.get(rootPid) ?? new Map<number, number>()
1305
- rememberedByRoot.set(rootPid, remembered)
1306
-
1307
- // Only identities that still name the same kernel process may seed a fresh
1308
- // descendant scan. This is what makes the five-second SIGKILL safe after the
1309
- // provider leader has already disappeared.
1310
- const rememberedRoots: number[] = []
1311
- for (const [pid, startedAt] of remembered) {
1312
- if (realProcessStartMs(pid) === startedAt) rememberedRoots.push(pid)
1313
- }
1314
-
1315
- const rows = readAttachedProcessRows()
1316
- if (rows === null) uncertainRoots.add(rootPid)
1317
- const knownRootStart = remembered.get(rootPid)
1318
- const currentRootStart = realProcessStartMs(rootPid)
1319
- const rootVerified = currentRootStart !== null
1320
- && (knownRootStart === undefined || currentRootStart === knownRootStart)
1321
- if (currentRootStart === null && rows?.some(row => row.pid === rootPid)) uncertainRoots.add(rootPid)
1322
- if (knownRootStart === undefined && currentRootStart !== null) remembered.set(rootPid, currentRootStart)
1323
- const roots = [...rememberedRoots]
1324
- if (rootVerified && !roots.includes(rootPid)) roots.push(rootPid)
1325
- const tree = rows === null ? [] : attachedProcessTree(rows, roots)
1326
- for (const row of tree) {
1327
- const startedAt = realProcessStartMs(row.pid)
1328
- if (startedAt !== null) remembered.set(row.pid, startedAt)
1329
- else uncertainRoots.add(rootPid)
1330
- }
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)
1331
1692
 
1332
1693
  // A child in a separate group must be signalled BEFORE the CLI group. If the
1333
1694
  // CLI exits first, macOS reparents that child to pid 1 and the relationship is
1334
- // no longer discoverable.
1335
- const descendantGroups = new Set(
1336
- tree.filter(row => row.pid !== rootPid && row.pgid !== rootPid).map(row => row.pgid),
1337
- )
1338
- for (const pgid of descendantGroups) signalGroup(pgid, signal)
1339
- for (const row of tree) {
1340
- if (row.pid !== rootPid) signalPid(row.pid, signal)
1341
- }
1342
-
1343
- const rootGroupStillOwned = rootVerified || tree.some(row => row.pgid === rootPid)
1344
- if (rootGroupStillOwned) signalGroup(rootPid, signal)
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)
1345
1699
 
1346
1700
  // Direct-child fallback covers a spawn that failed before setsid established
1347
1701
  // the detached group, and is harmless after a successful group signal.
1348
1702
  try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
1349
-
1350
1703
  }
1351
1704
 
1352
- function attachedProcessTreeAlive(
1705
+ async function attachedProcessTreeAlive(
1353
1706
  child: AttachedChildProcess,
1354
- rememberedByRoot: Map<number, Map<number, number>>,
1355
- uncertainRoots: Set<number>,
1356
- ): boolean {
1707
+ memory: AttachedTreeMemory,
1708
+ ): Promise<boolean> {
1709
+ const { uncertainRoots } = memory
1357
1710
  const rootPid = child.pid
1358
1711
  if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
1359
- const remembered = rememberedByRoot.get(rootPid)
1360
- const rows = readAttachedProcessRows()
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()
1361
1716
  // A failed global process-table read cannot prove closure. Keep the record so
1362
1717
  // the SIGKILL escalation can still target identities captured before TERM.
1363
- if (rows === null) return true
1364
-
1365
- let anyLive = false
1366
- if (remembered) {
1367
- const verifiedRoots: number[] = []
1368
- for (const [pid, startedAt] of remembered) {
1369
- const rowExists = rows.some(row => row.pid === pid)
1370
- if (!rowExists) {
1371
- // Absence from a successful full process-table snapshot is the only
1372
- // positive proof this identity is gone.
1373
- remembered.delete(pid)
1374
- continue
1375
- }
1376
- const currentStart = realProcessStartMs(pid)
1377
- if (currentStart === startedAt) {
1378
- anyLive = true
1379
- verifiedRoots.push(pid)
1380
- } else if (currentStart === null) {
1381
- // The pid still exists but its start could not be read. That is doubt,
1382
- // not death; retain the identity and permanently fail this cancellation
1383
- // toward the route's ambiguity fence.
1384
- anyLive = true
1385
- uncertainRoots.add(rootPid)
1386
- } else {
1387
- // A different start means PID reuse. The original identity is gone and
1388
- // the replacement may never be signalled from remembered state.
1389
- remembered.delete(pid)
1390
- }
1391
- }
1392
- const fresh = attachedProcessTree(rows, verifiedRoots)
1393
- for (const row of fresh) {
1394
- const startedAt = realProcessStartMs(row.pid)
1395
- if (startedAt !== null) {
1396
- remembered.set(row.pid, startedAt)
1397
- anyLive = true
1398
- } else {
1399
- anyLive = true
1400
- uncertainRoots.add(rootPid)
1401
- }
1402
- }
1403
- }
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
1404
1725
 
1405
1726
  // Once a snapshot/start probe missed part of the tree, later ancestry cannot
1406
1727
  // reconstruct a process that may already have been re-parented. Never convert
@@ -1413,11 +1734,6 @@ function attachedProcessTreeAlive(
1413
1734
  } catch (error: any) {
1414
1735
  if (error?.code !== 'ESRCH') anyLive = true
1415
1736
  }
1416
-
1417
- if (!anyLive) {
1418
- rememberedByRoot.delete(rootPid)
1419
- uncertainRoots.delete(rootPid)
1420
- }
1421
1737
  return anyLive
1422
1738
  }
1423
1739
 
@@ -1430,24 +1746,32 @@ function attachedProcessTreeAlive(
1430
1746
  * which is the one answer this module must never invent.
1431
1747
  */
1432
1748
  export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
1433
- const rememberedProcesses = new Map<number, Map<number, number>>()
1434
- const uncertainProcessTrees = new Set<number>()
1749
+ const treeMemory = createAttachedTreeMemory()
1435
1750
  return {
1436
1751
  now: () => Date.now(),
1437
1752
  preflight,
1438
1753
  resolveBinary: provider => resolveProviderBinary(provider),
1439
- spawn: request => nodeSpawn(request.binaryPath, [...request.args], {
1440
- stdio: ['pipe', 'pipe', 'pipe'],
1441
- cwd: request.cwd,
1442
- env: request.env,
1443
- // Group leader, so the whole provider tree can be signalled on timeout —
1444
- // matching what both ordinary bridges do.
1445
- detached: true,
1446
- }) 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
+ },
1447
1771
  processStartMs: pid => realProcessStartMs(pid),
1448
1772
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
1449
1773
  releaseSpawn: pid => releaseCosSpawn(pid),
1450
- terminate: (child, signal) => terminateAttachedProcessTree(child, signal, rememberedProcesses, uncertainProcessTrees),
1451
- processTreeAlive: child => attachedProcessTreeAlive(child, rememberedProcesses, uncertainProcessTrees),
1774
+ terminate: (child, signal) => terminateAttachedProcessTree(child, signal, treeMemory),
1775
+ processTreeAlive: child => attachedProcessTreeAlive(child, treeMemory),
1452
1776
  }
1453
1777
  }