brl-subagent 2.4.0 → 2.5.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/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +77 -10
- package/src/index.ts +355 -63
- package/src/kill-escalation.ts +426 -0
- package/src/logging.ts +37 -3
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/preflight.ts +29 -14
- package/src/proc.ts +390 -0
- package/src/recovery-engine.ts +348 -0
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +343 -0
- package/src/run-registry.ts +330 -0
- package/src/runner.ts +409 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +234 -29
- package/src/state.ts +81 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +323 -103
- package/src/types.ts +173 -1
- package/src/unit-run.ts +16 -1
package/src/proc.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Purpose: Production process primitives — Linux/POSIX /proc reads, feature detection, and the default recovery deps.
|
|
2
|
+
/**
|
|
3
|
+
* Option B U1 — production process wiring for the recovery engine.
|
|
4
|
+
*
|
|
5
|
+
* The recovery decision engine (`src/recovery-engine.ts`) is pure over the
|
|
6
|
+
* injected `RecoveryDeps`. This module supplies the REAL deps: the Linux/POSIX
|
|
7
|
+
* `/proc` start-time token (`/proc/<pid>/stat` field 22), the child-marker scan
|
|
8
|
+
* (`/proc/<pid>/environ`), cross-platform pid liveness, and the default
|
|
9
|
+
* `RecoveryDeps` assembly.
|
|
10
|
+
*
|
|
11
|
+
* PLATFORM BOUNDARY (required): the start token and marker scan are Linux/POSIX
|
|
12
|
+
* reads. `isProcAvailable()` feature-detects them (cached; the answer cannot
|
|
13
|
+
* change mid-process). Where they are unavailable, `pidAlive` still works but
|
|
14
|
+
* on its own cannot rule out pid reuse — so the boot scan skips entirely and
|
|
15
|
+
* nothing is killed or marked unverified (`recoverProduction`, src/recovery.ts).
|
|
16
|
+
*
|
|
17
|
+
* `__setProcAvailableForTest` is the injectable seam that lets a test exercise
|
|
18
|
+
* that platform guard without mocking `node:fs` module-wide.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import type { ProcessOwner } from "./types";
|
|
24
|
+
import { SIGKILL_GRACE_MS, SIGKILL_POLL_MS } from "./types";
|
|
25
|
+
import { CHILD_MARKER_ENV_KEY } from "./sanitize";
|
|
26
|
+
import type { RecoveryDeps } from "./recovery-engine";
|
|
27
|
+
import { supportsProcessGroupKill } from "./kill-escalation";
|
|
28
|
+
|
|
29
|
+
const PROC_ROOT = "/proc";
|
|
30
|
+
|
|
31
|
+
let procAvailable: boolean | undefined;
|
|
32
|
+
let procAvailableOverride: boolean | undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* TEST-ONLY: force the `/proc` feature-detection answer (undefined restores the
|
|
36
|
+
* real detection and clears the cache). The production scan consults this
|
|
37
|
+
* through `isProcAvailable`, so the platform guard is exercisable in-repo.
|
|
38
|
+
*/
|
|
39
|
+
export function __setProcAvailableForTest(value: boolean | undefined): void {
|
|
40
|
+
procAvailableOverride = value;
|
|
41
|
+
procAvailable = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Feature-detect the Linux/POSIX `/proc` surface this module needs. Cached:
|
|
46
|
+
* the answer cannot change mid-process. Exported so the entry point can skip
|
|
47
|
+
* the whole scan with one log line on platforms without /proc.
|
|
48
|
+
*/
|
|
49
|
+
export function isProcAvailable(): boolean {
|
|
50
|
+
if (procAvailableOverride !== undefined) return procAvailableOverride;
|
|
51
|
+
if (procAvailable === undefined) {
|
|
52
|
+
try {
|
|
53
|
+
procAvailable = fs.existsSync(path.join(PROC_ROOT, "self", "stat"));
|
|
54
|
+
} catch {
|
|
55
|
+
procAvailable = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return procAvailable;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The outcome of a `/proc/<pid>/stat` read: the fields, or why it failed. */
|
|
62
|
+
type StatFieldsRead =
|
|
63
|
+
| { ok: true; fields: string[] }
|
|
64
|
+
| { ok: false; code: string | undefined };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `readStatFields` primitive WITH the failure cause preserved: `code` is the
|
|
68
|
+
* errno of a failed read (`"ENOENT"` when the pid is absent) or undefined when
|
|
69
|
+
* /proc is unavailable, the pid is invalid, or the read failed for a reason
|
|
70
|
+
* other than a missing file. `pidGone` needs the cause to separate "the pid was
|
|
71
|
+
* REAPED" (ENOENT — absence-of-process evidence) from "the read failed and we
|
|
72
|
+
* cannot tell" (everything else).
|
|
73
|
+
*/
|
|
74
|
+
function readStatFieldsDetailed(pid: number): StatFieldsRead {
|
|
75
|
+
if (!isProcAvailable() || !Number.isInteger(pid) || pid <= 0) {
|
|
76
|
+
return { ok: false, code: undefined };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.readFileSync(path.join(PROC_ROOT, String(pid), "stat"), "utf-8");
|
|
80
|
+
const close = stat.lastIndexOf(")");
|
|
81
|
+
if (close < 0) return { ok: false, code: undefined };
|
|
82
|
+
const rest = stat.slice(close + 1).trim();
|
|
83
|
+
if (!rest) return { ok: false, code: undefined };
|
|
84
|
+
return { ok: true, fields: rest.split(/\s+/) };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { ok: false, code: (err as NodeJS.ErrnoException).code };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The whitespace-split `/proc/<pid>/stat` fields AFTER the `(comm)` field, or
|
|
92
|
+
* undefined when /proc is unavailable, the pid is invalid, or the process
|
|
93
|
+
* vanished. Shared by the start-token, process-group and group-liveness readers.
|
|
94
|
+
* The comm field is skipped by slicing at the LAST `)` so a name containing
|
|
95
|
+
* spaces or parens cannot shift the parse.
|
|
96
|
+
*/
|
|
97
|
+
function readStatFields(pid: number): string[] | undefined {
|
|
98
|
+
const read = readStatFieldsDetailed(pid);
|
|
99
|
+
return read.ok ? read.fields : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a process's start-time token — `/proc/<pid>/stat` field 22 (1-indexed),
|
|
104
|
+
* i.e. index 19 of the whitespace-split fields AFTER the `(comm)` field.
|
|
105
|
+
* Returns undefined when /proc is unavailable, the pid is invalid, or the
|
|
106
|
+
* process vanished.
|
|
107
|
+
*/
|
|
108
|
+
export function readStartToken(pid: number): string | undefined {
|
|
109
|
+
const token = readStatFields(pid)?.[19];
|
|
110
|
+
return token && /^\d+$/.test(token) ? token : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a process's process-group id — `/proc/<pid>/stat` field 5 (index 2 after
|
|
115
|
+
* `(comm)`). Returns undefined when /proc is unavailable, the pid is invalid, or
|
|
116
|
+
* the process vanished.
|
|
117
|
+
*/
|
|
118
|
+
export function readProcessGroup(pid: number): number | undefined {
|
|
119
|
+
const pgid = Number(readStatFields(pid)?.[2]);
|
|
120
|
+
return Number.isInteger(pgid) && pgid > 0 ? pgid : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* #299 fix A.2 — the THREE-state answer to "does process group `pgid` still have
|
|
125
|
+
* a LIVE member?":
|
|
126
|
+
*
|
|
127
|
+
* - `true` — at least one live member was observed.
|
|
128
|
+
* - `false` — a COMPLETED inspection found no live member: POSITIVELY empty.
|
|
129
|
+
* - `undefined` — the inspection was UNAVAILABLE or PARTIAL. Absence of
|
|
130
|
+
* evidence is never evidence of absence, so callers must treat `undefined`
|
|
131
|
+
* as UNKNOWN — never as empty.
|
|
132
|
+
*
|
|
133
|
+
* `/proc` host-level unavailability is the documented platform boundary, not
|
|
134
|
+
* `undefined`: with no `/proc` at all the caller degrades to leader liveness
|
|
135
|
+
* (this returns `false`). `undefined` is reserved for an inspection that FAILED
|
|
136
|
+
* on a `/proc`-capable host — a `/proc` listing failure, or a member's stat read
|
|
137
|
+
* that failed for a reason other than the process being gone (`EACCES`, `EIO`, a
|
|
138
|
+
* parse anomaly). A member that genuinely vanished during the scan is skipped
|
|
139
|
+
* (ENOENT — it cannot be a live member).
|
|
140
|
+
*/
|
|
141
|
+
export type GroupMembership = boolean | undefined;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* #299 fix A.4 — the kernel's process-state letters, i.e. the valid values of
|
|
145
|
+
* field 3 of `/proc/<pid>/stat`. A state token OUTSIDE this set means the
|
|
146
|
+
* record was mis-parsed (a truncated or field-shifted read), so the inspection
|
|
147
|
+
* is PARTIAL rather than a positive non-membership. The set is the union of the
|
|
148
|
+
* Linux `task_state_array`: R (running), S (sleeping), D (uninterruptible
|
|
149
|
+
* sleep), Z (zombie), T (stopped), t (tracing stop), X/x (dead), K (wakekill),
|
|
150
|
+
* W (waking, pre-3.8), P (parked), I (idle).
|
|
151
|
+
*/
|
|
152
|
+
const PROCESS_STATE_LETTERS = new Set(["R", "S", "D", "Z", "T", "t", "X", "x", "K", "W", "P", "I"]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The kernel's `PID_MAX_LIMIT` on 64-bit Linux. A pgrp above this is not a real
|
|
156
|
+
* process-group id, so a record carrying one is mis-parsed — the inspection is
|
|
157
|
+
* PARTIAL, not a positive non-membership. `pid_max` can be configured lower; a
|
|
158
|
+
* value above the kernel CEILING can never be genuine at all.
|
|
159
|
+
*/
|
|
160
|
+
const PID_MAX_LIMIT = 4_194_304;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* #299 fix A.5 — the SINGLE source of the `/proc/<pid>/stat` PREFIX contract,
|
|
164
|
+
* shared by every group-membership read. Given the whitespace-split fields after
|
|
165
|
+
* `(comm)`, it returns `{ state, pgrp }` ONLY when the record is WELL-FORMED
|
|
166
|
+
* enough to trust, and `undefined` (malformed/truncated/shifted) otherwise.
|
|
167
|
+
*
|
|
168
|
+
* The contract is deliberately strict, because `groupHasMembers` turns a clean
|
|
169
|
+
* scan into the actionable claim "the group is POSITIVELY empty" (an escalation
|
|
170
|
+
* early-exit and a clean survivor report). Every field the prefix relies on is
|
|
171
|
+
* therefore validated by SHAPE and ALIGNMENT:
|
|
172
|
+
*
|
|
173
|
+
* - `fields[0]` (state) must be a kernel process-state letter — numerics,
|
|
174
|
+
* punctuation, empty and multi-char tokens are all malformed.
|
|
175
|
+
* - `fields[1]` (ppid) must be a non-negative integer — this is the ALIGNMENT
|
|
176
|
+
* check. A valid state followed by a non-numeric ppid (`R S 1 9999`) is a
|
|
177
|
+
* SHIFTED record whose "pgrp" slot happens to hold a real integer; trusting
|
|
178
|
+
* it would read a malformed record as a justified non-member.
|
|
179
|
+
* - `fields[2]` (pgrp) must be an integer in `[0, PID_MAX_LIMIT]` — negatives
|
|
180
|
+
* and oversized values are impossible pgrps. `0` is LEGAL: kernel threads
|
|
181
|
+
* carry pgrp 0 and must not taint an otherwise-clean scan.
|
|
182
|
+
*/
|
|
183
|
+
function parseStatPrefix(fields: string[]): { state: string; pgrp: number } | undefined {
|
|
184
|
+
const state = fields[0];
|
|
185
|
+
const ppid = fields[1];
|
|
186
|
+
const pgrpField = fields[2];
|
|
187
|
+
if (state === undefined || !PROCESS_STATE_LETTERS.has(state)) return undefined;
|
|
188
|
+
// Strict decimal shape: `Number("1e3")`/`Number("0x10")`/`Number("")` would
|
|
189
|
+
// otherwise sneak through as integers, so validate the TOKEN, not the cast.
|
|
190
|
+
if (ppid === undefined || !/^\d+$/.test(ppid)) return undefined;
|
|
191
|
+
if (pgrpField === undefined || !/^\d+$/.test(pgrpField)) return undefined;
|
|
192
|
+
const pgrp = Number(pgrpField);
|
|
193
|
+
if (pgrp > PID_MAX_LIMIT) return undefined;
|
|
194
|
+
return { state, pgrp };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The states whose processes are NOT live group members (an unreaped corpse
|
|
198
|
+
* cannot hold a group non-empty): `Z` (zombie), `X`/`x` (dead). Only ever
|
|
199
|
+
* consulted for a record that `parseStatPrefix` already accepted. */
|
|
200
|
+
const NON_MEMBER_STATES = new Set(["Z", "X", "x"]);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Scans `/proc` for processes whose pgrp (field 5) equals `pgid`, skipping the
|
|
204
|
+
* leader itself and non-live states (`Z`/`X`/`x` — an unreaped corpse is not a
|
|
205
|
+
* live member and must not hold the group non-empty). A group's leader exiting
|
|
206
|
+
* does NOT empty the group, so this is what lets `escalateKill` keep waiting for
|
|
207
|
+
* a member's cleanup window.
|
|
208
|
+
*
|
|
209
|
+
* The return contract is EXACT (see `GroupMembership`): `false` may be returned
|
|
210
|
+
* ONLY after a COMPLETE scan in which every record parsed as a well-formed
|
|
211
|
+
* prefix. Any unparseable record taints the scan to `undefined` (unknown) — the
|
|
212
|
+
* check lives in `parseStatPrefix`, so there is no fall-through that can turn
|
|
213
|
+
* malformed input into a positively-empty `false`.
|
|
214
|
+
*/
|
|
215
|
+
export function groupHasMembers(pgid: number): GroupMembership {
|
|
216
|
+
// Platform boundary: no /proc at all → the caller falls back to leader
|
|
217
|
+
// liveness (documented non-Linux boundary). Same for an invalid pgid.
|
|
218
|
+
if (!Number.isInteger(pgid) || pgid <= 0 || !isProcAvailable()) return false;
|
|
219
|
+
let entries: string[];
|
|
220
|
+
try {
|
|
221
|
+
entries = fs.readdirSync(PROC_ROOT);
|
|
222
|
+
} catch {
|
|
223
|
+
// A listing failure on a /proc-capable host leaves the scan PARTIAL: we
|
|
224
|
+
// cannot tell whether a member exists, so the answer is unknown.
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
229
|
+
const pid = Number(entry);
|
|
230
|
+
if (pid === pgid) continue; // the leader (possibly a zombie) is not a member
|
|
231
|
+
const read = readStatFieldsDetailed(pid);
|
|
232
|
+
if (!read.ok) {
|
|
233
|
+
// A member gone mid-scan is skippable (it cannot be a live member); any
|
|
234
|
+
// OTHER failure makes the inspection partial → unknown.
|
|
235
|
+
if (read.code === "ENOENT") continue;
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
// #299 fix A.5: ONE strict prefix validation. A truncated, shifted or
|
|
239
|
+
// otherwise malformed record (too few fields, non-letter state, non-numeric
|
|
240
|
+
// ppid, impossible pgrp) makes the inspection PARTIAL → unknown, never a
|
|
241
|
+
// positively-empty `false`. Well-formed dead states are skipped; a live
|
|
242
|
+
// member is a match.
|
|
243
|
+
const parsed = parseStatPrefix(read.fields);
|
|
244
|
+
if (parsed === undefined) return undefined;
|
|
245
|
+
if (NON_MEMBER_STATES.has(parsed.state)) continue;
|
|
246
|
+
if (parsed.pgrp === pgid) return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* #299 review Major 1 — signal-time identity re-check. True only when the LIVE
|
|
253
|
+
* process `pid` carries `CHILD_MARKER_ENV_KEY=<marker>` in its environment.
|
|
254
|
+
* `findByMarker` proves the pid was ours at SCAN time; this proves it is ours at
|
|
255
|
+
* the SIGNAL time, closing the scan→signal pid-reuse window (a pid that exited
|
|
256
|
+
* and was reused by a foreign process no longer carries the marker). Returns
|
|
257
|
+
* false when /proc is unavailable, the pid is invalid, or the process vanished.
|
|
258
|
+
*
|
|
259
|
+
* RESIDUAL: this narrows the reuse window to the read→`kill` syscall boundary —
|
|
260
|
+
* the same exposure the direct signals have carried since Option A. It does NOT
|
|
261
|
+
* eliminate it (the kernel may reuse the pid between this read and the signal).
|
|
262
|
+
*/
|
|
263
|
+
export function pidHasMarker(pid: number, marker: string): boolean {
|
|
264
|
+
if (!marker || !isProcAvailable() || !Number.isInteger(pid) || pid <= 0) return false;
|
|
265
|
+
const needle = `${CHILD_MARKER_ENV_KEY}=${marker}`;
|
|
266
|
+
try {
|
|
267
|
+
const environ = fs.readFileSync(path.join(PROC_ROOT, String(pid), "environ"), "utf-8");
|
|
268
|
+
return environ.split("\0").includes(needle);
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The CURRENT process's owner identity (pid + start token; empty string when unavailable). */
|
|
275
|
+
export function currentProcessOwner(): ProcessOwner {
|
|
276
|
+
return { pid: process.pid, start: readStartToken(process.pid) ?? "" };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* #299 D4: the ONE production process-group signal (`kill(-pgid, sig)`), shared
|
|
281
|
+
* by the recovery deps and `runner.ts`'s child/marker group targets. ESRCH means
|
|
282
|
+
* the group is already empty — the desired end state, so it is swallowed; every
|
|
283
|
+
* other errno is rethrown for the caller's own tolerance. On a platform without
|
|
284
|
+
* process groups neither caller wires this in (`supportsProcessGroupKill`).
|
|
285
|
+
*/
|
|
286
|
+
export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
287
|
+
try {
|
|
288
|
+
process.kill(-pgid, signal);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Cross-platform pid liveness (`kill -0`). EPERM means the process exists. */
|
|
295
|
+
export function pidAlive(pid: number): boolean {
|
|
296
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
297
|
+
try {
|
|
298
|
+
process.kill(pid, 0);
|
|
299
|
+
return true;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* #299 review Major 1: true when the pid is GONE — it no longer exists OR is a
|
|
307
|
+
* not-yet-reaped zombie (`Z`/`X`), which `pidAlive` (kill -0) still reports as
|
|
308
|
+
* alive. The signal-time guard uses this to separate a REUSED pid (alive, marker
|
|
309
|
+
* gone) from a merely dead/zombie one, so a zombie leader's pgid is still
|
|
310
|
+
* signaled to reach its surviving members (D5). Without /proc the zombie
|
|
311
|
+
* distinction is unavailable and this degrades to `!pidAlive`.
|
|
312
|
+
*
|
|
313
|
+
* #299 re-review A.1: a POSITIVE liveness sample immediately followed by ENOENT
|
|
314
|
+
* on `/proc/<pid>/stat` means the pid was REAPED between the two reads — it is
|
|
315
|
+
* GONE. `/proc/<pid>/stat` is readable for any process that still exists (a
|
|
316
|
+
* not-yet-reaped zombie included), so ENOENT is absence-of-process evidence;
|
|
317
|
+
* classifying it as "not gone" made the guard treat our own reaped leader as a
|
|
318
|
+
* live FOREIGN pid and refuse BOTH the group SIGTERM and the final group
|
|
319
|
+
* SIGKILL while its group still had live members. Every OTHER read failure
|
|
320
|
+
* (EACCES, a parse anomaly, /proc unavailable) stays conservatively not-gone.
|
|
321
|
+
*
|
|
322
|
+
* PLATFORM BOUNDARY (hidepid=2): on a `/proc` mounted with `hidepid=2` a foreign
|
|
323
|
+
* LIVE pid we do not own can read `kill -0` EPERM (alive) and then
|
|
324
|
+
* `/proc/<pid>/stat` ENOENT (hidden — it looks GONE). The signal guard may then
|
|
325
|
+
* ATTEMPT a group signal despite the marker mismatch. This is a documented
|
|
326
|
+
* platform boundary, NOT a signal-safety guarantee: the kernel's EPERM on the
|
|
327
|
+
* signal constrains the unprivileged blast radius, and the group signal still
|
|
328
|
+
* targets only the pgid we derived (it does not widen the target set).
|
|
329
|
+
*/
|
|
330
|
+
export function pidGone(pid: number): boolean {
|
|
331
|
+
if (!pidAlive(pid)) return true;
|
|
332
|
+
const read = readStatFieldsDetailed(pid);
|
|
333
|
+
if (!read.ok) return read.code === "ENOENT";
|
|
334
|
+
const state = read.fields[0];
|
|
335
|
+
return state === "Z" || state === "X";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pids whose environment carries the marker. Scans `/proc/<pid>/environ` (NUL
|
|
340
|
+
* separated). Skips this process; unreadable/vanished processes are ignored.
|
|
341
|
+
* Returns [] — never a kill — when /proc is unavailable.
|
|
342
|
+
*/
|
|
343
|
+
export function findByMarker(marker: string): number[] {
|
|
344
|
+
if (!marker || !isProcAvailable()) return [];
|
|
345
|
+
const found: number[] = [];
|
|
346
|
+
let entries: string[];
|
|
347
|
+
try {
|
|
348
|
+
entries = fs.readdirSync(PROC_ROOT);
|
|
349
|
+
} catch {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
354
|
+
const pid = Number(entry);
|
|
355
|
+
if (pid === process.pid) continue;
|
|
356
|
+
if (pidHasMarker(pid, marker)) found.push(pid);
|
|
357
|
+
}
|
|
358
|
+
return found;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The real production dependencies (Linux/POSIX /proc reads + process signals). */
|
|
362
|
+
export function defaultRecoveryDeps(): RecoveryDeps {
|
|
363
|
+
return {
|
|
364
|
+
pidAlive,
|
|
365
|
+
startTokenOf: readStartToken,
|
|
366
|
+
findByMarker,
|
|
367
|
+
kill: (pid, signal) => {
|
|
368
|
+
process.kill(pid, signal);
|
|
369
|
+
},
|
|
370
|
+
// D4 (#299): group-reaching targets on POSIX. The marker identifies the
|
|
371
|
+
// orphan tree; the process group is only the kill mechanism that reaches
|
|
372
|
+
// env-scrubbed descendants. Omitted on Windows (D6) so `reapPids` keeps
|
|
373
|
+
// today's direct pid targets.
|
|
374
|
+
killGroup: supportsProcessGroupKill() ? killProcessGroup : undefined,
|
|
375
|
+
// #299 review Major 2: a group's leader exiting does not empty the group,
|
|
376
|
+
// so the group target's death check counts live members.
|
|
377
|
+
groupHasMembers,
|
|
378
|
+
// #299 review Major 1: signal-time marker re-check for marker-derived
|
|
379
|
+
// targets (closes the scan→signal pid-reuse window).
|
|
380
|
+
verifyMarker: pidHasMarker,
|
|
381
|
+
// #299 review Major 1: the guard separates a reused pid (alive, marker
|
|
382
|
+
// gone) from a dead/zombie one, so a zombie leader's group is still killed.
|
|
383
|
+
pidGone,
|
|
384
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
385
|
+
graceMs: SIGKILL_GRACE_MS,
|
|
386
|
+
// Item 4 (#299): poll so the boot scan returns as soon as its orphans die
|
|
387
|
+
// instead of always blocking the full grace window (a ~5 s session_start).
|
|
388
|
+
pollMs: SIGKILL_POLL_MS,
|
|
389
|
+
};
|
|
390
|
+
}
|