@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.
- package/CHANGELOG.md +38 -0
- package/README.md +37 -1
- package/bin/hooks/cos-session-hook +88 -43
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +508 -176
- package/server/lib/claude-hooks-installer.ts +58 -0
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +141 -0
- package/server/lib/session-hooks-runtime.ts +62 -2
- package/server/routes/agent-session-bindings.ts +265 -28
- package/server/routes/health.ts +2 -1
- package/server/routes/meeting.ts +13 -4
- package/server/routes/transcribe-stream.ts +29 -1
- package/server/scripts/hooks-cli.ts +6 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
970
|
-
// which only runs
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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', [
|
|
1243
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1287
|
-
*
|
|
1288
|
-
* still reach an uncooperative tool after the CLI has exited and the tool has
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
|
1336
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
|
1360
|
-
|
|
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)
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
|
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 =>
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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,
|
|
1451
|
-
processTreeAlive: child => attachedProcessTreeAlive(child,
|
|
1782
|
+
terminate: (child, signal) => terminateAttachedProcessTree(child, signal, treeMemory),
|
|
1783
|
+
processTreeAlive: child => attachedProcessTreeAlive(child, treeMemory),
|
|
1452
1784
|
}
|
|
1453
1785
|
}
|