pi-crew 0.11.38 → 0.11.40

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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * reap-group.ts — fix-wave 11a (F5): THE single place that may signal a POSIX
3
+ * process GROUP, with an identity-proven refusal ladder and a bounded
4
+ * escalation ladder.
5
+ *
6
+ * Why this module exists: every pre-wave reap path sent ONE signal to ONE pid
7
+ * (`orphan-reaper.ts`, `child-pi-kill.ts`) or blindly group-killed a stored
8
+ * pgid (`owned-process.ts` distrusts nothing). Both shapes fail the incident
9
+ * that motivated wave 11 (run `team_20261022110510_cefb9164af126460` completed
10
+ * but 6 grandchildren survived 24 h): a pid-only SIGTERM never reaches
11
+ * descendants, while a raw `kill(-pgid)` can hit an UNRELATED group after PID
12
+ * reuse. The guard ladder below is what makes a group kill defensible:
13
+ *
14
+ * 1. POSIX only, and /proc must be readable (the identity source).
15
+ * 2. `pgid` must be an integer > 1 — NEVER `kill(-1)` / `kill(0)`, which
16
+ * would signal every process we are allowed to signal, and never 1 (init's
17
+ * group).
18
+ * 3. never our own group (its REAL pgrp, read once from /proc — see
19
+ * `selfIdentity()`), never our pid, never our parent's pid.
20
+ * 4. the candidate must have a live identity, OR (candidate already gone) a
21
+ * caller-supplied snapshot (`expected`) that PROVES the group leader —
22
+ * `expected.pid === expected.pgid === pgid` + a starttime — because a
23
+ * dead candidate cannot prove anything by itself.
24
+ * 5. same uid as us (we can only reason about our own processes).
25
+ * 6. the candidate's REAL pgid must equal the requested pgid (PID-reuse).
26
+ * 7. the identity must carry a starttime (the reuse-proof field).
27
+ * 8. if a snapshot exists, its starttime must match the live one.
28
+ *
29
+ * Wave 11a REVIEW FIXES (round 1) baked into the ladder:
30
+ * - CRITICAL #1 `selfIdentity()`: comparing `pgid` with {pid, ppid} misses a
31
+ * host started under `setsid`/a wrapper, whose pgrp is neither ⇒ the old
32
+ * rung PASSED and `kill(-ownPgrp)` took out the host session.
33
+ * - MAJOR #2 dead-candidate path: `identity := expected` made rungs 4/6/7/8
34
+ * tautologies over the snapshot itself (blind `kill(-pgid)`). The snapshot
35
+ * is now only usable as LEADER PROOF, and the kernel's "a pgid is not
36
+ * recycled while the group still has members" property is what makes
37
+ * "leader gone but group alive" safe.
38
+ *
39
+ * Escalation: SIGTERM is delivered SYNCHRONOUSLY (postmortem-safe — a hard exit
40
+ * right after the call still leaves the graceful signal sent), then a bounded
41
+ * poll → re-validate → SIGKILL → bounded poll. The poll timers are unref'd so a
42
+ * reaper never pins the event loop — which is exactly why the SIGKILL rung needs
43
+ * explicit owners (see the "deferred SIGKILL deliveries" block below):
44
+ * `drainReapEscalations()` awaits the armed promises under a REF'D keep-alive,
45
+ * and `flushReapEscalationsSync()` delivers the last rung synchronously on
46
+ * session shutdown. Nothing here throws: every failure is a typed refusal or
47
+ * outcome.
48
+ *
49
+ * Scope note (wave 11a): a worker's bash grandchildren live in PI's bash-tool
50
+ * group, not the worker's group (GAP-0) — this module cannot reach them. That
51
+ * is the run-scoped descendant sweep (F7) in wave 11b.
52
+ */
53
+
54
+ import * as fs from "node:fs";
55
+ import { getCrewEnvBool } from "../../config/env-vars.ts";
56
+ import { logInternalError } from "../../utils/internal-error.ts";
57
+ import { fieldsAfterComm, PROC_STAT_PGID_INDEX, PROC_STAT_STARTTIME_INDEX } from "./proc-stat.ts";
58
+
59
+ /** Default grace window between SIGTERM and SIGKILL (ms). */
60
+ const DEFAULT_GRACEFUL_MS = 2_000;
61
+ /** Default hard cap after SIGKILL before declaring the group stuck (ms). */
62
+ const DEFAULT_CAP_MS = 2_000;
63
+ /** Poll interval for group-liveness (ms). */
64
+ const POLL_INTERVAL_MS = 20;
65
+
66
+ /**
67
+ * Kill switch (host-process only, default ON). `PI_CREW_REAP_GROUP=0` degrades
68
+ * every group-kill caller to its pre-wave pid-only behaviour, without a
69
+ * redeploy — the escape hatch if a platform turns out to mis-report pgids.
70
+ */
71
+ export function resolveReapGroupEnabled(): boolean {
72
+ return getCrewEnvBool("PI_CREW_REAP_GROUP") ?? true;
73
+ }
74
+
75
+ /** A process identity read from /proc — the reuse-proof triple plus pid/pgid. */
76
+ export interface ProcIdentity {
77
+ pid: number;
78
+ pgid: number | undefined;
79
+ starttime: string | undefined;
80
+ uid: number | undefined;
81
+ }
82
+
83
+ /** Every way we refuse to signal a group. Each maps to one ladder rung. */
84
+ export type GroupRefusalReason =
85
+ | "platform-not-posix"
86
+ | "proc-unavailable"
87
+ | "pgid-invalid"
88
+ | "pgid-init-or-self"
89
+ | "uid-mismatch"
90
+ | "identity-missing"
91
+ | "starttime-mismatch"
92
+ | "not-group-member";
93
+
94
+ /** Terminal verdict for a group reap attempt. */
95
+ export type GroupReapOutcome = "gone" | "killed-after-escalation" | "stuck" | "refused";
96
+
97
+ /** Injectable dependencies (every side effect is replaceable in tests). */
98
+ export interface ReapGroupOptions {
99
+ /** Grace window between SIGTERM and SIGKILL. Default 2000 ms. */
100
+ gracefulMs?: number;
101
+ /** Hard cap after SIGKILL before giving up. Default 2000 ms. */
102
+ capMs?: number;
103
+ /** Platform override (tests simulate win32). Default `process.platform`. */
104
+ platform?: NodeJS.Platform;
105
+ /** /proc identity reader (tests inject fixtures). Default `readProcIdentity`. */
106
+ readIdentity?: (pid: number) => ProcIdentity | undefined;
107
+ /** Group signal sender. Default `process.kill(-pgid, signal)`. */
108
+ signalGroup?: (pgid: number, signal: NodeJS.Signals) => void;
109
+ /** Group liveness probe. Default `process.kill(-pgid, 0)`. */
110
+ groupAlive?: (pgid: number) => boolean;
111
+ /** Snapshot recorded at spawn time — required when the candidate is gone. */
112
+ expected?: ProcIdentity;
113
+ /** Diagnostic label used in log messages. */
114
+ name?: string;
115
+ /**
116
+ * uid source for the same-uid rungs (5 and the dead-path uid proof). Default
117
+ * `() => process.getuid?.()` — production behaviour is unchanged. The seam
118
+ * exists because `process.getuid` is undefined on win32, which made the uid
119
+ * rungs untestable there without a process-wide monkeypatch.
120
+ *
121
+ * Read PER CALL and never cached: unlike `selfIdentity()` (which is cached
122
+ * because a process cannot change its group), this is an injected value with
123
+ * no invariant of its own, and caching it would make the first caller win for
124
+ * the lifetime of the module.
125
+ */
126
+ getuid?: () => number | undefined;
127
+ }
128
+
129
+ /** Sync verdict + the authoritative async verdict. */
130
+ export interface GroupReapResult {
131
+ /**
132
+ * Provisional sync verdict: `refused` (nothing signalled), `gone` (SIGTERM
133
+ * already cleared the group) or `killed-after-escalation` (escalation
134
+ * armed — `pending` is authoritative).
135
+ */
136
+ outcome: GroupReapOutcome;
137
+ /** Set when the guard ladder refused. */
138
+ refusal?: GroupRefusalReason;
139
+ /** Authoritative verdict; already resolved when no escalation was needed. */
140
+ pending: Promise<GroupReapOutcome>;
141
+ }
142
+
143
+ /** `/proc/<pid>/status` first `Uid:` field (real uid). Never throws. */
144
+ function readUidFromStatus(pid: number): number | undefined {
145
+ try {
146
+ const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
147
+ const match = /^Uid:\s+(\d+)/m.exec(status);
148
+ if (!match) return undefined;
149
+ const uid = Number(match[1]);
150
+ return Number.isInteger(uid) ? uid : undefined;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Read pid/pgid/starttime/uid from /proc. Reuses the paren-aware stat parser in
158
+ * proc-stat.ts (comm may contain spaces AND parens — a naive split corrupts
159
+ * every field after it). Returns undefined for anything unreadable: /proc is
160
+ * absent (non-Linux), the pid is gone, or the line is malformed.
161
+ */
162
+ export function readProcIdentity(pid: number): ProcIdentity | undefined {
163
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
164
+ let stat: string;
165
+ try {
166
+ stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ const fields = fieldsAfterComm(stat);
171
+ if (!fields) return undefined;
172
+ const rawPgid = Number(fields[PROC_STAT_PGID_INDEX]);
173
+ const pgid = Number.isInteger(rawPgid) && rawPgid > 0 ? rawPgid : undefined;
174
+ const rawStarttime = fields[PROC_STAT_STARTTIME_INDEX];
175
+ const starttime = rawStarttime && Number.isFinite(Number(rawStarttime)) ? rawStarttime : undefined;
176
+ return { pid, pgid, starttime, uid: readUidFromStatus(pid) };
177
+ }
178
+
179
+ /**
180
+ * OUR OWN identity as read from /proc — the authoritative answer to "which
181
+ * process group am I in?". Read ONCE per process and cached (a POSIX process
182
+ * cannot change its group after start), so the ladder never re-reads /proc for
183
+ * a value that cannot move. A FAILED read is NOT cached (MINOR #3): /proc can be
184
+ * momentarily unreadable (mount race, sandbox start-up) and caching that would
185
+ * turn a transient failure into a permanent refuse-everything state.
186
+ *
187
+ * Why not {process.pid, process.ppid} (the pre-review rung 3): a host started
188
+ * under `setsid`, a tmux/systemd/supervisor wrapper, or any launcher that puts
189
+ * it in a fresh group has a pgrp that is NEITHER its pid NOR its parent's pid
190
+ * — the old comparison PASSED for that group, and `kill(-pgrp)` then killed the
191
+ * whole host session. Review finding CRITICAL #1 (wave 11a).
192
+ */
193
+ let selfIdentityCache: ProcIdentity | undefined;
194
+ let selfIdentityLoaded = false;
195
+ /** Injected ONLY by tests (the ladder never accepts a per-call reader for self). */
196
+ let selfIdentityReader: (pid: number) => ProcIdentity | undefined = readProcIdentity;
197
+
198
+ /**
199
+ * Lazily read + cache our own /proc identity (undefined ⇒ unreadable RIGHT NOW;
200
+ * the next call retries). Only a successful read is cached (MINOR #3).
201
+ */
202
+ function selfIdentity(): ProcIdentity | undefined {
203
+ if (selfIdentityLoaded) return selfIdentityCache;
204
+ const identity = selfIdentityReader(process.pid);
205
+ if (!identity) {
206
+ logInternalError(
207
+ "reap-group.self-identity",
208
+ new Error("cannot read our own /proc identity — refusing group signals, will retry"),
209
+ undefined,
210
+ "debug",
211
+ );
212
+ return undefined;
213
+ }
214
+ selfIdentityCache = identity;
215
+ selfIdentityLoaded = true;
216
+ return identity;
217
+ }
218
+
219
+ /** Test seam: forget the cached self identity so the next call re-reads /proc. */
220
+ export function __resetSelfIdentityForTest(): void {
221
+ selfIdentityCache = undefined;
222
+ selfIdentityLoaded = false;
223
+ selfIdentityReader = readProcIdentity;
224
+ }
225
+
226
+ /** Test seam: install a synthetic self identity (undefined ⇒ "cannot read"). */
227
+ export function __setSelfIdentityForTest(identity: ProcIdentity | undefined): void {
228
+ selfIdentityCache = identity;
229
+ selfIdentityLoaded = true;
230
+ }
231
+
232
+ /**
233
+ * Test seam: swap the /proc reader used for OUR OWN identity. Needed to pin the
234
+ * MINOR #3 retry rule (a FAILED self read must not be cached) without depending
235
+ * on a real /proc — the per-call `opts.readIdentity` deliberately cannot do it.
236
+ */
237
+ export function __setSelfIdentityReaderForTest(reader: ((pid: number) => ProcIdentity | undefined) | undefined): void {
238
+ selfIdentityReader = reader ?? readProcIdentity;
239
+ }
240
+
241
+ /** Default uid source for the same-uid rungs: the real process uid, when the
242
+ * runtime has one (POSIX). win32 has none ⇒ `undefined` ⇒ the rungs degrade
243
+ * exactly as they did before the seam existed. */
244
+ const defaultGetuid = (): number | undefined => process.getuid?.();
245
+
246
+ /**
247
+ * Run the refusal ladder. Returns the FIRST failing rung, or undefined when the
248
+ * group is safe to signal. Pure with respect to injected `readIdentity` /
249
+ * `groupAlive`; the self identity is cached module state (seam above).
250
+ */
251
+ export function assertSafeGroup(pgid: number, candidatePid: number, opts: ReapGroupOptions = {}): GroupRefusalReason | undefined {
252
+ const platform = opts.platform ?? process.platform;
253
+ const readIdentity = opts.readIdentity ?? readProcIdentity;
254
+ const groupAlive = opts.groupAlive ?? defaultGroupAlive;
255
+ // Rung 1: our own /proc identity must be readable — it is BOTH the /proc
256
+ // availability probe AND the rung-3 comparison. MINOR #4 (review): this is
257
+ // the cached SELF read, never the injected `readIdentity`: the injection point
258
+ // exists for the CANDIDATE, and letting a test hook vouch for our own identity
259
+ // is precisely the shape of CRITICAL #1.
260
+ if (platform === "win32") return "platform-not-posix";
261
+ const self = selfIdentity();
262
+ if (!self) return "proc-unavailable";
263
+ // Rung 2: a non-positive pgid would signal far too much (kill(-1)/kill(0));
264
+ // pgid 1 is init's group — reaping it is never a worker cleanup.
265
+ if (!Number.isInteger(pgid) || pgid <= 1) return "pgid-invalid";
266
+ // Rung 3: never our own group (its REAL pgrp — CRITICAL #1), never our pid,
267
+ // never our parent's pid. Unknown self pgrp ⇒ refuse: that is exactly the
268
+ // state in which `kill(-pgid)` could take out the host session.
269
+ if (self.pgid === undefined) return "identity-missing";
270
+ if (pgid === process.pid || pgid === process.ppid || pgid === self.pgid) return "pgid-init-or-self";
271
+ const selfUid = (opts.getuid ?? defaultGetuid)();
272
+ // Rung 4: a LIVE candidate identity, or (dead candidate) leader-proof.
273
+ const live = readIdentity(candidatePid);
274
+ if (!live) {
275
+ // The candidate is gone, so nothing about IT can be verified any more. A
276
+ // snapshot is only evidence when it PROVES the group leader: the candidate
277
+ // we were asked to signal must BE the leader (`candidatePid === pgid`, and
278
+ // the snapshot must agree on both pid and pgid — MINOR #1) and must carry
279
+ // the starttime. Without that, every later rung would compare the snapshot
280
+ // with itself ⇒ tautology ⇒ blind kill(-pgid) after PID reuse (MAJOR #2).
281
+ const expected = opts.expected;
282
+ if (!expected || candidatePid !== pgid || expected.pid !== pgid || expected.pgid !== pgid || expected.starttime === undefined) {
283
+ return "identity-missing";
284
+ }
285
+ // Rung 5 (dead path): the uid proof is MANDATORY here (MINOR #2). The live
286
+ // /proc path always carries one, so an absent uid means we cannot separate
287
+ // our own process from a stranger's: signalling then fails with EPERM AFTER
288
+ // the caller already reported `reaped`, which leaves a false `stuck`.
289
+ if (expected.uid === undefined) return "identity-missing";
290
+ if (selfUid !== undefined && expected.uid !== selfUid) return "uid-mismatch";
291
+ // The kernel does NOT recycle a pgid while the group still has a member,
292
+ // so the leader is the only pid whose starttime the snapshot pins. If the
293
+ // leader is still there it must be the SAME incarnation AND still lead that
294
+ // group (MINOR #1: a pid that left the group via setpgid is no evidence).
295
+ const leader = readIdentity(pgid);
296
+ if (leader) {
297
+ if (leader.pgid !== pgid) return "not-group-member";
298
+ if (leader.starttime === undefined) return "identity-missing";
299
+ if (leader.starttime !== expected.starttime) return "starttime-mismatch";
300
+ if (leader.uid !== undefined && selfUid !== undefined && leader.uid !== selfUid) return "uid-mismatch";
301
+ return undefined;
302
+ }
303
+ // Leader gone: a still-live group means surviving members hold the pgid
304
+ // (so it cannot have been recycled) ⇒ our old group. An empty group means
305
+ // there is nothing to justify the kill and nothing to kill.
306
+ if (groupAlive(pgid)) {
307
+ // TOCTOU, documented on purpose (MINOR #7): between this probe and the
308
+ // caller's `signalGroup` the LAST member may exit and the pgid be recycled
309
+ // by an unrelated group. That window cannot be closed without a pidfd-style
310
+ // handle (one signal per pid, no group target). It is accepted because the
311
+ // alternative — re-introducing the snapshot as identity — made the very
312
+ // rungs above tautological (MAJOR #2).
313
+ return undefined;
314
+ }
315
+ return "identity-missing";
316
+ }
317
+ // Rung 5: same uid (a missing uid only happens for injected snapshots; the
318
+ // live /proc path always carries one — the remaining rungs still hold).
319
+ if (live.uid !== undefined && selfUid !== undefined && live.uid !== selfUid) return "uid-mismatch";
320
+ // Rung 6: PID reuse — the candidate must actually belong to that group.
321
+ if (live.pgid !== pgid) return "not-group-member";
322
+ // Rung 7: starttime is the field that survives PID reuse.
323
+ if (live.starttime === undefined) return "identity-missing";
324
+ // Rung 8: the snapshot and the live process must be the SAME incarnation.
325
+ const expectedStart = opts.expected?.starttime;
326
+ if (expectedStart !== undefined && live.starttime !== expectedStart) return "starttime-mismatch";
327
+ return undefined;
328
+ }
329
+
330
+ /** Unref'd sleep so an in-flight reap never pins the event loop. */
331
+ const delay = (ms: number): Promise<void> =>
332
+ new Promise((resolve) => {
333
+ const timer = setTimeout(resolve, Math.max(0, ms));
334
+ timer.unref?.();
335
+ });
336
+
337
+ /** Poll `predicate` until true or the deadline passes; returns the final value. */
338
+ async function pollUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
339
+ const deadline = Date.now() + Math.max(0, timeoutMs);
340
+ while (Date.now() < deadline) {
341
+ await delay(Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
342
+ if (predicate()) return true;
343
+ }
344
+ return predicate();
345
+ }
346
+
347
+ /** Default group liveness: `kill(-pgid, 0)`; EPERM means "alive, not ours". */
348
+ function defaultGroupAlive(pgid: number): boolean {
349
+ if (!Number.isInteger(pgid) || pgid <= 1) return false;
350
+ try {
351
+ process.kill(-pgid, 0);
352
+ return true;
353
+ } catch (err) {
354
+ return (err as NodeJS.ErrnoException).code === "EPERM";
355
+ }
356
+ }
357
+
358
+ /** Default group signal. Defensively refuses non-positive pgids (kill(-1)/kill(0)). */
359
+ function defaultSignalGroup(pgid: number, signal: NodeJS.Signals): void {
360
+ if (!Number.isInteger(pgid) || pgid <= 1) return;
361
+ try {
362
+ process.kill(-pgid, signal);
363
+ } catch {
364
+ /* group already gone — nothing to deliver */
365
+ }
366
+ }
367
+
368
+ /** Log a refusal/abnormal outcome at debug (never above: refusals are expected). */
369
+ function logDebug(scope: string, message: string, opts: ReapGroupOptions): void {
370
+ logInternalError(scope, new Error(message), `name=${opts.name ?? "-"}`, "debug");
371
+ }
372
+
373
+ // ── deferred SIGKILL deliveries (wave 11a review, MAJOR #3) ───────────────────
374
+ //
375
+ // `reapGroup` hands the SIGKILL rung back as a promise, and every production
376
+ // caller is SYNCHRONOUS (the stale-run reconciler, `killProcessTree`): they can
377
+ // at most register the promise with a drain owner. The escalation's own poll
378
+ // timers are unref'd (they must not keep a finished run alive), which means an
379
+ // un-awaited escalation is abandoned the moment the host's loop drains — the
380
+ // SIGKILL is silently never delivered (measured: an unsettled top-level await
381
+ // over unref'd timers exits 13).
382
+ //
383
+ // Two owners close that hole:
384
+ // 1. `drainReapEscalations()` (stale-reconciler.ts) awaits the registered
385
+ // promises while holding a REF'D keep-alive interval.
386
+ // 2. `flushReapEscalationsSync()` (below) is for session shutdown: it
387
+ // re-validates and delivers the SIGKILL rung synchronously, so the last
388
+ // rung still happens in a process that is about to exit.
389
+ /** One armed escalation: everything needed to re-validate and signal it later. */
390
+ interface ArmedGroupEscalation {
391
+ pgid: number;
392
+ candidatePid: number;
393
+ opts: ReapGroupOptions;
394
+ /** Set once {@link flushReapEscalationsSync} delivered the SIGKILL — the async
395
+ * escalation must then never signal a second time. */
396
+ deliveredSync?: boolean;
397
+ }
398
+
399
+ const armedEscalations = new Set<ArmedGroupEscalation>();
400
+
401
+ /**
402
+ * Deliver the SIGKILL rung of every ARMED escalation SYNCHRONOUSLY — the
403
+ * session-shutdown path. Each entry is re-validated against the ladder at THIS
404
+ * instant (the group may have drained, or the pgid been recycled since the
405
+ * SIGTERM), so a stale entry never becomes a blind kill. Best-effort per entry:
406
+ * one failure cannot stop the rest. The registry is cleared either way. Returns
407
+ * the number of groups signalled. Never throws.
408
+ */
409
+ export function flushReapEscalationsSync(): number {
410
+ const entries = [...armedEscalations];
411
+ armedEscalations.clear();
412
+ let delivered = 0;
413
+ for (const entry of entries) {
414
+ try {
415
+ const refusal = assertSafeGroup(entry.pgid, entry.candidatePid, entry.opts);
416
+ if (refusal) {
417
+ logDebug("reap-group.flush-refused", `no deferred SIGKILL for pgid=${entry.pgid} (${refusal})`, entry.opts);
418
+ continue;
419
+ }
420
+ (entry.opts.signalGroup ?? defaultSignalGroup)(entry.pgid, "SIGKILL");
421
+ entry.deliveredSync = true;
422
+ delivered++;
423
+ } catch (err) {
424
+ logInternalError(
425
+ "reap-group.flush-error",
426
+ err instanceof Error ? err : new Error(String(err)),
427
+ `pgid=${entry.pgid} name=${entry.opts.name ?? "-"}`,
428
+ "warn",
429
+ );
430
+ }
431
+ }
432
+ if (delivered > 0) logDebug("reap-group.flush", `delivered ${delivered} deferred group SIGKILL(s) at shutdown`, {});
433
+ return delivered;
434
+ }
435
+
436
+ /** Test seam: how many escalations are still armed (registry must empty on flush). */
437
+ export function __armedEscalationCountForTest(): number {
438
+ return armedEscalations.size;
439
+ }
440
+
441
+ /** Test seam: drop the armed registry without signalling anything. */
442
+ export function __resetArmedEscalationsForTest(): void {
443
+ armedEscalations.clear();
444
+ }
445
+
446
+ /**
447
+ * Reap a whole process group. See the module header for the safety model.
448
+ * Refused ⇒ nothing is signalled. Accepted ⇒ SIGTERM now, escalation in
449
+ * `pending` (re-validated before SIGKILL).
450
+ */
451
+ export function reapGroup(pgid: number, candidatePid: number, opts: ReapGroupOptions = {}): GroupReapResult {
452
+ const signalGroup = opts.signalGroup ?? defaultSignalGroup;
453
+ const groupAlive = opts.groupAlive ?? defaultGroupAlive;
454
+ const gracefulMs = opts.gracefulMs ?? DEFAULT_GRACEFUL_MS;
455
+ const capMs = opts.capMs ?? DEFAULT_CAP_MS;
456
+ const refusal = assertSafeGroup(pgid, candidatePid, opts);
457
+ if (refusal) {
458
+ logDebug("reap-group.refused", `refusing to signal pgid=${pgid} (${refusal})`, opts);
459
+ return { outcome: "refused", refusal, pending: Promise.resolve("refused") };
460
+ }
461
+ // Postmortem-safe prefix: the graceful signal is out BEFORE any await, so a
462
+ // host that dies right here still leaves SIGTERM delivered.
463
+ signalGroup(pgid, "SIGTERM");
464
+ if (!groupAlive(pgid)) return { outcome: "gone", pending: Promise.resolve("gone") };
465
+ // MINOR #5 (review): the escalation promise is handed to callers who may
466
+ // `void` it. It must therefore NEVER reject — an unhandled rejection is fatal
467
+ // on Node 22 — so every rung is wrapped and the worst case is a typed
468
+ // "stuck" outcome.
469
+ // MAJOR #3 (review): the escalation is also REGISTERED, so the two owners
470
+ // above can await it (drain) or deliver it synchronously (shutdown flush).
471
+ const armed: ArmedGroupEscalation = { pgid, candidatePid, opts };
472
+ armedEscalations.add(armed);
473
+ const pending = (async (): Promise<GroupReapOutcome> => {
474
+ try {
475
+ if (await pollUntil(() => !groupAlive(pgid), gracefulMs)) return "gone";
476
+ // The shutdown flush may already have delivered SIGKILL synchronously —
477
+ // signalling a second time would be a double kill of a possibly recycled
478
+ // group, so stop here.
479
+ if (armed.deliveredSync === true) return "killed-after-escalation";
480
+ // Re-validate immediately before SIGKILL: between the SIGTERM prefix and
481
+ // here the group may have drained and the pgid been recycled by an
482
+ // unrelated group. A failure here is a refusal, not an escalation.
483
+ const recheck = assertSafeGroup(pgid, candidatePid, opts);
484
+ if (recheck) {
485
+ logDebug("reap-group.sigkill-refused", `escalation refused for pgid=${pgid} (${recheck})`, opts);
486
+ return "refused";
487
+ }
488
+ signalGroup(pgid, "SIGKILL");
489
+ if (await pollUntil(() => !groupAlive(pgid), capMs)) return "killed-after-escalation";
490
+ logInternalError(
491
+ "reap-group.stuck",
492
+ new Error(`process group ${pgid} still alive after SIGKILL cap (${capMs}ms); giving up`),
493
+ `name=${opts.name ?? "-"}`,
494
+ "warn",
495
+ );
496
+ return "stuck";
497
+ } catch (err) {
498
+ logInternalError(
499
+ "reap-group.escalation-error",
500
+ err instanceof Error ? err : new Error(String(err)),
501
+ `pgid=${pgid} name=${opts.name ?? "-"}`,
502
+ "warn",
503
+ );
504
+ return "stuck";
505
+ }
506
+ })();
507
+ // Retire the entry the moment the escalation settles: a healthy escalation
508
+ // leaves nothing behind for the shutdown flush (and an empty registry is what
509
+ // the flush contract promises). Both handlers swallow — this derived promise
510
+ // must never reject, nothing awaits it.
511
+ void pending.then(
512
+ () => armedEscalations.delete(armed),
513
+ () => armedEscalations.delete(armed),
514
+ );
515
+ return { outcome: "killed-after-escalation", pending };
516
+ }