@gotcos/glasses-server 6.53.1 → 6.53.4

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
 
@@ -186,6 +186,14 @@ export interface AttachedTurnFailureResult {
186
186
  /**
187
187
  * Did `close`/`exit` actually fire, i.e. did the kernel reap the child?
188
188
  *
189
+ * WHAT IT DOES AND DOES NOT PROMISE (6.53.3, scoped by its /qa round). On a CANCEL or a
190
+ * TIMEOUT, `reaped: true` also means the whole owned process tree was read gone after
191
+ * `close` (the tree poll); a tree that outlives SIGKILL settles `reaped: false`. On a
192
+ * NATURAL exit (the provider closed without a cancel or timeout) it means only that the
193
+ * CLI itself was reaped: that path settles at once, with no tree probe, so a detached
194
+ * background job the tool left running is not looked for. A pre-spawn cancel reports
195
+ * `reaped: true` because no process ever existed.
196
+ *
189
197
  * NOT DERIVABLE FROM `exitCode`. A child killed by a signal reports
190
198
  * `code === null`, and the handlers below only assign `exitCode` for a numeric
191
199
  * code — so the dominant timeout shape (SIGTERM, then SIGKILL) is reaped while
@@ -320,8 +328,13 @@ export interface AttachedTurnDeps {
320
328
  * True while any verified member of the provider process tree is still live.
321
329
  * Unknown/probe failure MUST read as true: a cancellation may only be reported
322
330
  * reaped after the whole tree is positively gone.
331
+ *
332
+ * 6.53.3: may answer with a promise, and the production one does: it reads the process
333
+ * table with an async `ps`, so the poll that waits for a cancelled tree no longer stalls
334
+ * the event loop ~43 ms per read. A plain boolean is still accepted. Anything but an
335
+ * exact `false` (a rejection, `undefined`, a throw) reads as alive.
323
336
  */
324
- processTreeAlive: (child: AttachedChildProcess) => boolean
337
+ processTreeAlive: (child: AttachedChildProcess) => boolean | PromiseLike<boolean>
325
338
  /**
326
339
  * Argv builder. Optional, defaults to the real one.
327
340
  *
@@ -401,6 +414,26 @@ export const CANCEL_KILL_GRACE_MS = 5_000
401
414
  */
402
415
  export const FORCE_SETTLE_MS = 2_000
403
416
 
417
+ /**
418
+ * 6.53.3: how often a cancelled or timed-out turn re-reads the process table after the CLI
419
+ * closed while part of its tree still lives.
420
+ *
421
+ * Polling, not waiting out the grace, is the point: a tool that exits one second after
422
+ * SIGTERM used to hold the result for the whole five-second cancel grace (measured 5.3 s).
423
+ * 200 ms keeps that to about one second while costing one async `ps` per tick; 6.53.2 ran
424
+ * a blocking `ps` every 25 ms, up to ~29 reads in the two seconds after SIGKILL.
425
+ */
426
+ export const TREE_POLL_MS = 200
427
+
428
+ /** 6.53.3: the first re-read after SIGKILL. The kernel needs a moment to tear the tree down. */
429
+ const TREE_POLL_AFTER_KILL_MS = 25
430
+
431
+ /**
432
+ * 6.53.3: the last read at the force-settle deadline gets this long to answer. A probe that
433
+ * never answers must not wedge the route's claim, so the cap settles unreaped instead.
434
+ */
435
+ export const FINAL_PROBE_CAP_MS = 1_500
436
+
404
437
  /**
405
438
  * Prompt ceiling. Generous — this is a guard against a pathological or
406
439
  * corrupted value reaching a pipe, not a product limit.
@@ -812,8 +845,10 @@ async function run(
812
845
  }
813
846
  // 6.53.0: a cancel that landed before the spawn. No process is created, so nothing can
814
847
  // have been delivered; the turn ends `cancelled`, which the route never fences.
848
+ // 6.53.3: and `reaped: true`, because no process exists to survive. A reader that asks
849
+ // "is anything of this turn still running?" gets the true answer, not the fail() default.
815
850
  if (request.abortSignal?.aborted === true) {
816
- return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', durationMs: duration() })
851
+ return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', reaped: true, durationMs: duration() })
817
852
  }
818
853
  let child: AttachedChildProcess
819
854
  try {
@@ -884,12 +919,22 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
884
919
  }
885
920
  }
886
921
 
887
- function safeProcessTreeAlive(deps: AttachedTurnDeps, child: AttachedChildProcess): boolean {
922
+ /**
923
+ * 6.53.3: resolves true unless the probe positively answered `false`.
924
+ *
925
+ * Never rejects. A failed ownership probe is not proof that the process tree is gone, and
926
+ * neither is a malformed answer: 6.53.2 read anything but `true` as dead, so a probe that
927
+ * returned `undefined` would have reported a reaped tree.
928
+ */
929
+ function probeProcessTree(deps: AttachedTurnDeps, child: AttachedChildProcess): Promise<boolean> {
888
930
  try {
889
- return deps.processTreeAlive(child) === true
931
+ const answer = deps.processTreeAlive(child)
932
+ if (answer !== null && typeof answer === 'object' && typeof (answer as PromiseLike<boolean>).then === 'function') {
933
+ return Promise.resolve(answer).then(alive => alive !== false, () => true)
934
+ }
935
+ return Promise.resolve(answer !== false)
890
936
  } catch {
891
- // A failed ownership probe is not proof that the process tree is gone.
892
- return true
937
+ return Promise.resolve(true)
893
938
  }
894
939
  }
895
940
 
@@ -928,8 +973,16 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
928
973
  let timedOut = false
929
974
  /** 6.53.0: the caller's abort fired after the child existed. Read before `timedOut`. */
930
975
  let cancelled = false
976
+ /**
977
+ * 6.53.3: that abort landed before the prompt write. The result keeps `delivery:
978
+ * 'aborted'` (no byte exists) but still waits for the tree like any other cancel.
979
+ */
980
+ let abortedBeforeWrite = false
931
981
  let spawnErrored = false
932
982
  let onAbort: (() => void) | null = null
983
+ /** 6.53.3: one async process-table read at a time; a request during one reruns after it. */
984
+ let probeInFlight = false
985
+ let probeAgain = false
933
986
 
934
987
  let deadline: ReturnType<typeof setTimeout> | null = null
935
988
  let graceTimer: ReturnType<typeof setTimeout> | null = null
@@ -966,8 +1019,9 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
966
1019
  nativeThreadId,
967
1020
  returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
968
1021
  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
1022
+ // Every settle but the force-settle below is reached from `settleTerminal`,
1023
+ // which only runs after the `close`/`error` handlers (directly, or from the
1024
+ // 6.53.3 tree poll once a read shows nothing owned) — so the child was
971
1025
  // reaped. The one exception overrides this explicitly.
972
1026
  reaped: true,
973
1027
  stderrClass: classifyStderr(stderrSample),
@@ -1011,11 +1065,79 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1011
1065
  }
1012
1066
  }
1013
1067
 
1068
+ // A child that survived SIGKILL cannot be reached from here, and blocking forever
1069
+ // would wedge the coordinator and any Control drain behind it. A cancel that landed
1070
+ // during the timeout's escalation still reads as the user's (6.53.0).
1071
+ const settleUnreaped = () => {
1072
+ // 6.53.3: no prompt byte was written, so the delivery stays `aborted`; `reaped: false`
1073
+ // is what tells the route a process of ours may still be alive.
1074
+ if (abortedBeforeWrite) return settleFailure('cancelled', { detail: 'unreaped', reaped: false })
1075
+ if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1076
+ else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1077
+ }
1078
+
1079
+ // --- 6.53.3: wait for the WHOLE tree, by polling ------------------------------
1080
+ // The CLI wrapper may close before a tool in a separate process group. 6.53.2 then
1081
+ // parked the result until the SIGKILL grace ran out and only polled after it, so a
1082
+ // tool that stopped one second after SIGTERM still held the turn ~5.3 s. Now the
1083
+ // table is re-read from `close` on, and the turn settles on the first read that
1084
+ // positively shows no owned process. The grace timers stay armed throughout: this
1085
+ // only ever settles EARLIER, never skips the SIGKILL a stubborn tool needs.
1086
+ const runTreeProbe = () => {
1087
+ treePollTimer = null
1088
+ if (settled) return
1089
+ if (probeInFlight) {
1090
+ probeAgain = true
1091
+ return
1092
+ }
1093
+ probeInFlight = true
1094
+ void probeProcessTree(deps, child).then(alive => {
1095
+ probeInFlight = false
1096
+ if (settled) return
1097
+ // The probe already proved the tree gone. Judge the outcome without a second read.
1098
+ if (!alive) return settleTerminal()
1099
+ if (probeAgain) {
1100
+ probeAgain = false
1101
+ return runTreeProbe()
1102
+ }
1103
+ if (!treePollTimer) treePollTimer = setTimeout(runTreeProbe, TREE_POLL_MS)
1104
+ })
1105
+ }
1106
+
1107
+ // The force deadline after SIGKILL. One last read first, so a tree that died since
1108
+ // the previous poll is not reported unreaped; the cap keeps a probe that never
1109
+ // answers from wedging the claim.
1110
+ const forceSettle = () => {
1111
+ forceTimer = null
1112
+ if (settled) return
1113
+ if (!terminalObserved) return settleUnreaped()
1114
+ forceTimer = setTimeout(settleUnreaped, FINAL_PROBE_CAP_MS)
1115
+ void probeProcessTree(deps, child).then(alive => {
1116
+ if (settled) return
1117
+ if (alive) settleUnreaped()
1118
+ else settleTerminal()
1119
+ })
1120
+ }
1121
+
1122
+ const escalateToKill = () => {
1123
+ graceTimer = null
1124
+ if (settled) return
1125
+ safeTerminate(deps, child, 'SIGKILL')
1126
+ if (terminalObserved) {
1127
+ if (treePollTimer) clearTimeout(treePollTimer)
1128
+ treePollTimer = setTimeout(runTreeProbe, TREE_POLL_AFTER_KILL_MS)
1129
+ }
1130
+ forceTimer = setTimeout(forceSettle, FORCE_SETTLE_MS)
1131
+ }
1132
+
1014
1133
  const finishTerminal = () => {
1015
1134
  // The CLI wrapper may close before a tool in a separate process group.
1016
1135
  // Keep the escalation timers alive until the verified whole tree is gone.
1017
- if ((cancelled || timedOut) && safeProcessTreeAlive(deps, child)) return
1136
+ if (cancelled || timedOut) return runTreeProbe()
1137
+ settleTerminal()
1138
+ }
1018
1139
 
1140
+ const settleTerminal = () => {
1019
1141
  // Drain whatever sat in the trailing partial line before judging.
1020
1142
  if (stdoutTail.length > 0) {
1021
1143
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
@@ -1029,6 +1151,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1029
1151
  // clean exit 0 that echoed our exact id: that turn finished before the stop landed,
1030
1152
  // and reporting it cancelled would hide a delivered turn.
1031
1153
  if (cancelled && !(exitCode === 0 && delivery === 'ambiguous' && observedIds.length === 1 && observedIds[0] === nativeThreadId)) {
1154
+ // 6.53.3: stopped before the write, and the tree is gone. `delivery` is still
1155
+ // `aborted` here, and with `reaped: true` the route ends the turn cancelled
1156
+ // with no fence, which is what "not one byte was written" means.
1157
+ if (abortedBeforeWrite) return settleFailure('cancelled', { detail: 'before_write' })
1032
1158
  return settleFailure('cancelled', { delivery: 'cancelled' })
1033
1159
  }
1034
1160
  if (timedOut) return settleFailure('timeout')
@@ -1118,25 +1244,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1118
1244
  deadline = setTimeout(() => {
1119
1245
  timedOut = true
1120
1246
  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)
1247
+ graceTimer = setTimeout(escalateToKill, KILL_GRACE_MS)
1140
1248
  }, timeoutMs)
1141
1249
 
1142
1250
  // --- the cancel (6.53.0) ---------------------------------------------------
@@ -1153,18 +1261,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1153
1261
  if (deadline) { clearTimeout(deadline); deadline = null }
1154
1262
  safeTerminate(deps, child, 'SIGTERM')
1155
1263
  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)
1264
+ escalateToKill()
1168
1265
  }, CANCEL_KILL_GRACE_MS)
1169
1266
  }
1170
1267
  try {
@@ -1195,10 +1292,21 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1195
1292
  // 6.53.0: a cancel that landed between the spawn check and here (a dependency that
1196
1293
  // yielded, or one that aborted synchronously). No prompt byte is written: the child is
1197
1294
  // killed and the turn is provably undelivered.
1295
+ //
1296
+ // 6.53.3: and REAPED like any other cancel before it settles. 6.53.2 returned at once
1297
+ // with `reaped: false`, so the route fenced a thread no byte had reached while an
1298
+ // adapter test called the same result unambiguous. Now `close` plus a tree read that
1299
+ // shows nothing owned settles `cancelled / aborted / reaped: true` (no fence), and
1300
+ // only a tree that outlives SIGKILL by FORCE_SETTLE_MS settles `reaped: false`.
1198
1301
  if (abortSignal?.aborted === true) {
1199
1302
  safeTerminate(deps, child, 'SIGKILL')
1200
1303
  delivery = 'aborted'
1201
- return settleFailure('cancelled', { detail: 'before_write', reaped: false })
1304
+ cancelled = true
1305
+ abortedBeforeWrite = true
1306
+ if (deadline) { clearTimeout(deadline); deadline = null }
1307
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
1308
+ forceTimer = setTimeout(forceSettle, FORCE_SETTLE_MS)
1309
+ return
1202
1310
  }
1203
1311
 
1204
1312
  try {
@@ -1220,57 +1328,343 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1220
1328
  // Production wiring
1221
1329
  // ---------------------------------------------------------------------------
1222
1330
 
1223
- interface AttachedProcessRow {
1331
+ /** One row of ONE atomic `ps` table read. */
1332
+ export interface AttachedProcessRow {
1224
1333
  pid: number
1225
1334
  ppid: number
1226
1335
  pgid: number
1336
+ startMs: number | null
1337
+ }
1338
+
1339
+ /**
1340
+ * 6.53.3: is Node still holding the provider leader?
1341
+ *
1342
+ * unreaped `exit` has not fired, so the pid is alive or a zombie and CANNOT have been
1343
+ * recycled. Its process group is ours whatever a table read says.
1344
+ * reaped Node collected it. The pid is free, and a row carrying it is a stranger.
1345
+ * unknown a test double without the ChildProcess fields. Judged by remembered
1346
+ * identity, as 6.53.2 judged every leader.
1347
+ */
1348
+ export type AttachedLeaderState = 'unreaped' | 'reaped' | 'unknown'
1349
+
1350
+ export function attachedLeaderState(child: AttachedChildProcess): AttachedLeaderState {
1351
+ const status = child as { exitCode?: unknown; signalCode?: unknown }
1352
+ if (status.exitCode === null && status.signalCode === null) return 'unreaped'
1353
+ if (typeof status.exitCode === 'number' || typeof status.signalCode === 'string') return 'reaped'
1354
+ return 'unknown'
1355
+ }
1356
+
1357
+ /**
1358
+ * What one turn has learned about its own process tree, keyed by the leader pid.
1359
+ * `realAttachedTurnDeps` builds one per turn, so nothing crosses turns.
1360
+ */
1361
+ export interface AttachedTreeMemory {
1362
+ /** pid -> start of every process seen in the tree. Absent from a complete read = gone. */
1363
+ rememberedByRoot: Map<number, Map<number, number>>
1364
+ /**
1365
+ * 6.53.3: pgid -> leader start (null until the leader row is seen) of every process
1366
+ * group a member of the tree has sat in. The leader's own group is in it from the spawn.
1367
+ */
1368
+ groupsByRoot: Map<number, Map<number, number | null>>
1369
+ /**
1370
+ * 6.53.3: lower bound on the start of anything the turn owns: the wall clock just
1371
+ * before the spawn call, less OWNED_START_SLACK_MS.
1372
+ */
1373
+ floorByRoot: Map<number, number>
1374
+ /** Roots whose tree a failed or malformed read may have hidden. Latched for the turn. */
1375
+ uncertainRoots: Set<number>
1376
+ /**
1377
+ * 6.53.3: bumped by every `terminate`. A liveness read that was already in flight when
1378
+ * a terminate re-read the table is older than what memory now holds, so it answers
1379
+ * "alive" and changes nothing rather than forgetting what the newer read learned.
1380
+ */
1381
+ terminationsByRoot: Map<number, number>
1382
+ }
1383
+
1384
+ export function createAttachedTreeMemory(): AttachedTreeMemory {
1385
+ return {
1386
+ rememberedByRoot: new Map(),
1387
+ groupsByRoot: new Map(),
1388
+ floorByRoot: new Map(),
1389
+ uncertainRoots: new Set(),
1390
+ terminationsByRoot: new Map(),
1391
+ }
1392
+ }
1393
+
1394
+ /**
1395
+ * 6.53.3: `lstart` is truncated to the second, so a process started in the spawn's own
1396
+ * second reads up to 999 ms before the spawn call. Nothing of this turn can start earlier.
1397
+ */
1398
+ const OWNED_START_SLACK_MS = 1_000
1399
+
1400
+ const ATTACHED_PS_ARGS: readonly string[] = ['-axo', 'pid=,ppid=,pgid=,lstart=']
1401
+
1402
+ function attachedPsOptions() {
1403
+ return {
1404
+ encoding: 'utf8' as const,
1405
+ timeout: 1_000,
1406
+ maxBuffer: 2 * 1024 * 1024,
1407
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' },
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * One malformed row voids the whole read (null). The row we cannot parse might be the
1413
+ * member that escaped, and a partial table cannot prove anything is absent.
1414
+ */
1415
+ function parseAttachedProcessRows(output: string, now: number): AttachedProcessRow[] | null {
1416
+ const rows: AttachedProcessRow[] = []
1417
+ try {
1418
+ for (const line of output.split('\n')) {
1419
+ if (line.trim().length === 0) continue
1420
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)(?:\s+(.*?))?\s*$/)
1421
+ if (!match) return null
1422
+ const pid = Number(match[1])
1423
+ const ppid = Number(match[2])
1424
+ const pgid = Number(match[3])
1425
+ if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return null
1426
+ const startMs = interpretPsLstart(match[4] ?? '', now)
1427
+ if (startMs === null) return null
1428
+ rows.push({ pid, ppid, pgid, startMs })
1429
+ }
1430
+ } catch {
1431
+ return null
1432
+ }
1433
+ return rows
1227
1434
  }
1228
1435
 
1229
1436
  /**
1230
- * Read the live parent and process-group graph for an attached provider.
1437
+ * Read the live parent and process-group graph for an attached provider, SYNCHRONOUSLY.
1231
1438
  *
1232
1439
  * Codex may put a shell tool in its OWN process group. Signalling only the
1233
1440
  * 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
1441
+ * This read is synchronous because termination is a one-shot ownership
1235
1442
  * 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.
1443
+ * it. A failed read still signals the root group while Node holds the leader, but
1444
+ * latches uncertainty so the adapter cannot later report a false whole-tree reap.
1445
+ *
1446
+ * 6.53.3: only `terminate` uses this (twice per cancel at most: SIGTERM, SIGKILL). The
1447
+ * liveness poll reads through `readAttachedProcessRowsAsync`.
1238
1448
  */
1239
1449
  function readAttachedProcessRows(): AttachedProcessRow[] | null {
1240
1450
  if (process.platform === 'win32') return []
1241
1451
  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
- })
1452
+ const output = execFileSync('/bin/ps', [...ATTACHED_PS_ARGS], attachedPsOptions())
1453
+ return parseAttachedProcessRows(output, Date.now())
1256
1454
  } catch {
1257
1455
  return null
1258
1456
  }
1259
1457
  }
1260
1458
 
1261
- function attachedProcessTree(rows: AttachedProcessRow[], roots: Iterable<number>): AttachedProcessRow[] {
1459
+ /**
1460
+ * 6.53.3: the same read without blocking the event loop.
1461
+ *
1462
+ * One `ps -axo` over ~1,400 processes takes ~43 ms. 6.53.2 ran it synchronously every
1463
+ * 25 ms while it waited out a cancelled tree, stalling every request on the server
1464
+ * (review A7). A liveness answer is only ever acted on after it arrives, so nothing is
1465
+ * lost by waiting for it. Every failure (spawn, timeout, non-zero exit, oversized or
1466
+ * malformed output) resolves null, never a partial table.
1467
+ */
1468
+ function readAttachedProcessRowsAsync(): Promise<AttachedProcessRow[] | null> {
1469
+ if (process.platform === 'win32') return Promise.resolve([])
1470
+ return new Promise(resolve => {
1471
+ try {
1472
+ execFile('/bin/ps', [...ATTACHED_PS_ARGS], attachedPsOptions(), (error, stdout) => {
1473
+ if (error) return resolve(null)
1474
+ resolve(parseAttachedProcessRows(String(stdout), Date.now()))
1475
+ })
1476
+ } catch {
1477
+ resolve(null)
1478
+ }
1479
+ })
1480
+ }
1481
+
1482
+ /**
1483
+ * Which rows of ONE atomic table read belong to this turn. Updates `memory`.
1484
+ *
1485
+ * A row is owned when it is
1486
+ * 1. a remembered identity (pid AND start) that still names the same kernel process;
1487
+ * 2. the leader, while Node has not reaped it (the pid cannot be recycled before that);
1488
+ * 3. a descendant, by ppid, of anything owned; or
1489
+ * 4. (6.53.3) a member of a remembered process group that started no earlier than the
1490
+ * spawn floor. The leader's own group counts from the spawn; a tool's group counts
1491
+ * from the first read that saw one of our processes in it. Either stops counting the
1492
+ * first time a complete read shows it empty (its id may be recycled from then on) or
1493
+ * shows its leader pid carrying a different start.
1494
+ *
1495
+ * Rule 4 is the 6.53.3 change. A background job already re-parented to launchd keeps its
1496
+ * process group but has no ppid path back to us: 6.53.2 sent that group one SIGTERM,
1497
+ * never tracked it, and reported `reaped: true` 3/3 while the job lived (review A2). And
1498
+ * once the leader had exited, a same-group process holding the CLI's stdout was owned by
1499
+ * nothing, so it was never signalled at all (review A3, a regression from 6.52.3).
1500
+ *
1501
+ * WHY GROUP MEMBERSHIP CAN BE TRUSTED. The leader is spawned `detached`, i.e. through
1502
+ * `setsid`, so the provider runs in its own session, and a process can only join a group
1503
+ * inside its own session. Every group a member of the tree sits in was therefore made
1504
+ * inside the tree. The exceptions, the group this server runs in (a leader spawned
1505
+ * without `setsid`) and launchd's, are refused outright.
1506
+ */
1507
+ export function ownedAttachedProcessRows(
1508
+ rows: readonly AttachedProcessRow[],
1509
+ rootPid: number,
1510
+ leader: AttachedLeaderState,
1511
+ memory: AttachedTreeMemory,
1512
+ ): AttachedProcessRow[] {
1513
+ const { uncertainRoots } = memory
1514
+ const remembered = memory.rememberedByRoot.get(rootPid) ?? new Map<number, number>()
1515
+ memory.rememberedByRoot.set(rootPid, remembered)
1516
+ const groups = memory.groupsByRoot.get(rootPid) ?? new Map<number, number | null>([[rootPid, null]])
1517
+ memory.groupsByRoot.set(rootPid, groups)
1518
+
1519
+ const rowsByPid = new Map(rows.map(row => [row.pid, row]))
1520
+ const membersByGroup = new Map<number, AttachedProcessRow[]>()
1521
+ for (const row of rows) {
1522
+ const members = membersByGroup.get(row.pgid)
1523
+ if (members) members.push(row)
1524
+ else membersByGroup.set(row.pgid, [row])
1525
+ }
1526
+ const serverGroup = rowsByPid.get(process.pid)?.pgid ?? null
1527
+ const foreignGroup = (pgid: number) => pgid <= 1 || pgid === serverGroup
1528
+
1529
+ // Only identities that still name the same kernel process may seed a fresh
1530
+ // descendant scan. This is what makes the five-second SIGKILL safe after the
1531
+ // provider leader has already disappeared. The leader itself is judged below,
1532
+ // by Node's own reap state, never by a remembered identity.
1533
+ const knownRootStart = remembered.get(rootPid)
1534
+ const rememberedRoots: number[] = []
1535
+ for (const [pid, startedAt] of remembered) {
1536
+ if (pid === rootPid) continue
1537
+ const row = rowsByPid.get(pid)
1538
+ if (!row) {
1539
+ // Absence from a successful full process-table snapshot is the only
1540
+ // positive proof this identity is gone.
1541
+ remembered.delete(pid)
1542
+ } else if (row.startMs === startedAt) {
1543
+ rememberedRoots.push(pid)
1544
+ } else if (row.startMs === null) {
1545
+ // The pid still exists but its start could not be read. That is doubt,
1546
+ // not death; retain the identity and fail this turn toward the fence.
1547
+ uncertainRoots.add(rootPid)
1548
+ } else {
1549
+ // A different start means PID reuse. The original identity is gone and
1550
+ // the replacement may never be signalled from remembered state.
1551
+ remembered.delete(pid)
1552
+ }
1553
+ }
1554
+
1555
+ const rootRow = rowsByPid.get(rootPid)
1556
+ let rootOwned = false
1557
+ if (rootRow && leader !== 'reaped') {
1558
+ if (rootRow.startMs === null) uncertainRoots.add(rootPid)
1559
+ else if (leader === 'unreaped' || knownRootStart === undefined || knownRootStart === rootRow.startMs) rootOwned = true
1560
+ }
1561
+ if (rootOwned && rootRow && rootRow.startMs !== null) {
1562
+ if (knownRootStart === undefined) remembered.set(rootPid, rootRow.startMs)
1563
+ // Only for a leader spawned outside realAttachedTurnDeps: the kernel start is a
1564
+ // floor for everything the leader went on to create.
1565
+ if (!memory.floorByRoot.has(rootPid)) memory.floorByRoot.set(rootPid, rootRow.startMs - OWNED_START_SLACK_MS)
1566
+ }
1567
+ const floor = memory.floorByRoot.get(rootPid)
1568
+
1569
+ for (const [pgid, leaderStart] of groups) {
1570
+ const leaderRow = rowsByPid.get(pgid)
1571
+ if (!membersByGroup.has(pgid)) {
1572
+ // Empty in a complete read: gone, and its id may be recycled from here on. Only
1573
+ // the leader's group cannot be empty while Node still holds the leader.
1574
+ if (!(pgid === rootPid && leader === 'unreaped')) groups.delete(pgid)
1575
+ continue
1576
+ }
1577
+ if (pgid === rootPid && leader === 'reaped' && leaderRow) {
1578
+ // The leader pid is back in the table after Node reaped it. POSIX does not reuse
1579
+ // a pid that is still a live group id, so the root group emptied in between.
1580
+ groups.delete(pgid)
1581
+ continue
1582
+ }
1583
+ if (leaderRow && leaderStart !== null && leaderRow.startMs !== leaderStart) {
1584
+ // A tool group's leader pid carrying another start: the same inference.
1585
+ groups.delete(pgid)
1586
+ continue
1587
+ }
1588
+ if (leaderStart === null && leaderRow && leaderRow.pgid === pgid && leaderRow.startMs !== null
1589
+ && !(pgid === rootPid && leader === 'reaped')) {
1590
+ groups.set(pgid, leaderRow.startMs)
1591
+ }
1592
+ }
1593
+
1594
+ const groupOwns = (row: AttachedProcessRow): boolean => {
1595
+ if (foreignGroup(row.pgid)) return false
1596
+ // While Node holds the leader, its group cannot have been recycled: all of it is ours.
1597
+ if (row.pgid === rootPid && leader === 'unreaped') return true
1598
+ if (!groups.has(row.pgid)) return false
1599
+ return floor !== undefined && row.startMs !== null && row.startMs >= floor
1600
+ }
1601
+
1602
+ const roots = [...rememberedRoots]
1603
+ if (rootOwned && !roots.includes(rootPid)) roots.push(rootPid)
1262
1604
  const owned = new Set<number>(roots)
1263
1605
  let changed = true
1264
1606
  while (changed) {
1265
1607
  changed = false
1266
1608
  for (const row of rows) {
1267
- if (!owned.has(row.pid) && owned.has(row.ppid)) {
1609
+ if (owned.has(row.pid)) continue
1610
+ if (owned.has(row.ppid) || groupOwns(row)) {
1268
1611
  owned.add(row.pid)
1269
1612
  changed = true
1270
1613
  }
1271
1614
  }
1615
+ // Any group an owned process sits in was made inside the tree (see above). Remember
1616
+ // it from this read on; its other members join in the next pass of this same read.
1617
+ for (const pid of owned) {
1618
+ const row = rowsByPid.get(pid)
1619
+ if (!row || foreignGroup(row.pgid) || groups.has(row.pgid)) continue
1620
+ const groupLeader = rowsByPid.get(row.pgid)
1621
+ groups.set(row.pgid, groupLeader && groupLeader.pgid === row.pgid ? groupLeader.startMs : null)
1622
+ changed = true
1623
+ }
1624
+ }
1625
+
1626
+ const tree = rows.filter(row => owned.has(row.pid))
1627
+ for (const row of tree) {
1628
+ if (row.startMs !== null) remembered.set(row.pid, row.startMs)
1629
+ else uncertainRoots.add(rootPid)
1630
+ }
1631
+ return tree
1632
+ }
1633
+
1634
+ /** Which signals one `terminate` sends. Pure, so every rule is testable without a kill. */
1635
+ export interface AttachedTerminationPlan {
1636
+ /** Tool process groups, signalled whole and FIRST. Only groups with no member we do not own. */
1637
+ groups: number[]
1638
+ /** Every owned pid but the leader, signalled one by one. */
1639
+ pids: number[]
1640
+ /** Whether the leader's own group is signalled. */
1641
+ rootGroup: boolean
1642
+ }
1643
+
1644
+ export function planAttachedTermination(
1645
+ rows: readonly AttachedProcessRow[] | null,
1646
+ rootPid: number,
1647
+ leader: AttachedLeaderState,
1648
+ memory: AttachedTreeMemory,
1649
+ ): AttachedTerminationPlan {
1650
+ if (rows === null) {
1651
+ memory.uncertainRoots.add(rootPid)
1652
+ // 6.53.3 (A3): a failed read no longer skips the root group. While Node holds the
1653
+ // leader, `-rootPid` names our group and nothing else, exactly as 6.52.3 assumed.
1654
+ return { groups: [], pids: [], rootGroup: leader === 'unreaped' }
1655
+ }
1656
+ const tree = ownedAttachedProcessRows(rows, rootPid, leader, memory)
1657
+ const ownedPids = new Set(tree.map(row => row.pid))
1658
+ // A group holding any process we do not own is signalled member by member instead.
1659
+ const wholeGroupOwned = (pgid: number) => rows.every(row => row.pgid !== pgid || ownedPids.has(row.pid))
1660
+ const toolGroups = new Set(tree.filter(row => row.pgid !== rootPid).map(row => row.pgid))
1661
+ return {
1662
+ groups: [...toolGroups].filter(wholeGroupOwned),
1663
+ pids: tree.filter(row => row.pid !== rootPid).map(row => row.pid),
1664
+ // 6.53.3 (A3): unconditional while Node holds the leader. After that, only while the
1665
+ // group still holds processes we own and none we do not.
1666
+ rootGroup: leader === 'unreaped' || (tree.some(row => row.pgid === rootPid) && wholeGroupOwned(rootPid)),
1272
1667
  }
1273
- return rows.filter(row => owned.has(row.pid))
1274
1668
  }
1275
1669
 
1276
1670
  function signalPid(pid: number, signal: NodeJS.Signals): void {
@@ -1283,124 +1677,59 @@ function signalGroup(pgid: number, signal: NodeJS.Signals): void {
1283
1677
  }
1284
1678
 
1285
1679
  /**
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.
1680
+ * Stop the provider plus every process this turn owns (see `ownedAttachedProcessRows`).
1681
+ * Captured pid/start identities and group ids survive the SIGTERM grace, so SIGKILL can
1682
+ * still reach an uncooperative tool after the CLI has exited and the tool has been
1683
+ * re-parented to launchd. Identities are re-checked before reuse; a recycled pid or
1684
+ * group id is never signalled from remembered state.
1291
1685
  */
1292
1686
  function terminateAttachedProcessTree(
1293
1687
  child: AttachedChildProcess,
1294
1688
  signal: NodeJS.Signals,
1295
- rememberedByRoot: Map<number, Map<number, number>>,
1296
- uncertainRoots: Set<number>,
1689
+ memory: AttachedTreeMemory,
1297
1690
  ): void {
1298
1691
  const rootPid = child.pid
1299
1692
  if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
1300
1693
  try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
1301
1694
  return
1302
1695
  }
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
- }
1696
+ memory.terminationsByRoot.set(rootPid, (memory.terminationsByRoot.get(rootPid) ?? 0) + 1)
1697
+ // Read before the table: Node cannot reap while this synchronous call holds the loop,
1698
+ // so the leader state and the read agree.
1699
+ const plan = planAttachedTermination(readAttachedProcessRows(), rootPid, attachedLeaderState(child), memory)
1331
1700
 
1332
1701
  // A child in a separate group must be signalled BEFORE the CLI group. If the
1333
1702
  // 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)
1703
+ // no longer discoverable by ppid.
1704
+ for (const pgid of plan.groups) signalGroup(pgid, signal)
1705
+ for (const pid of plan.pids) signalPid(pid, signal)
1706
+ if (plan.rootGroup) signalGroup(rootPid, signal)
1345
1707
 
1346
1708
  // Direct-child fallback covers a spawn that failed before setsid established
1347
1709
  // the detached group, and is harmless after a successful group signal.
1348
1710
  try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
1349
-
1350
1711
  }
1351
1712
 
1352
- function attachedProcessTreeAlive(
1713
+ async function attachedProcessTreeAlive(
1353
1714
  child: AttachedChildProcess,
1354
- rememberedByRoot: Map<number, Map<number, number>>,
1355
- uncertainRoots: Set<number>,
1356
- ): boolean {
1715
+ memory: AttachedTreeMemory,
1716
+ ): Promise<boolean> {
1717
+ const { uncertainRoots } = memory
1357
1718
  const rootPid = child.pid
1358
1719
  if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
1359
- const remembered = rememberedByRoot.get(rootPid)
1360
- const rows = readAttachedProcessRows()
1720
+ const terminationsBefore = memory.terminationsByRoot.get(rootPid) ?? 0
1721
+ // 6.53.3 (A7): once doubt is latched the answer is "alive" whatever the table says, so
1722
+ // the table is not read again. 6.53.2 kept reading it every 25 ms until force-settle.
1723
+ const rows = uncertainRoots.has(rootPid) ? null : await readAttachedProcessRowsAsync()
1361
1724
  // A failed global process-table read cannot prove closure. Keep the record so
1362
1725
  // 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
- }
1726
+ if (rows === null) uncertainRoots.add(rootPid)
1727
+ // A terminate re-read the table while this one was in flight: this read is the older
1728
+ // one. Inconclusive, and it must not overwrite what the newer read remembered.
1729
+ if (rows !== null && (memory.terminationsByRoot.get(rootPid) ?? 0) !== terminationsBefore) return true
1730
+ // The leader state is read AFTER the await: Node may have reaped it while `ps` ran.
1731
+ const live = rows === null ? [] : ownedAttachedProcessRows(rows, rootPid, attachedLeaderState(child), memory)
1732
+ let anyLive = live.length > 0
1404
1733
 
1405
1734
  // Once a snapshot/start probe missed part of the tree, later ancestry cannot
1406
1735
  // reconstruct a process that may already have been re-parented. Never convert
@@ -1413,11 +1742,6 @@ function attachedProcessTreeAlive(
1413
1742
  } catch (error: any) {
1414
1743
  if (error?.code !== 'ESRCH') anyLive = true
1415
1744
  }
1416
-
1417
- if (!anyLive) {
1418
- rememberedByRoot.delete(rootPid)
1419
- uncertainRoots.delete(rootPid)
1420
- }
1421
1745
  return anyLive
1422
1746
  }
1423
1747
 
@@ -1430,24 +1754,32 @@ function attachedProcessTreeAlive(
1430
1754
  * which is the one answer this module must never invent.
1431
1755
  */
1432
1756
  export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
1433
- const rememberedProcesses = new Map<number, Map<number, number>>()
1434
- const uncertainProcessTrees = new Set<number>()
1757
+ const treeMemory = createAttachedTreeMemory()
1435
1758
  return {
1436
1759
  now: () => Date.now(),
1437
1760
  preflight,
1438
1761
  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,
1762
+ spawn: request => {
1763
+ // 6.53.3: read BEFORE the spawn call, so every process of this turn starts at or
1764
+ // after it. This is the floor that keeps group ownership (rule 4 above) from ever
1765
+ // claiming a process that predates the turn.
1766
+ const floor = Date.now() - OWNED_START_SLACK_MS
1767
+ const child = nodeSpawn(request.binaryPath, [...request.args], {
1768
+ stdio: ['pipe', 'pipe', 'pipe'],
1769
+ cwd: request.cwd,
1770
+ env: request.env,
1771
+ // Group leader, so the whole provider tree can be signalled on timeout —
1772
+ // matching what both ordinary bridges do.
1773
+ detached: true,
1774
+ })
1775
+ const pid = child.pid
1776
+ if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) treeMemory.floorByRoot.set(pid, floor)
1777
+ return child as unknown as AttachedChildProcess
1778
+ },
1447
1779
  processStartMs: pid => realProcessStartMs(pid),
1448
1780
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
1449
1781
  releaseSpawn: pid => releaseCosSpawn(pid),
1450
- terminate: (child, signal) => terminateAttachedProcessTree(child, signal, rememberedProcesses, uncertainProcessTrees),
1451
- processTreeAlive: child => attachedProcessTreeAlive(child, rememberedProcesses, uncertainProcessTrees),
1782
+ terminate: (child, signal) => terminateAttachedProcessTree(child, signal, treeMemory),
1783
+ processTreeAlive: child => attachedProcessTreeAlive(child, treeMemory),
1452
1784
  }
1453
1785
  }