@gotcos/glasses-server 6.53.0 → 6.53.1
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
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 6.53.1
|
|
2
|
+
|
|
3
|
+
### Cancel the whole Codex process tree
|
|
4
|
+
|
|
5
|
+
The 6.53.0 live Codex canary found that `codex exec resume` can launch a tool in its own process group. Cancelling the wrapper group stopped the COS turn, but an in-flight tool could be re-parented to launchd and continue running. This hotfix snapshots verified descendant identities before signalling the wrapper, reaches separate descendant groups, retains PID/start-time identities across re-parenting, and escalates those retained processes to SIGKILL after the five-second cancel grace. A wrapper `close` no longer clears the escalation while any verified tree member remains. Probe failure fails closed, and PID reuse is never signalled.
|
|
6
|
+
|
|
7
|
+
A spawned cancellation is now reported as ordinary `turn_cancelled` only after the adapter confirms the entire tree was reaped. An unverified or surviving tree takes the existing ambiguous-delivery fence path. A pre-spawn cancellation remains provably process-free; a spawned pre-write cancellation still requires reaping proof even though no prompt byte was sent.
|
|
8
|
+
|
|
9
|
+
- Tests: a real detached Node wrapper launches a SIGTERM-resistant tool in a second process group; the wrapper dies, the retained tool survives TERM, SIGKILL reaches it after re-parenting, and the tree is confirmed gone. Fault-injected process-table and start-time probes stay uncertain instead of inventing death. Adapter coverage proves wrapper close cannot settle early; route coverage proves both post-write and pre-write unreaped cancellations fence. The mutation gate removes each of those protections independently.
|
|
10
|
+
|
|
1
11
|
## 6.53.0
|
|
2
12
|
|
|
3
13
|
### Cancel a session run from the glasses
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.53.
|
|
3
|
+
"version": "6.53.1",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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,7 +93,7 @@
|
|
|
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 { 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'
|
|
@@ -316,6 +316,12 @@ 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
|
+
processTreeAlive: (child: AttachedChildProcess) => boolean
|
|
319
325
|
/**
|
|
320
326
|
* Argv builder. Optional, defaults to the real one.
|
|
321
327
|
*
|
|
@@ -878,6 +884,15 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
|
|
|
878
884
|
}
|
|
879
885
|
}
|
|
880
886
|
|
|
887
|
+
function safeProcessTreeAlive(deps: AttachedTurnDeps, child: AttachedChildProcess): boolean {
|
|
888
|
+
try {
|
|
889
|
+
return deps.processTreeAlive(child) === true
|
|
890
|
+
} catch {
|
|
891
|
+
// A failed ownership probe is not proof that the process tree is gone.
|
|
892
|
+
return true
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
881
896
|
function safeRelease(deps: AttachedTurnDeps, pid: number): void {
|
|
882
897
|
try {
|
|
883
898
|
deps.releaseSpawn(pid)
|
|
@@ -919,11 +934,14 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
919
934
|
let deadline: ReturnType<typeof setTimeout> | null = null
|
|
920
935
|
let graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
921
936
|
let forceTimer: ReturnType<typeof setTimeout> | null = null
|
|
937
|
+
let treePollTimer: ReturnType<typeof setTimeout> | null = null
|
|
938
|
+
let terminalObserved = false
|
|
922
939
|
|
|
923
940
|
const clearTimers = () => {
|
|
924
941
|
if (deadline) { clearTimeout(deadline); deadline = null }
|
|
925
942
|
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
|
|
926
943
|
if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
|
|
944
|
+
if (treePollTimer) { clearTimeout(treePollTimer); treePollTimer = null }
|
|
927
945
|
}
|
|
928
946
|
|
|
929
947
|
const duration = () => readDuration(deps, startedAt)
|
|
@@ -994,6 +1012,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
994
1012
|
}
|
|
995
1013
|
|
|
996
1014
|
const finishTerminal = () => {
|
|
1015
|
+
// The CLI wrapper may close before a tool in a separate process group.
|
|
1016
|
+
// Keep the escalation timers alive until the verified whole tree is gone.
|
|
1017
|
+
if ((cancelled || timedOut) && safeProcessTreeAlive(deps, child)) return
|
|
1018
|
+
|
|
997
1019
|
// Drain whatever sat in the trailing partial line before judging.
|
|
998
1020
|
if (stdoutTail.length > 0) {
|
|
999
1021
|
for (const id of extractNativeIdsFromLine(stdoutTail)) {
|
|
@@ -1064,6 +1086,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1064
1086
|
})
|
|
1065
1087
|
child.on('close', (code: number | null) => {
|
|
1066
1088
|
if (typeof code === 'number') exitCode = code
|
|
1089
|
+
terminalObserved = true
|
|
1067
1090
|
finishTerminal()
|
|
1068
1091
|
})
|
|
1069
1092
|
} catch {
|
|
@@ -1097,14 +1120,22 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1097
1120
|
safeTerminate(deps, child, 'SIGTERM')
|
|
1098
1121
|
graceTimer = setTimeout(() => {
|
|
1099
1122
|
safeTerminate(deps, child, 'SIGKILL')
|
|
1100
|
-
|
|
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
|
+
}
|
|
1101
1131
|
// A child that survived SIGKILL cannot be reached from here, and
|
|
1102
1132
|
// blocking forever would wedge the coordinator and any Control drain
|
|
1103
1133
|
// behind it. A cancel that landed during this escalation still reads
|
|
1104
1134
|
// as the user's (6.53.0).
|
|
1105
1135
|
if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
|
|
1106
1136
|
else settleFailure('timeout', { detail: 'unreaped', reaped: false })
|
|
1107
|
-
}
|
|
1137
|
+
}
|
|
1138
|
+
forceTimer = setTimeout(pollForTreeExit, 25)
|
|
1108
1139
|
}, KILL_GRACE_MS)
|
|
1109
1140
|
}, timeoutMs)
|
|
1110
1141
|
|
|
@@ -1123,9 +1154,17 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1123
1154
|
safeTerminate(deps, child, 'SIGTERM')
|
|
1124
1155
|
graceTimer = setTimeout(() => {
|
|
1125
1156
|
safeTerminate(deps, child, 'SIGKILL')
|
|
1126
|
-
|
|
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
|
+
}
|
|
1127
1165
|
settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
|
|
1128
|
-
}
|
|
1166
|
+
}
|
|
1167
|
+
forceTimer = setTimeout(pollForTreeExit, 25)
|
|
1129
1168
|
}, CANCEL_KILL_GRACE_MS)
|
|
1130
1169
|
}
|
|
1131
1170
|
try {
|
|
@@ -1181,6 +1220,207 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1181
1220
|
// Production wiring
|
|
1182
1221
|
// ---------------------------------------------------------------------------
|
|
1183
1222
|
|
|
1223
|
+
interface AttachedProcessRow {
|
|
1224
|
+
pid: number
|
|
1225
|
+
ppid: number
|
|
1226
|
+
pgid: number
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Read the live parent and process-group graph for an attached provider.
|
|
1231
|
+
*
|
|
1232
|
+
* Codex may put a shell tool in its OWN process group. Signalling only the
|
|
1233
|
+
* detached CLI group then kills Codex while the tool is re-parented to pid 1.
|
|
1234
|
+
* This probe is synchronous because termination is a one-shot ownership
|
|
1235
|
+
* boundary: the parent graph must be captured before the CLI can exit and erase
|
|
1236
|
+
* it. A failed probe still signals the original root group below, but latches
|
|
1237
|
+
* uncertainty so the adapter cannot later report a false whole-tree reap.
|
|
1238
|
+
*/
|
|
1239
|
+
function readAttachedProcessRows(): AttachedProcessRow[] | null {
|
|
1240
|
+
if (process.platform === 'win32') return []
|
|
1241
|
+
try {
|
|
1242
|
+
const output = execFileSync('/bin/ps', ['-axo', 'pid=,ppid=,pgid='], {
|
|
1243
|
+
encoding: 'utf8',
|
|
1244
|
+
timeout: 1_000,
|
|
1245
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1246
|
+
})
|
|
1247
|
+
return output.split('\n').flatMap(line => {
|
|
1248
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/)
|
|
1249
|
+
if (!match) return []
|
|
1250
|
+
const pid = Number(match[1])
|
|
1251
|
+
const ppid = Number(match[2])
|
|
1252
|
+
const pgid = Number(match[3])
|
|
1253
|
+
if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return []
|
|
1254
|
+
return [{ pid, ppid, pgid }]
|
|
1255
|
+
})
|
|
1256
|
+
} catch {
|
|
1257
|
+
return null
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function attachedProcessTree(rows: AttachedProcessRow[], roots: Iterable<number>): AttachedProcessRow[] {
|
|
1262
|
+
const owned = new Set<number>(roots)
|
|
1263
|
+
let changed = true
|
|
1264
|
+
while (changed) {
|
|
1265
|
+
changed = false
|
|
1266
|
+
for (const row of rows) {
|
|
1267
|
+
if (!owned.has(row.pid) && owned.has(row.ppid)) {
|
|
1268
|
+
owned.add(row.pid)
|
|
1269
|
+
changed = true
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return rows.filter(row => owned.has(row.pid))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function signalPid(pid: number, signal: NodeJS.Signals): void {
|
|
1277
|
+
try { process.kill(pid, signal) } catch { /* already terminal or unavailable */ }
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function signalGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
1281
|
+
if (process.platform === 'win32') return
|
|
1282
|
+
try { process.kill(-pgid, signal) } catch { /* already terminal or unavailable */ }
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Stop the provider plus every descendant visible before its parent link is
|
|
1287
|
+
* lost. Captured pid/start identities survive the SIGTERM grace, so SIGKILL can
|
|
1288
|
+
* still reach an uncooperative tool after the CLI has exited and the tool has
|
|
1289
|
+
* been re-parented to launchd. Identities are re-checked before reuse; a recycled
|
|
1290
|
+
* pid is never signalled from remembered state.
|
|
1291
|
+
*/
|
|
1292
|
+
function terminateAttachedProcessTree(
|
|
1293
|
+
child: AttachedChildProcess,
|
|
1294
|
+
signal: NodeJS.Signals,
|
|
1295
|
+
rememberedByRoot: Map<number, Map<number, number>>,
|
|
1296
|
+
uncertainRoots: Set<number>,
|
|
1297
|
+
): void {
|
|
1298
|
+
const rootPid = child.pid
|
|
1299
|
+
if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
|
|
1300
|
+
try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const remembered = rememberedByRoot.get(rootPid) ?? new Map<number, number>()
|
|
1305
|
+
rememberedByRoot.set(rootPid, remembered)
|
|
1306
|
+
|
|
1307
|
+
// Only identities that still name the same kernel process may seed a fresh
|
|
1308
|
+
// descendant scan. This is what makes the five-second SIGKILL safe after the
|
|
1309
|
+
// provider leader has already disappeared.
|
|
1310
|
+
const rememberedRoots: number[] = []
|
|
1311
|
+
for (const [pid, startedAt] of remembered) {
|
|
1312
|
+
if (realProcessStartMs(pid) === startedAt) rememberedRoots.push(pid)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const rows = readAttachedProcessRows()
|
|
1316
|
+
if (rows === null) uncertainRoots.add(rootPid)
|
|
1317
|
+
const knownRootStart = remembered.get(rootPid)
|
|
1318
|
+
const currentRootStart = realProcessStartMs(rootPid)
|
|
1319
|
+
const rootVerified = currentRootStart !== null
|
|
1320
|
+
&& (knownRootStart === undefined || currentRootStart === knownRootStart)
|
|
1321
|
+
if (currentRootStart === null && rows?.some(row => row.pid === rootPid)) uncertainRoots.add(rootPid)
|
|
1322
|
+
if (knownRootStart === undefined && currentRootStart !== null) remembered.set(rootPid, currentRootStart)
|
|
1323
|
+
const roots = [...rememberedRoots]
|
|
1324
|
+
if (rootVerified && !roots.includes(rootPid)) roots.push(rootPid)
|
|
1325
|
+
const tree = rows === null ? [] : attachedProcessTree(rows, roots)
|
|
1326
|
+
for (const row of tree) {
|
|
1327
|
+
const startedAt = realProcessStartMs(row.pid)
|
|
1328
|
+
if (startedAt !== null) remembered.set(row.pid, startedAt)
|
|
1329
|
+
else uncertainRoots.add(rootPid)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// A child in a separate group must be signalled BEFORE the CLI group. If the
|
|
1333
|
+
// CLI exits first, macOS reparents that child to pid 1 and the relationship is
|
|
1334
|
+
// no longer discoverable.
|
|
1335
|
+
const descendantGroups = new Set(
|
|
1336
|
+
tree.filter(row => row.pid !== rootPid && row.pgid !== rootPid).map(row => row.pgid),
|
|
1337
|
+
)
|
|
1338
|
+
for (const pgid of descendantGroups) signalGroup(pgid, signal)
|
|
1339
|
+
for (const row of tree) {
|
|
1340
|
+
if (row.pid !== rootPid) signalPid(row.pid, signal)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const rootGroupStillOwned = rootVerified || tree.some(row => row.pgid === rootPid)
|
|
1344
|
+
if (rootGroupStillOwned) signalGroup(rootPid, signal)
|
|
1345
|
+
|
|
1346
|
+
// Direct-child fallback covers a spawn that failed before setsid established
|
|
1347
|
+
// the detached group, and is harmless after a successful group signal.
|
|
1348
|
+
try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
|
|
1349
|
+
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function attachedProcessTreeAlive(
|
|
1353
|
+
child: AttachedChildProcess,
|
|
1354
|
+
rememberedByRoot: Map<number, Map<number, number>>,
|
|
1355
|
+
uncertainRoots: Set<number>,
|
|
1356
|
+
): boolean {
|
|
1357
|
+
const rootPid = child.pid
|
|
1358
|
+
if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
|
|
1359
|
+
const remembered = rememberedByRoot.get(rootPid)
|
|
1360
|
+
const rows = readAttachedProcessRows()
|
|
1361
|
+
// A failed global process-table read cannot prove closure. Keep the record so
|
|
1362
|
+
// the SIGKILL escalation can still target identities captured before TERM.
|
|
1363
|
+
if (rows === null) return true
|
|
1364
|
+
|
|
1365
|
+
let anyLive = false
|
|
1366
|
+
if (remembered) {
|
|
1367
|
+
const verifiedRoots: number[] = []
|
|
1368
|
+
for (const [pid, startedAt] of remembered) {
|
|
1369
|
+
const rowExists = rows.some(row => row.pid === pid)
|
|
1370
|
+
if (!rowExists) {
|
|
1371
|
+
// Absence from a successful full process-table snapshot is the only
|
|
1372
|
+
// positive proof this identity is gone.
|
|
1373
|
+
remembered.delete(pid)
|
|
1374
|
+
continue
|
|
1375
|
+
}
|
|
1376
|
+
const currentStart = realProcessStartMs(pid)
|
|
1377
|
+
if (currentStart === startedAt) {
|
|
1378
|
+
anyLive = true
|
|
1379
|
+
verifiedRoots.push(pid)
|
|
1380
|
+
} else if (currentStart === null) {
|
|
1381
|
+
// The pid still exists but its start could not be read. That is doubt,
|
|
1382
|
+
// not death; retain the identity and permanently fail this cancellation
|
|
1383
|
+
// toward the route's ambiguity fence.
|
|
1384
|
+
anyLive = true
|
|
1385
|
+
uncertainRoots.add(rootPid)
|
|
1386
|
+
} else {
|
|
1387
|
+
// A different start means PID reuse. The original identity is gone and
|
|
1388
|
+
// the replacement may never be signalled from remembered state.
|
|
1389
|
+
remembered.delete(pid)
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const fresh = attachedProcessTree(rows, verifiedRoots)
|
|
1393
|
+
for (const row of fresh) {
|
|
1394
|
+
const startedAt = realProcessStartMs(row.pid)
|
|
1395
|
+
if (startedAt !== null) {
|
|
1396
|
+
remembered.set(row.pid, startedAt)
|
|
1397
|
+
anyLive = true
|
|
1398
|
+
} else {
|
|
1399
|
+
anyLive = true
|
|
1400
|
+
uncertainRoots.add(rootPid)
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Once a snapshot/start probe missed part of the tree, later ancestry cannot
|
|
1406
|
+
// reconstruct a process that may already have been re-parented. Never convert
|
|
1407
|
+
// that uncertainty into reaped:true; the route will fence after force-settle.
|
|
1408
|
+
if (uncertainRoots.has(rootPid)) anyLive = true
|
|
1409
|
+
|
|
1410
|
+
try {
|
|
1411
|
+
process.kill(-rootPid, 0)
|
|
1412
|
+
anyLive = true
|
|
1413
|
+
} catch (error: any) {
|
|
1414
|
+
if (error?.code !== 'ESRCH') anyLive = true
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (!anyLive) {
|
|
1418
|
+
rememberedByRoot.delete(rootPid)
|
|
1419
|
+
uncertainRoots.delete(rootPid)
|
|
1420
|
+
}
|
|
1421
|
+
return anyLive
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1184
1424
|
/**
|
|
1185
1425
|
* The real dependency set.
|
|
1186
1426
|
*
|
|
@@ -1190,6 +1430,8 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1190
1430
|
* which is the one answer this module must never invent.
|
|
1191
1431
|
*/
|
|
1192
1432
|
export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
|
|
1433
|
+
const rememberedProcesses = new Map<number, Map<number, number>>()
|
|
1434
|
+
const uncertainProcessTrees = new Set<number>()
|
|
1193
1435
|
return {
|
|
1194
1436
|
now: () => Date.now(),
|
|
1195
1437
|
preflight,
|
|
@@ -1205,23 +1447,7 @@ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict):
|
|
|
1205
1447
|
processStartMs: pid => realProcessStartMs(pid),
|
|
1206
1448
|
recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
|
|
1207
1449
|
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
|
-
},
|
|
1450
|
+
terminate: (child, signal) => terminateAttachedProcessTree(child, signal, rememberedProcesses, uncertainProcessTrees),
|
|
1451
|
+
processTreeAlive: child => attachedProcessTreeAlive(child, rememberedProcesses, uncertainProcessTrees),
|
|
1226
1452
|
}
|
|
1227
1453
|
}
|
|
@@ -1196,7 +1196,7 @@ type Delivery =
|
|
|
1196
1196
|
| { kind: 'completed'; after: string | null }
|
|
1197
1197
|
| { kind: 'aborted' }
|
|
1198
1198
|
| { kind: 'ambiguous' }
|
|
1199
|
-
/**
|
|
1199
|
+
/** Stopped by a cancel this route requested and positively reaped. */
|
|
1200
1200
|
| { kind: 'cancelled' }
|
|
1201
1201
|
|
|
1202
1202
|
/**
|
|
@@ -1214,11 +1214,17 @@ type Delivery =
|
|
|
1214
1214
|
export function classifyDelivery(result: unknown, cancelRequested = false): Delivery {
|
|
1215
1215
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
|
|
1216
1216
|
if (cancelRequested === true) {
|
|
1217
|
-
const { ok, reason, delivery } = result as { ok?: unknown; reason?: unknown; delivery?: unknown }
|
|
1217
|
+
const { ok, reason, delivery, reaped } = result as { ok?: unknown; reason?: unknown; delivery?: unknown; reaped?: unknown }
|
|
1218
1218
|
if (ok === false && reason === 'cancelled'
|
|
1219
|
-
&& (delivery === 'cancelled'
|
|
1219
|
+
&& ((delivery === 'cancelled' && reaped === true)
|
|
1220
|
+
|| delivery === 'not_attempted'
|
|
1221
|
+
|| (delivery === 'aborted' && reaped === true))) {
|
|
1220
1222
|
return { kind: 'cancelled' }
|
|
1221
1223
|
}
|
|
1224
|
+
// A cancel result that names a spawned-but-unreaped child must not fall
|
|
1225
|
+
// through to the generic `aborted` classifier below. The route requested
|
|
1226
|
+
// the stop, but it cannot claim the process tree is gone, so fence it.
|
|
1227
|
+
if (ok === false && reason === 'cancelled') return { kind: 'ambiguous' }
|
|
1222
1228
|
}
|
|
1223
1229
|
const status = (result as { status?: unknown }).status
|
|
1224
1230
|
if (status === 'completed') {
|