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