@sentropic/h2a-runtime 0.97.11 → 0.97.12
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/dist/cli-help-groups.js +1 -1
- package/dist/cli-help-groups.js.map +1 -1
- package/dist/file-lock.d.ts +12 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +37 -17
- package/dist/file-lock.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -7
- package/dist/index.js.map +1 -1
- package/dist/launch-guard.d.ts +31 -0
- package/dist/launch-guard.d.ts.map +1 -0
- package/dist/launch-guard.js +135 -0
- package/dist/launch-guard.js.map +1 -0
- package/dist/native-host.d.ts +11 -4
- package/dist/native-host.d.ts.map +1 -1
- package/dist/native-host.js +19 -6
- package/dist/native-host.js.map +1 -1
- package/dist/native-terminal/client.d.ts.map +1 -1
- package/dist/native-terminal/client.js +7 -0
- package/dist/native-terminal/client.js.map +1 -1
- package/dist/native-terminal/host.d.ts +258 -17
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +814 -68
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts.map +1 -1
- package/dist/native-terminal/op.js +8 -2
- package/dist/native-terminal/op.js.map +1 -1
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +1 -0
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/protocol.d.ts +1 -0
- package/dist/native-terminal/protocol.d.ts.map +1 -1
- package/dist/native-terminal/protocol.js.map +1 -1
- package/dist/native-terminal/screen.d.ts +3 -0
- package/dist/native-terminal/screen.d.ts.map +1 -0
- package/dist/native-terminal/screen.js +18 -0
- package/dist/native-terminal/screen.js.map +1 -0
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +5 -0
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +34 -2
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +420 -42
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/proc-identity.d.ts +67 -0
- package/dist/proc-identity.d.ts.map +1 -0
- package/dist/proc-identity.js +106 -0
- package/dist/proc-identity.js.map +1 -0
- package/dist/prompt-delivery.d.ts +11 -1
- package/dist/prompt-delivery.d.ts.map +1 -1
- package/dist/prompt-delivery.js +62 -6
- package/dist/prompt-delivery.js.map +1 -1
- package/dist/registry.d.ts +111 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -11
- package/dist/registry.js.map +1 -1
- package/package.json +3 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
3
4
|
// Durable session identity (incl. pgid) lives in the single registry store by
|
|
4
5
|
// design — one local store per the owner's durable-state direction (see the
|
|
5
6
|
// longer rationale on `persistNativeTerminalPgid`/`readNativeTerminalPgid` in
|
|
@@ -8,6 +9,9 @@ import { randomUUID } from "node:crypto";
|
|
|
8
9
|
// ONLY for pgid durability across a host crash, nothing else here reads or
|
|
9
10
|
// writes registry state.
|
|
10
11
|
import { listNativeTerminalPgidEntries, persistNativeTerminalPgid, pruneNativeTerminalPgidEntry, readNativeTerminalPgid, } from "../registry.js";
|
|
12
|
+
// The pid-namespace/boot/machine anchor a durable row is only re-provable
|
|
13
|
+
// within — the SAME definition the row was written with (see proc-identity.ts).
|
|
14
|
+
import { readBootId, readMachineId, readPidNamespaceId } from "../proc-identity.js";
|
|
11
15
|
import { TerminalReplayBuffer, } from "./replay-buffer.js";
|
|
12
16
|
import { NATIVE_TERMINAL_DEFAULT_MAX_SESSIONS, NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS, NATIVE_TERMINAL_MAX_SESSIONS, } from "./protocol.js";
|
|
13
17
|
function isErrnoException(error) {
|
|
@@ -228,6 +232,231 @@ export function groupCarriesSessionToken(pgid, expectedToken) {
|
|
|
228
232
|
}
|
|
229
233
|
return false;
|
|
230
234
|
}
|
|
235
|
+
/** Only these two errno values mean "this pid is gone", never "unreadable". */
|
|
236
|
+
function isGoneErrno(error) {
|
|
237
|
+
return (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ESRCH"));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* State (field 3) and process-group id (field 5) of one `/proc/.../stat` line.
|
|
241
|
+
* Anchors on the LAST `") "` for the reason `readProcessStartTime` documents at
|
|
242
|
+
* length (a `comm` field can itself contain spaces and parentheses). Returns
|
|
243
|
+
* undefined when the line cannot be parsed — the caller must treat that as
|
|
244
|
+
* "unknown", never as a state.
|
|
245
|
+
*/
|
|
246
|
+
function parseStatStateAndPgrp(raw) {
|
|
247
|
+
const splitAt = raw.lastIndexOf(") ");
|
|
248
|
+
if (splitAt < 0)
|
|
249
|
+
return undefined;
|
|
250
|
+
const fields = raw.slice(splitAt + 2).split(" ");
|
|
251
|
+
const state = fields[0];
|
|
252
|
+
const pgrp = Number(fields[2]);
|
|
253
|
+
if (state === undefined || state.length === 0)
|
|
254
|
+
return undefined;
|
|
255
|
+
if (!Number.isInteger(pgrp))
|
|
256
|
+
return undefined;
|
|
257
|
+
return { state, pgrp };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Whether the proc filesystem mounted at `procRoot` HIDES other processes
|
|
261
|
+
* (`hidepid=1`/`2`/`invisible`, i.e. systemd's `ProtectProc=`). Under
|
|
262
|
+
* `hidepid=2` a live process owned by another uid is not even listed by
|
|
263
|
+
* `readdir`, so a census cannot detect it by reading anything: only the mount
|
|
264
|
+
* option itself reveals that the view is partial.
|
|
265
|
+
*
|
|
266
|
+
* Three-valued on purpose: `true` = positively hidden, `false` = positively NOT
|
|
267
|
+
* hidden, `undefined` = the mount table could not be read OR describes no proc
|
|
268
|
+
* mount at `procRoot` at all, neither of which is evidence of a complete view.
|
|
269
|
+
* Callers requiring a complete view must accept `false` only.
|
|
270
|
+
*/
|
|
271
|
+
function procMountHidesProcesses(procRoot) {
|
|
272
|
+
let raw;
|
|
273
|
+
try {
|
|
274
|
+
raw = readFileSync(`${procRoot}/self/mountinfo`, "utf8");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
// `false` is a POSITIVE claim ("this mount does not hide processes"), so it
|
|
280
|
+
// may only be returned once a line actually describing this proc mount was
|
|
281
|
+
// seen. A mount table that names no such mount (a fixture tree, a reader
|
|
282
|
+
// pointed at a copy, an unexpected layout) leaves the frame unknown — the
|
|
283
|
+
// same epistemic state as a table that could not be read.
|
|
284
|
+
let sawProcMountAtRoot = false;
|
|
285
|
+
for (const line of raw.split("\n")) {
|
|
286
|
+
// mountinfo: `id parent major:minor root mountPoint options... - fstype source superOptions`.
|
|
287
|
+
const separator = line.indexOf(" - ");
|
|
288
|
+
if (separator < 0)
|
|
289
|
+
continue;
|
|
290
|
+
if (line.split(" ")[4] !== procRoot)
|
|
291
|
+
continue;
|
|
292
|
+
const post = line.slice(separator + 3).split(" ");
|
|
293
|
+
if (post[0] !== "proc")
|
|
294
|
+
continue;
|
|
295
|
+
sawProcMountAtRoot = true;
|
|
296
|
+
const option = (post[2] ?? "")
|
|
297
|
+
.split(",")
|
|
298
|
+
.find((candidate) => candidate.startsWith("hidepid="));
|
|
299
|
+
if (option === undefined)
|
|
300
|
+
continue;
|
|
301
|
+
const value = option.slice("hidepid=".length);
|
|
302
|
+
if (value !== "0" && value !== "off")
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return sawProcMountAtRoot ? false : undefined;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The EFFECTIVE state of a thread group (`/proc/<tgid>/task/*`): `"Z"` only
|
|
309
|
+
* when EVERY task is a zombie, otherwise the state of the first task that is
|
|
310
|
+
* not. Undefined when the task list, or any task's `stat`, could not be read —
|
|
311
|
+
* including a task that vanished mid-read, which does NOT prove the process
|
|
312
|
+
* gone (a surviving sibling thread keeps it alive).
|
|
313
|
+
*
|
|
314
|
+
* This exists because `/proc/<tgid>/stat` reports the state of the thread-group
|
|
315
|
+
* LEADER task alone. A process whose main thread called `pthread_exit()` while
|
|
316
|
+
* another thread keeps running is a DELAYED zombie: the leader is `Z`, the
|
|
317
|
+
* process is ALIVE, executes code and can fork (`ps` shows `Zl <defunct>`).
|
|
318
|
+
* Reading the leader's `Z` as "this member is dead" is a false death verdict.
|
|
319
|
+
*/
|
|
320
|
+
function threadGroupState(pid, procRoot) {
|
|
321
|
+
let tids;
|
|
322
|
+
try {
|
|
323
|
+
tids = readdirSync(`${procRoot}/${pid}/task`);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
let seen = 0;
|
|
329
|
+
for (const tid of tids) {
|
|
330
|
+
if (!/^\d+$/.test(tid))
|
|
331
|
+
continue;
|
|
332
|
+
let raw;
|
|
333
|
+
try {
|
|
334
|
+
raw = readFileSync(`${procRoot}/${pid}/task/${tid}/stat`, "utf8");
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const parsed = parseStatStateAndPgrp(raw);
|
|
340
|
+
if (parsed === undefined)
|
|
341
|
+
return undefined;
|
|
342
|
+
seen += 1;
|
|
343
|
+
if (parsed.state !== "Z")
|
|
344
|
+
return parsed.state;
|
|
345
|
+
}
|
|
346
|
+
return seen === 0 ? undefined : "Z";
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* One census of process group `pgid`: pid -> EFFECTIVE state, for every process
|
|
350
|
+
* the scan could see. Undefined means the census could not be taken COMPLETELY
|
|
351
|
+
* — distinct from an empty map, which means "the scan ran and showed no
|
|
352
|
+
* member". Refuses to answer at all unless:
|
|
353
|
+
*
|
|
354
|
+
* - the platform is Linux and the proc mount positively does NOT hide
|
|
355
|
+
* processes (see `procMountHidesProcesses`);
|
|
356
|
+
* - every `stat` it needed either read successfully or failed with
|
|
357
|
+
* ENOENT/ESRCH, the ONLY two errors that mean "this pid exited mid-scan".
|
|
358
|
+
* EACCES, EIO or anything else means a member exists that we cannot see, so
|
|
359
|
+
* the whole census is unknown;
|
|
360
|
+
* - every zombie member's own task list confirmed all its threads are zombies
|
|
361
|
+
* (see `threadGroupState`).
|
|
362
|
+
*/
|
|
363
|
+
function censusProcessGroup(pgid, procRoot) {
|
|
364
|
+
if (process.platform !== "linux")
|
|
365
|
+
return undefined;
|
|
366
|
+
if (procMountHidesProcesses(procRoot) !== false)
|
|
367
|
+
return undefined;
|
|
368
|
+
let names;
|
|
369
|
+
try {
|
|
370
|
+
names = readdirSync(procRoot);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const members = new Map();
|
|
376
|
+
for (const name of names) {
|
|
377
|
+
if (!/^\d+$/.test(name))
|
|
378
|
+
continue;
|
|
379
|
+
let raw;
|
|
380
|
+
try {
|
|
381
|
+
raw = readFileSync(`${procRoot}/${name}/stat`, "utf8");
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (isGoneErrno(error))
|
|
385
|
+
continue; // exited mid-scan: not a member
|
|
386
|
+
return undefined; // unreadable: a member may exist that we cannot see
|
|
387
|
+
}
|
|
388
|
+
const parsed = parseStatStateAndPgrp(raw);
|
|
389
|
+
if (parsed === undefined)
|
|
390
|
+
return undefined;
|
|
391
|
+
if (parsed.pgrp !== pgid)
|
|
392
|
+
continue;
|
|
393
|
+
const pid = Number(name);
|
|
394
|
+
if (parsed.state !== "Z") {
|
|
395
|
+
members.set(pid, parsed.state);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
// A `Z` leader task proves nothing about the process: check every thread.
|
|
399
|
+
const effective = threadGroupState(pid, procRoot);
|
|
400
|
+
if (effective === undefined)
|
|
401
|
+
return undefined;
|
|
402
|
+
members.set(pid, effective);
|
|
403
|
+
}
|
|
404
|
+
return members;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* True iff two CONSECUTIVE censuses of `pgid` both positively show the SAME,
|
|
408
|
+
* non-empty set of members and every one of them is a ZOMBIE down to its last
|
|
409
|
+
* thread — a group that is DEAD for containment: a zombie is an exit status
|
|
410
|
+
* waiting to be collected by its parent, it executes nothing, and no terminal
|
|
411
|
+
* work can ever resume in it.
|
|
412
|
+
*
|
|
413
|
+
* Why this needs its own probe: `kill(-pgid, 0)` answers ALIVE for such a
|
|
414
|
+
* group (a zombie is still attached to its pgid), and a zombie's
|
|
415
|
+
* `/proc/<pid>/environ` is empty, so it can carry no session token either.
|
|
416
|
+
* Without this re-check, the leader-absent branch of
|
|
417
|
+
* `#verifyGroupLeaderIdentity` classifies the group as
|
|
418
|
+
* `membership-unprovable` — an unidentifiable SURVIVOR — and a socket is then
|
|
419
|
+
* contained on account of processes that are already dead. Emitting
|
|
420
|
+
* `kill(-pgid, SIGKILL)` there would not help either: a zombie cannot be
|
|
421
|
+
* killed again, so the confirmation poll would run to its timeout.
|
|
422
|
+
*
|
|
423
|
+
* What this DOES fail closed on (answer `false`, i.e. "not proven dead"):
|
|
424
|
+
* non-Linux; a proc mount that hides processes or whose mount table could not
|
|
425
|
+
* be read; any member `stat` unreadable for a reason other than "it exited";
|
|
426
|
+
* any zombie member whose thread list could not be fully read; a group with no
|
|
427
|
+
* member at all (which contradicts the liveness probe that led here and is
|
|
428
|
+
* therefore not evidence of anything); and two censuses that disagree.
|
|
429
|
+
*
|
|
430
|
+
* DECLARED LIMIT, not closed by this: a census is not atomic. A live member can
|
|
431
|
+
* fork a child that inherits the pgid and exit before its own `stat` is read;
|
|
432
|
+
* the second census is what makes that shape answer `false` (the child is
|
|
433
|
+
* listed, or the member set changed), which NARROWS the race rather than
|
|
434
|
+
* eliminating it. The residual requires a member to fork and exit inside both
|
|
435
|
+
* censuses while leaving an identical, all-zombie pid set behind — and the
|
|
436
|
+
* consequence of the residual is bounded by what a caller does with a `true`:
|
|
437
|
+
* nothing is ever signalled on this path.
|
|
438
|
+
*
|
|
439
|
+
* `procRoot` is injected ONLY by tests (a `/proc`-shaped fixture tree, and a
|
|
440
|
+
* function form so a test can make the two censuses see different trees);
|
|
441
|
+
* production always reads the real `/proc`.
|
|
442
|
+
*/
|
|
443
|
+
export function groupIsOnlyZombies(pgid, procRoot = "/proc") {
|
|
444
|
+
const root = () => (typeof procRoot === "function" ? procRoot() : procRoot);
|
|
445
|
+
const first = censusProcessGroup(pgid, root());
|
|
446
|
+
if (first === undefined || first.size === 0)
|
|
447
|
+
return false;
|
|
448
|
+
for (const state of first.values())
|
|
449
|
+
if (state !== "Z")
|
|
450
|
+
return false;
|
|
451
|
+
const second = censusProcessGroup(pgid, root());
|
|
452
|
+
if (second === undefined || second.size !== first.size)
|
|
453
|
+
return false;
|
|
454
|
+
for (const [pid, state] of second) {
|
|
455
|
+
if (state !== "Z" || !first.has(pid))
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
231
460
|
function processExists(pid) {
|
|
232
461
|
try {
|
|
233
462
|
process.kill(pid, 0);
|
|
@@ -280,8 +509,26 @@ export const defaultOwnerHostProbe = (owner) => {
|
|
|
280
509
|
function delay(milliseconds) {
|
|
281
510
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
282
511
|
}
|
|
283
|
-
|
|
512
|
+
/** How long a group kill waits for the OS to report the group empty before
|
|
513
|
+
* reporting it timed out. Exported so a caller that budgets a wait around a
|
|
514
|
+
* reap (the functional suite) derives it from this value instead of copying it. */
|
|
515
|
+
export const NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS = 30_000;
|
|
284
516
|
const NATIVE_TERMINAL_FORCE_KILL_POLL_INTERVAL_MS = 50;
|
|
517
|
+
/** Deferred pty-exit prune: retry spacing and attempt budget (hygiene only —
|
|
518
|
+
* see `#schedulePruneOfExitedSession`). 8 x 25 ms bounds how long a contended
|
|
519
|
+
* registry keeps one exited session's timer alive. */
|
|
520
|
+
const NATIVE_TERMINAL_PRUNE_RETRY_DELAY_MS = 25;
|
|
521
|
+
const NATIVE_TERMINAL_PRUNE_MAX_ATTEMPTS = 8;
|
|
522
|
+
function readCurrentProcFrame() {
|
|
523
|
+
const pidNamespace = readPidNamespaceId();
|
|
524
|
+
const bootId = readBootId();
|
|
525
|
+
const machineId = readMachineId();
|
|
526
|
+
return {
|
|
527
|
+
...(pidNamespace !== undefined ? { pidNamespace } : {}),
|
|
528
|
+
...(bootId !== undefined ? { bootId } : {}),
|
|
529
|
+
...(machineId !== undefined ? { machineId } : {}),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
285
532
|
export class NativeTerminalHost {
|
|
286
533
|
#generation;
|
|
287
534
|
#replayBytesPerSession;
|
|
@@ -289,17 +536,24 @@ export class NativeTerminalHost {
|
|
|
289
536
|
#spawner;
|
|
290
537
|
#sessions = new Map();
|
|
291
538
|
#registryPath;
|
|
539
|
+
#socketPath;
|
|
292
540
|
#reaper;
|
|
293
541
|
#log;
|
|
294
542
|
#forceKillTimeoutMs;
|
|
295
543
|
#forceKillPollIntervalMs;
|
|
296
544
|
#readLeaderStartTime;
|
|
297
545
|
#findGroupMemberToken;
|
|
546
|
+
#groupIsOnlyZombies;
|
|
547
|
+
#readFrame;
|
|
298
548
|
#pgidGuardCounters = {
|
|
299
549
|
recycled: 0,
|
|
550
|
+
staleBoot: 0,
|
|
551
|
+
foreignFrame: 0,
|
|
300
552
|
membershipUnprovable: 0,
|
|
301
553
|
unverifiedLegacy: 0,
|
|
302
554
|
tokenVerified: 0,
|
|
555
|
+
zombieGroup: 0,
|
|
556
|
+
groupDrained: 0,
|
|
303
557
|
};
|
|
304
558
|
constructor(options) {
|
|
305
559
|
if (options.generation.trim().length === 0 ||
|
|
@@ -319,6 +573,15 @@ export class NativeTerminalHost {
|
|
|
319
573
|
this.#maxSessions = maxSessions;
|
|
320
574
|
this.#spawner = options.spawner;
|
|
321
575
|
this.#registryPath = options.registryPath;
|
|
576
|
+
// NORMALIZED before it is ever recorded: this string is the socket
|
|
577
|
+
// ATTRIBUTION a supervisor's socket-scoped containment decision is derived
|
|
578
|
+
// from (see `RegistryEntry.ownerHostSocketPath`), and two spellings of the
|
|
579
|
+
// same socket — a doubled separator, a "." segment, a relative path —
|
|
580
|
+
// would compare unequal and fail OPEN. The supervisor normalizes its own
|
|
581
|
+
// socket path the same way, and normalizes the row's value at comparison
|
|
582
|
+
// time so rows written before this still match.
|
|
583
|
+
this.#socketPath =
|
|
584
|
+
options.socketPath === undefined ? undefined : resolve(options.socketPath);
|
|
322
585
|
this.#reaper = options.reaper ?? posixProcessGroupReaper;
|
|
323
586
|
this.#log =
|
|
324
587
|
options.log ??
|
|
@@ -334,12 +597,14 @@ export class NativeTerminalHost {
|
|
|
334
597
|
options.readLeaderStartTime ?? readProcessStartTime;
|
|
335
598
|
this.#findGroupMemberToken =
|
|
336
599
|
options.findGroupMemberToken ?? groupCarriesSessionToken;
|
|
600
|
+
this.#groupIsOnlyZombies = options.groupIsOnlyZombies ?? groupIsOnlyZombies;
|
|
601
|
+
this.#readFrame = options.readFrame ?? readCurrentProcFrame;
|
|
337
602
|
}
|
|
338
603
|
get generation() {
|
|
339
604
|
return this.#generation;
|
|
340
605
|
}
|
|
341
606
|
/**
|
|
342
|
-
* COUNTABLE per arch condition 1/2: the
|
|
607
|
+
* COUNTABLE per arch condition 1/2: the five DISCERNIBLE outcomes of the
|
|
343
608
|
* group-leader-identity guard, so "has this guard ever refused (and why),
|
|
344
609
|
* or ever proceeded WITHOUT the fast leader-start-time proof" is
|
|
345
610
|
* answerable later without re-deriving it from raw logs.
|
|
@@ -350,15 +615,30 @@ export class NativeTerminalHost {
|
|
|
350
615
|
* counted at all (it is the expected common case). `unverifiedLegacy` is
|
|
351
616
|
* an UNPROVEN proceed (the kill WAS emitted) on a row with no baseline —
|
|
352
617
|
* neither a leader-start-time nor a group-token — to check against at
|
|
353
|
-
* all, the fail-open branch of `#verifyGroupLeaderIdentity`.
|
|
354
|
-
*
|
|
355
|
-
*
|
|
618
|
+
* all, the fail-open branch of `#verifyGroupLeaderIdentity`.
|
|
619
|
+
* `zombieGroup` is neither a refusal nor a kill: the group was found
|
|
620
|
+
* reduced to zombies, i.e. already dead, so NOTHING was emitted and the
|
|
621
|
+
* reap is reported confirmed. `groupDrained` is its sibling: the group was
|
|
622
|
+
* still there when the checks began and the OS positively reported it EMPTY
|
|
623
|
+
* when they ended. `staleBoot` is a refusal that PROVES the group gone (the
|
|
624
|
+
* row predates this boot), counted apart from `recycled` because the two
|
|
625
|
+
* rest on different facts. `foreignFrame` is a refusal that proves NOTHING
|
|
626
|
+
* because the row was written in a frame this reader cannot prove is its
|
|
627
|
+
* own, counted apart from `membershipUnprovable`, which judged a group in
|
|
628
|
+
* this reader's own process space. Never collapsed: a proven kill, an
|
|
629
|
+
* unproven one, a refusal that proves death, a refusal that proves nothing,
|
|
630
|
+
* and an already-dead group must never look alike here — see
|
|
356
631
|
* `NativeTerminalReapOutcome`'s `cause`/`verified`.
|
|
357
632
|
*/
|
|
358
633
|
get pgidGuardCounters() {
|
|
359
634
|
return { ...this.#pgidGuardCounters };
|
|
360
635
|
}
|
|
361
636
|
create(options) {
|
|
637
|
+
if (options.launchFence && (options.launchFence.generation !== this.#generation ||
|
|
638
|
+
typeof options.launchFence.incarnation !== "string" ||
|
|
639
|
+
!/^[0-9a-f-]{36}$/.test(options.launchFence.incarnation))) {
|
|
640
|
+
throw new Error("invalid native launch fence");
|
|
641
|
+
}
|
|
362
642
|
if (options.id.trim().length === 0 ||
|
|
363
643
|
options.id.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
|
|
364
644
|
throw new RangeError(`terminal session id must contain 1-${NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS} characters`);
|
|
@@ -399,20 +679,23 @@ export class NativeTerminalHost {
|
|
|
399
679
|
// killed has no other way to learn this session's pgid). A session
|
|
400
680
|
// whose pgid did not durably persist would be unreapable after a host
|
|
401
681
|
// crash — refuse to create it rather than leave an untracked child.
|
|
402
|
-
// The OWNER attribution (this host's own pid + start-time
|
|
403
|
-
// on the same durable write:
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
682
|
+
// The OWNER attribution (this host's own pid + start-time, plus the
|
|
683
|
+
// socket it serves) rides along on the same durable write: the pid and
|
|
684
|
+
// start-time are what let a LATER reconcile pass prove THIS host is
|
|
685
|
+
// dead (not just unreachable) before ever reaping this row, and the
|
|
686
|
+
// socket is what keeps a fail-closed containment decision SCOPED to the
|
|
687
|
+
// supervisor that owns it — see `reconcileDeadHostOrphans`. The
|
|
688
|
+
// GROUP-LEADER's own start-time (leader pid == pgid) and the GROUP
|
|
689
|
+
// token both ride along the same write for the SAME reason one level
|
|
690
|
+
// down: they are what let a LATER kill re-prove the group itself is
|
|
691
|
+
// still the one this row was written for, not a recycled pgid — see
|
|
410
692
|
// `#verifyGroupLeaderIdentity`.
|
|
411
693
|
const ownStartTime = readProcessStartTime(process.pid);
|
|
412
694
|
const leaderStartTime = this.#readLeaderStartTime(pty.pgid);
|
|
413
695
|
persistNativeTerminalPgid(options.id, pty.pgid, this.#registryPath, {
|
|
414
696
|
pid: process.pid,
|
|
415
697
|
...(ownStartTime === undefined ? {} : { startTime: ownStartTime }),
|
|
698
|
+
...(this.#socketPath === undefined ? {} : { socketPath: this.#socketPath }),
|
|
416
699
|
}, leaderStartTime, groupToken);
|
|
417
700
|
}
|
|
418
701
|
catch (error) {
|
|
@@ -426,8 +709,9 @@ export class NativeTerminalHost {
|
|
|
426
709
|
}
|
|
427
710
|
const record = {
|
|
428
711
|
id: options.id,
|
|
429
|
-
incarnation: randomUUID(),
|
|
712
|
+
incarnation: options.launchFence?.incarnation ?? randomUUID(),
|
|
430
713
|
pty,
|
|
714
|
+
groupToken,
|
|
431
715
|
replay: new TerminalReplayBuffer(this.#replayBytesPerSession),
|
|
432
716
|
status: "running",
|
|
433
717
|
exit: null,
|
|
@@ -464,6 +748,14 @@ export class NativeTerminalHost {
|
|
|
464
748
|
record.exitSubscription = record.pty.onExit((event) => {
|
|
465
749
|
if (record.exit !== null)
|
|
466
750
|
return;
|
|
751
|
+
// A durable row exists to make this session reapable after THIS host
|
|
752
|
+
// dies. Once the group is confirmed empty the row can no longer make
|
|
753
|
+
// anything reapable — it is a stale record that a later host death would
|
|
754
|
+
// re-examine against whatever holds that pgid NUMBER by then. Drop it
|
|
755
|
+
// on POSITIVE proof only (see `#pruneDurableRowIfGroupEmpty`), and OFF
|
|
756
|
+
// this callback: this is node-pty's own synchronous exit callback on the
|
|
757
|
+
// host's event loop (see `#schedulePruneOfExitedSession`).
|
|
758
|
+
this.#schedulePruneOfExitedSession(record, "its pty exited");
|
|
467
759
|
const exit = {
|
|
468
760
|
exitCode: event.exitCode,
|
|
469
761
|
};
|
|
@@ -678,6 +970,15 @@ export class NativeTerminalHost {
|
|
|
678
970
|
if (outcome.status !== "dead") {
|
|
679
971
|
errors.push(new Error(`terminal session ${record.id} pgid=${record.pty.pgid} did not confirm dead within ${this.#forceKillTimeoutMs}ms`));
|
|
680
972
|
}
|
|
973
|
+
else {
|
|
974
|
+
// PROVEN empty by the call above, so the durable row cannot make
|
|
975
|
+
// anything reapable any more — the same staleness the pty-exit path
|
|
976
|
+
// prunes, reached through the shutdown path instead. This one DOES
|
|
977
|
+
// wait for the lock: `forceStopAll` is the graceful-shutdown path, it
|
|
978
|
+
// is already asynchronous, and the row should be gone before the host
|
|
979
|
+
// exits rather than left to a timer that may never fire.
|
|
980
|
+
this.#pruneDurableRowIfGroupEmpty(record, "forceStopAll proved its group dead", { waitForLock: true });
|
|
981
|
+
}
|
|
681
982
|
}
|
|
682
983
|
catch (error) {
|
|
683
984
|
errors.push(error);
|
|
@@ -696,8 +997,20 @@ export class NativeTerminalHost {
|
|
|
696
997
|
* this signals NOTHING and returns `"refused"` after logging a LOUD
|
|
697
998
|
* diagnostic — a silent no-op here would recreate the exact
|
|
698
999
|
* invisible-orphan bug this mechanism exists to close.
|
|
1000
|
+
*
|
|
1001
|
+
* `expectedPgid` and `expected.groupToken` are the caller's IMMUTABLE
|
|
1002
|
+
* snapshot of the row, and BOTH are re-compared against the row as it reads
|
|
1003
|
+
* NOW — the same compare-and-delete identity `pruneNativeTerminalPgidEntry`
|
|
1004
|
+
* uses, applied to the kill side. Comparing the pgid NUMBER alone is not
|
|
1005
|
+
* enough: session ids are caller-chosen, so a live host can recreate the
|
|
1006
|
+
* same id within one reconcile pass, and its new PTY leader can be handed
|
|
1007
|
+
* back the pid number the snapshotted group just freed. The re-read then
|
|
1008
|
+
* matches, every identity check below verifies against the NEW row's own
|
|
1009
|
+
* baselines, and a LIVE group is killed. The token is a per-session
|
|
1010
|
+
* `randomUUID`, so a successor never matches it. A caller that omits the
|
|
1011
|
+
* token (a legacy row carries none) does not constrain it.
|
|
699
1012
|
*/
|
|
700
|
-
async reapOrphan(sessionId, signal = "SIGKILL", expectedPgid) {
|
|
1013
|
+
async reapOrphan(sessionId, signal = "SIGKILL", expectedPgid, expected) {
|
|
701
1014
|
const lookup = readNativeTerminalPgid(sessionId, this.#registryPath);
|
|
702
1015
|
if (lookup.status === "unresolved") {
|
|
703
1016
|
this.#log(`REFUSING to reap terminal session ${sessionId}: pgid could not be resolved (${lookup.reason}). PROCESSES MAY HAVE SURVIVED — no group kill was issued.`);
|
|
@@ -712,10 +1025,30 @@ export class NativeTerminalHost {
|
|
|
712
1025
|
cause: "pgid-mismatch",
|
|
713
1026
|
};
|
|
714
1027
|
}
|
|
1028
|
+
// Checked AFTER the pgid, so the common rewrite keeps the diagnostic that
|
|
1029
|
+
// names both pgid numbers. This one catches what that check cannot see: the
|
|
1030
|
+
// row was rewritten for a DIFFERENT group that happens to carry the same
|
|
1031
|
+
// pgid number — the successor's PTY leader was handed back the pid number
|
|
1032
|
+
// the snapshotted group had just freed.
|
|
1033
|
+
if (expected?.groupToken !== undefined &&
|
|
1034
|
+
lookup.groupToken !== expected.groupToken) {
|
|
1035
|
+
this.#log(`REFUSING to reap terminal session ${sessionId}: pgid=${lookup.pgid} still matches the immutable snapshot, but the registry row now carries a DIFFERENT group token — it describes another group under the same session id, reachable at the same pid number. PROCESSES MAY HAVE SURVIVED — no group kill was issued. cause=pgid-mismatch sessionId=${sessionId} resolvedPgid=${lookup.pgid}`);
|
|
1036
|
+
return {
|
|
1037
|
+
sessionId,
|
|
1038
|
+
status: "refused",
|
|
1039
|
+
reason: `the re-resolved row carries a different group token than the immutable snapshot (pgid=${lookup.pgid})`,
|
|
1040
|
+
cause: "pgid-mismatch",
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
715
1043
|
const outcome = await this.#killGroupAndConfirmDead(lookup.pgid, signal, `orphan ${sessionId}`, {
|
|
716
1044
|
sessionId,
|
|
717
1045
|
persistedLeaderStartTime: lookup.leaderStartTime,
|
|
718
1046
|
persistedGroupToken: lookup.groupToken,
|
|
1047
|
+
persistedFrame: {
|
|
1048
|
+
...(lookup.pidNamespace !== undefined ? { pidNamespace: lookup.pidNamespace } : {}),
|
|
1049
|
+
...(lookup.bootId !== undefined ? { bootId: lookup.bootId } : {}),
|
|
1050
|
+
...(lookup.machineId !== undefined ? { machineId: lookup.machineId } : {}),
|
|
1051
|
+
},
|
|
719
1052
|
});
|
|
720
1053
|
if (outcome.status === "dead") {
|
|
721
1054
|
return {
|
|
@@ -743,6 +1076,47 @@ export class NativeTerminalHost {
|
|
|
743
1076
|
* of acting, never merely inherited. POSITIVE proof only (INV-1) —
|
|
744
1077
|
* ambiguity always REFUSES, never kills. In order:
|
|
745
1078
|
*
|
|
1079
|
+
* 0. The row's FRAME (`pgidPidNamespace`/`pgidBootId`/`pgidMachineId`,
|
|
1080
|
+
* see proc-identity.ts) is checked FIRST, because every comparison below
|
|
1081
|
+
* is between a number persisted then and a number read now, and that is
|
|
1082
|
+
* meaningless across a pid namespace, a reboot or a machine. Three cases:
|
|
1083
|
+
* - SAME, KNOWN pid namespace, DIFFERENT boot id, SAME, KNOWN machine id
|
|
1084
|
+
* -> the row is from a previous boot of this very pid space on this
|
|
1085
|
+
* very machine, and a reboot ends every process of the boot before
|
|
1086
|
+
* it. That is POSITIVE proof that every process this row describes
|
|
1087
|
+
* has ended — the strongest form of the `recycled` proof, not an
|
|
1088
|
+
* absence of one. REFUSE (nothing is ever signalled: the pgid NUMBER
|
|
1089
|
+
* may well be held by an unrelated live group now, and it must not be
|
|
1090
|
+
* touched) with cause `"stale-boot"`, which reconcile reads as proof
|
|
1091
|
+
* and prunes the row on. The machine id is what makes this a proof:
|
|
1092
|
+
* the init pid namespace has the SAME inode on every kernel and a boot
|
|
1093
|
+
* id is random per boot, so without it the very same two fields are
|
|
1094
|
+
* also what a row written by ANOTHER machine sharing this registry
|
|
1095
|
+
* (a networked `$HOME`) looks like — and that machine's group may be
|
|
1096
|
+
* alive. DECLARED EXCEPTIONS, out of model: a process checkpointed
|
|
1097
|
+
* before the reboot and restored after it (CRIU) can carry its pid
|
|
1098
|
+
* across boots, and two machines cloned from one image without
|
|
1099
|
+
* regenerating machine-id(5) are indistinguishable. h2a never
|
|
1100
|
+
* checkpoints a PTY host, and a deployment that restores one, or that
|
|
1101
|
+
* shares one registry between machines with duplicated machine ids,
|
|
1102
|
+
* must not rely on this proof.
|
|
1103
|
+
* - Any other mismatch — including an UNKNOWN namespace, boot id or
|
|
1104
|
+
* (for the boot proof) machine id on either side -> REFUSE, cause
|
|
1105
|
+
* `"foreign-frame"` (a start-time mismatch would otherwise be read as
|
|
1106
|
+
* `recycled` and prune the row; a coincidental MATCH would authorize a
|
|
1107
|
+
* kill at a group this row never described). A pid number means
|
|
1108
|
+
* nothing outside the frame it was resolved in, so neither proof is
|
|
1109
|
+
* available there, and neither is any local inspection: blocking.
|
|
1110
|
+
* - A row that records NO pid-space frame is not refused here — that
|
|
1111
|
+
* would strand every row written before the field existed — but it
|
|
1112
|
+
* cannot earn the `recycled` PROOF either (see branch 2).
|
|
1113
|
+
* DECLARED LIMIT, outside this guard: the already-empty short-circuit in
|
|
1114
|
+
* `#killGroupAndConfirmDead` answers BEFORE this frame check, so a row
|
|
1115
|
+
* whose pgid number is empty in this reader's process space is reported
|
|
1116
|
+
* reaped (and pruned) whatever frame it records. Within one machine that
|
|
1117
|
+
* is the pre-existing behaviour every row has always had; across
|
|
1118
|
+
* machines sharing one registry it is NOT a proof, and that deployment
|
|
1119
|
+
* shape is not covered.
|
|
746
1120
|
* 1. Leader **readable**, no start-time baseline was ever persisted (a
|
|
747
1121
|
* legacy row, or a write-time read failure) -> nothing to compare
|
|
748
1122
|
* against -> cannot be checked for recycling at all, so this PROCEEDS
|
|
@@ -757,7 +1131,12 @@ export class NativeTerminalHost {
|
|
|
757
1131
|
* baseline -> the OS reused `pgid` for an unrelated process since
|
|
758
1132
|
* this row was written -> REFUSE, cause `"recycled"` (the defensive
|
|
759
1133
|
* save: without this check, `killGroup` below would have signalled
|
|
760
|
-
* an innocent third party).
|
|
1134
|
+
* an innocent third party). `"recycled"` is also a POSITIVE proof that
|
|
1135
|
+
* the original group is gone — reconcile prunes the row on it — so it
|
|
1136
|
+
* requires the row to carry BOTH frame fields (matched in step 0). A row
|
|
1137
|
+
* with no frame takes the blocking `"membership-unprovable"` instead: the
|
|
1138
|
+
* mismatch is real, but it cannot prove anything about this reader's pid
|
|
1139
|
+
* space.
|
|
761
1140
|
* 3. Leader **readable**, start-time **MATCHES** -> the group is
|
|
762
1141
|
* provably the one this row was written for -> PROCEED,
|
|
763
1142
|
* `verified: true`.
|
|
@@ -774,8 +1153,26 @@ export class NativeTerminalHost {
|
|
|
774
1153
|
* independent of the leader -> PROCEED, `verified: true`, cause
|
|
775
1154
|
* `"token-verified"`.
|
|
776
1155
|
* - a persisted token exists and NO surviving member carries it ->
|
|
777
|
-
*
|
|
778
|
-
*
|
|
1156
|
+
* ONE re-check before refusing: a group the OS reports alive whose
|
|
1157
|
+
* every member is a ZOMBIE is already dead (see
|
|
1158
|
+
* `groupIsOnlyZombies` — a zombie is attached to its pgid, executes
|
|
1159
|
+
* nothing, and its environ is empty so it could never have carried
|
|
1160
|
+
* the token). That is `alreadyDead`, cause `"zombie-group"`:
|
|
1161
|
+
* NOTHING is signalled and the reap is reported CONFIRMED, because
|
|
1162
|
+
* a group with no live member cannot resume terminal work and a
|
|
1163
|
+
* group-kill would only poll to its timeout.
|
|
1164
|
+
* - the census did not prove the group all-zombie -> ONE last probe
|
|
1165
|
+
* before refusing: the liveness check that admitted this group ran
|
|
1166
|
+
* BEFORE every check above, and those checks take real time (two full
|
|
1167
|
+
* `/proc` censuses among them). A group whose last members were
|
|
1168
|
+
* collected meanwhile is EMPTY now, and the OS says so. `isGroupAlive`
|
|
1169
|
+
* answering ESRCH is the same positive proof of death the
|
|
1170
|
+
* short-circuit in `#killGroupAndConfirmDead` and its confirmation
|
|
1171
|
+
* poll already act on, so this cannot manufacture a death verdict —
|
|
1172
|
+
* a live group, leaderless or `Zl`, still answers ALIVE and is still
|
|
1173
|
+
* refused. That is `alreadyDead`, cause `"group-drained"`.
|
|
1174
|
+
* - otherwise -> cannot positively prove this group's identity by any
|
|
1175
|
+
* means -> REFUSE, cause `"membership-unprovable"` (conservative
|
|
779
1176
|
* refuse-and-leak: this may well be our OWN orphan, already fully
|
|
780
1177
|
* dead, but we cannot tell that apart from a live unrelated group
|
|
781
1178
|
* we simply cannot identify). DISTINCT from `"recycled"` in the
|
|
@@ -783,7 +1180,67 @@ export class NativeTerminalHost {
|
|
|
783
1180
|
* else's" and "cannot prove it is ours" are different epistemic
|
|
784
1181
|
* states.
|
|
785
1182
|
*/
|
|
786
|
-
#verifyGroupLeaderIdentity(pgid, persistedLeaderStartTime, persistedGroupToken, sessionId) {
|
|
1183
|
+
#verifyGroupLeaderIdentity(pgid, persistedLeaderStartTime, persistedGroupToken, sessionId, frame = {}) {
|
|
1184
|
+
// FRAME CHECK, before any pid-based comparison. Every proof below compares a
|
|
1185
|
+
// number persisted earlier with a number read now, and that is meaningful
|
|
1186
|
+
// only while both were taken in the same pid namespace and after the same
|
|
1187
|
+
// boot (see proc-identity.ts). A row that records a frame which does not
|
|
1188
|
+
// match this reader's is unusable in BOTH directions: a start-time mismatch
|
|
1189
|
+
// would be read as "recycled" (pruning the row and releasing the socket on a
|
|
1190
|
+
// proof that does not hold), and a coincidental MATCH would authorize a
|
|
1191
|
+
// group SIGKILL at a group this row never described. Refuse, blocking, which
|
|
1192
|
+
// is the behaviour that predates the `recycled` outcome.
|
|
1193
|
+
//
|
|
1194
|
+
// ONE mismatch is different, and is separated out below: same pid namespace,
|
|
1195
|
+
// different BOOT, same MACHINE. That is not an absence of proof, it is the
|
|
1196
|
+
// strongest proof available — a reboot ends every process of the boot
|
|
1197
|
+
// before it — so it prunes instead of blocking. It is still a refusal:
|
|
1198
|
+
// nothing is signalled, because an unrelated live group may hold the pgid
|
|
1199
|
+
// number by now.
|
|
1200
|
+
const anchoredFrame = frame.pidNamespace !== undefined && frame.bootId !== undefined;
|
|
1201
|
+
if (frame.pidNamespace !== undefined || frame.bootId !== undefined) {
|
|
1202
|
+
const current = this.#readFrame();
|
|
1203
|
+
// The boot proof is admissible only inside ONE pid space, KNOWN on both
|
|
1204
|
+
// sides: a pid resolved in another namespace is a different process, and
|
|
1205
|
+
// an unknown namespace on either side is no statement at all.
|
|
1206
|
+
const sameKnownPidNamespace = frame.pidNamespace !== undefined &&
|
|
1207
|
+
current.pidNamespace !== undefined &&
|
|
1208
|
+
frame.pidNamespace === current.pidNamespace;
|
|
1209
|
+
// ...and only on ONE machine, KNOWN on both sides. The init pid
|
|
1210
|
+
// namespace has the same inode on every kernel and boot ids are random
|
|
1211
|
+
// per boot, so "same namespace, different boot" is ALSO what a row written
|
|
1212
|
+
// by another machine sharing this registry looks like — a machine whose
|
|
1213
|
+
// group may be alive right now. Only the machine id tells the two apart.
|
|
1214
|
+
const sameKnownMachine = frame.machineId !== undefined &&
|
|
1215
|
+
current.machineId !== undefined &&
|
|
1216
|
+
frame.machineId === current.machineId;
|
|
1217
|
+
const differentKnownBoot = frame.bootId !== undefined &&
|
|
1218
|
+
current.bootId !== undefined &&
|
|
1219
|
+
frame.bootId !== current.bootId;
|
|
1220
|
+
if (sameKnownPidNamespace && sameKnownMachine && differentKnownBoot) {
|
|
1221
|
+
// PROOF, not an absence of one: a reboot ends every process of the
|
|
1222
|
+
// boot before it, so every process this row describes has ended. The
|
|
1223
|
+
// pgid NUMBER may be held by an unrelated live group now, which is
|
|
1224
|
+
// exactly why nothing is signalled here and why no diagnostic may
|
|
1225
|
+
// point anyone at it.
|
|
1226
|
+
this.#pgidGuardCounters.staleBoot += 1;
|
|
1227
|
+
this.#log(`NOT signalling process group pgid=${pgid} for session ${sessionId}: this row was written at boot ${String(frame.bootId)} and this reader is at boot ${String(current.bootId)}, in the same pid namespace ${String(current.pidNamespace)} on the same machine ${String(current.machineId)} — the reboot ENDED every process this row describes, so its group is PROVEN gone. The number pgid=${pgid} may belong to an unrelated live group now and must NOT be killed. cause=stale-boot sessionId=${sessionId} pgid=${pgid}`);
|
|
1228
|
+
return { proceed: false, cause: "stale-boot" };
|
|
1229
|
+
}
|
|
1230
|
+
if (frame.pidNamespace !== current.pidNamespace ||
|
|
1231
|
+
frame.bootId !== current.bootId) {
|
|
1232
|
+
// Says WHY nothing could be proven, because the two shapes call for
|
|
1233
|
+
// different checks by whoever reads this: a boot change that only lacks
|
|
1234
|
+
// the machine proof is either a reboot of this machine or another
|
|
1235
|
+
// machine; anything else is another (or an unknown) pid space.
|
|
1236
|
+
const why = sameKnownPidNamespace && differentKnownBoot
|
|
1237
|
+
? `a different boot in the same pid namespace inode is what a previous boot of THIS machine looks like, but also what ANOTHER machine sharing this registry looks like, and the machine ids that would tell them apart are ${frame.machineId === undefined || current.machineId === undefined ? "not known on both sides" : "DIFFERENT"}`
|
|
1238
|
+
: "no pid or start-time on it can be re-proven here";
|
|
1239
|
+
this.#pgidGuardCounters.foreignFrame += 1;
|
|
1240
|
+
this.#log(`REFUSING to act on process group pgid=${pgid} for session ${sessionId}: this row records pid namespace ${String(frame.pidNamespace)}, boot ${String(frame.bootId)} and machine ${String(frame.machineId)}, but this reader is in pid namespace ${String(current.pidNamespace)} at boot ${String(current.bootId)} on machine ${String(current.machineId)} — ${why}. Inspecting pgid=${pgid} HERE proves nothing about this row. PROCESSES MAY SURVIVE UNCOLLECTED. cause=foreign-frame sessionId=${sessionId} pgid=${pgid}`);
|
|
1241
|
+
return { proceed: false, cause: "foreign-frame" };
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
787
1244
|
const currentStartTime = this.#readLeaderStartTime(pgid);
|
|
788
1245
|
if (currentStartTime !== undefined) {
|
|
789
1246
|
if (persistedLeaderStartTime === undefined) {
|
|
@@ -792,6 +1249,16 @@ export class NativeTerminalHost {
|
|
|
792
1249
|
return { proceed: true, verified: false, cause: "unverified-legacy" };
|
|
793
1250
|
}
|
|
794
1251
|
if (currentStartTime !== persistedLeaderStartTime) {
|
|
1252
|
+
if (!anchoredFrame) {
|
|
1253
|
+
// The mismatch is real, but "the pgid was RECYCLED, so the original
|
|
1254
|
+
// group is gone" is a claim about ONE pid namespace and ONE boot, and
|
|
1255
|
+
// this row cannot pin either (written before the anchor existed). Keep
|
|
1256
|
+
// refusing — blocking, row kept — instead of pruning on a proof this
|
|
1257
|
+
// row cannot support.
|
|
1258
|
+
this.#pgidGuardCounters.membershipUnprovable += 1;
|
|
1259
|
+
this.#log(`REFUSING to kill process group pgid=${pgid} for session ${sessionId}: current leader start-time (${currentStartTime}) DIFFERS from the persisted start-time (${persistedLeaderStartTime}), but this row records no pid-namespace/boot anchor, so that mismatch cannot PROVE the original group is gone. PROCESSES MAY SURVIVE UNCOLLECTED. cause=membership-unprovable sessionId=${sessionId} pgid=${pgid}`);
|
|
1260
|
+
return { proceed: false, cause: "membership-unprovable" };
|
|
1261
|
+
}
|
|
795
1262
|
this.#pgidGuardCounters.recycled += 1;
|
|
796
1263
|
this.#log(`REFUSING to kill process group pgid=${pgid} for session ${sessionId}: current leader start-time (${currentStartTime}) DIFFERS from the persisted start-time (${persistedLeaderStartTime}) — pgid was RECYCLED to an unrelated process since this row was written. PROCESSES MAY SURVIVE UNCOLLECTED. cause=recycled sessionId=${sessionId} pgid=${pgid}`);
|
|
797
1264
|
return { proceed: false, cause: "recycled" };
|
|
@@ -810,6 +1277,28 @@ export class NativeTerminalHost {
|
|
|
810
1277
|
this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId}: the leader is unreadable, but a surviving GROUP MEMBER carries the persisted session token — positive proof of membership. cause=token-verified sessionId=${sessionId} pgid=${pgid}`);
|
|
811
1278
|
return { proceed: true, verified: true, cause: "token-verified" };
|
|
812
1279
|
}
|
|
1280
|
+
// Last re-check before a refusal that would contain a whole socket: a
|
|
1281
|
+
// group whose every remaining member is a ZOMBIE is already dead. This is
|
|
1282
|
+
// the shape a host SIGKILL leaves behind when the guardian's parent-death
|
|
1283
|
+
// broadcast lands, the leader is reaped first, and some member's exit
|
|
1284
|
+
// status has not been collected yet — the leader is unreadable and a
|
|
1285
|
+
// zombie's environ is empty, so neither anchor above could answer.
|
|
1286
|
+
if (this.#groupIsOnlyZombies(pgid)) {
|
|
1287
|
+
this.#pgidGuardCounters.zombieGroup += 1;
|
|
1288
|
+
this.#log(`NOT signalling process group pgid=${pgid} for session ${sessionId}: the leader is UNREADABLE and every remaining member of the group is a ZOMBIE — the group is already dead (a zombie executes nothing and cannot be killed again), so this reap is CONFIRMED without emitting any signal. cause=zombie-group sessionId=${sessionId} pgid=${pgid}`);
|
|
1289
|
+
return { proceed: false, alreadyDead: true, cause: "zombie-group" };
|
|
1290
|
+
}
|
|
1291
|
+
// The liveness probe that admitted this group ran BEFORE the frame read,
|
|
1292
|
+
// the leader read, the token scan and two full /proc censuses. A group
|
|
1293
|
+
// that finished draining across that work is empty NOW — ask the OS once
|
|
1294
|
+
// more rather than block a socket on a group it answers ESRCH for. This is
|
|
1295
|
+
// the same positive proof the short-circuit above this guard acts on: a
|
|
1296
|
+
// live group still answers ALIVE and still falls through to the refusal.
|
|
1297
|
+
if (!this.#reaper.isGroupAlive(pgid)) {
|
|
1298
|
+
this.#pgidGuardCounters.groupDrained += 1;
|
|
1299
|
+
this.#log(`NOT signalling process group pgid=${pgid} for session ${sessionId}: the leader is UNREADABLE and no member carried the token, but the group is now confirmed EMPTY by the OS — it finished draining while this reap was identifying it, so the reap is CONFIRMED without emitting any signal. cause=group-drained sessionId=${sessionId} pgid=${pgid}`);
|
|
1300
|
+
return { proceed: false, alreadyDead: true, cause: "group-drained" };
|
|
1301
|
+
}
|
|
813
1302
|
this.#pgidGuardCounters.membershipUnprovable += 1;
|
|
814
1303
|
this.#log(`REFUSING to kill process group pgid=${pgid} for session ${sessionId}: the group leader is UNREADABLE and no surviving member carries the persisted session token — cannot positively prove this group's identity. PROCESSES MAY SURVIVE UNCOLLECTED. cause=membership-unprovable sessionId=${sessionId} pgid=${pgid}`);
|
|
815
1304
|
return { proceed: false, cause: "membership-unprovable" };
|
|
@@ -857,8 +1346,16 @@ export class NativeTerminalHost {
|
|
|
857
1346
|
}
|
|
858
1347
|
let verified;
|
|
859
1348
|
if (verify) {
|
|
860
|
-
const verdict = this.#verifyGroupLeaderIdentity(pgid, verify.persistedLeaderStartTime, verify.persistedGroupToken, verify.sessionId);
|
|
1349
|
+
const verdict = this.#verifyGroupLeaderIdentity(pgid, verify.persistedLeaderStartTime, verify.persistedGroupToken, verify.sessionId, verify.persistedFrame);
|
|
861
1350
|
if (!verdict.proceed) {
|
|
1351
|
+
if (verdict.alreadyDead) {
|
|
1352
|
+
// The group was found reduced to zombies, or confirmed empty by the
|
|
1353
|
+
// OS after the checks: dead, with no signal emitted and nothing left
|
|
1354
|
+
// to wait for. Reported exactly like the already-empty short-circuit
|
|
1355
|
+
// above — no `verified` flag, because this proves the group's STATE,
|
|
1356
|
+
// not its identity.
|
|
1357
|
+
return { status: "dead", elapsedMs: 0 };
|
|
1358
|
+
}
|
|
862
1359
|
return { status: "refused", cause: verdict.cause };
|
|
863
1360
|
}
|
|
864
1361
|
verified = verdict.verified;
|
|
@@ -891,6 +1388,78 @@ export class NativeTerminalHost {
|
|
|
891
1388
|
await delay(this.#forceKillPollIntervalMs);
|
|
892
1389
|
}
|
|
893
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Drop a session's durable pgid row, but ONLY on the OS's positive proof
|
|
1393
|
+
* that its process group is empty — the same asymmetry every other decision
|
|
1394
|
+
* here applies, one step further: a row is the ONLY way a later pass can
|
|
1395
|
+
* reach a group that outlived its session leader (the ordinary orphan: a
|
|
1396
|
+
* shell that exits normally leaving a backgrounded descendant), so an
|
|
1397
|
+
* ambiguous probe must KEEP the row. `isGroupAlive` answers "alive" for
|
|
1398
|
+
* EPERM and for anything it cannot decide, which is exactly the fail-safe
|
|
1399
|
+
* direction here.
|
|
1400
|
+
*
|
|
1401
|
+
* Best-effort and never throws: pruning is hygiene, and a registry that
|
|
1402
|
+
* cannot be written must not turn a pty exit or a host shutdown into a
|
|
1403
|
+
* failure. A row left behind is the pre-existing behaviour, not a new risk.
|
|
1404
|
+
*/
|
|
1405
|
+
#pruneDurableRowIfGroupEmpty(record, because, options) {
|
|
1406
|
+
try {
|
|
1407
|
+
// Rows are keyed by session id alone, so a same-id session created
|
|
1408
|
+
// elsewhere owns this key now. Prune only a row that still points at THIS
|
|
1409
|
+
// session's group — never someone else's durable record. The cheap read
|
|
1410
|
+
// below skips the lock entirely for the common "group still alive" case;
|
|
1411
|
+
// the delete itself re-verifies the row UNDER the lock (compare-and-
|
|
1412
|
+
// delete), because this read and that delete are not one critical section.
|
|
1413
|
+
const row = readNativeTerminalPgid(record.id, this.#registryPath);
|
|
1414
|
+
if (row.status !== "resolved" || row.pgid !== record.pty.pgid)
|
|
1415
|
+
return "settled";
|
|
1416
|
+
if (this.#reaper.isGroupAlive(record.pty.pgid))
|
|
1417
|
+
return "settled";
|
|
1418
|
+
const outcome = pruneNativeTerminalPgidEntry(record.id, this.#registryPath, { pgid: record.pty.pgid, groupToken: record.groupToken }, { waitForLock: options.waitForLock });
|
|
1419
|
+
if (outcome === "lock-unavailable")
|
|
1420
|
+
return "lock-unavailable";
|
|
1421
|
+
if (outcome === "pruned") {
|
|
1422
|
+
this.#log(`pruned the durable pgid row of terminal session ${record.id} (pgid=${record.pty.pgid}): ${because} and the OS reports the group empty`);
|
|
1423
|
+
}
|
|
1424
|
+
return "settled";
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
this.#log(`could not prune the durable pgid row of terminal session ${record.id} (pgid=${record.pty.pgid}): ${String(error)}`);
|
|
1428
|
+
return "settled";
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Run the pty-exit prune OFF node-pty's synchronous `onExit` callback, and
|
|
1433
|
+
* never waiting for the registry lock.
|
|
1434
|
+
*
|
|
1435
|
+
* Why both: `onExit` runs on this host's event loop, the loop that serves
|
|
1436
|
+
* every other session's I/O, and taking the registry lock costs up to
|
|
1437
|
+
* LOCK_MAX_WAIT_MS (4 s) of BUSY-WAIT when another process holds it (see
|
|
1438
|
+
* file-lock.ts). Paying that inline freezes the whole host for seconds for
|
|
1439
|
+
* what is only row hygiene. So each attempt asks for the lock ONCE and, when
|
|
1440
|
+
* it is held, retries on a timer for a bounded number of attempts before
|
|
1441
|
+
* giving up and leaving the row (a stale row is the pre-existing behaviour,
|
|
1442
|
+
* and any later pass re-derives its verdict from the group itself).
|
|
1443
|
+
*
|
|
1444
|
+
* This opens NO window in which a live tree loses its row: the decision is
|
|
1445
|
+
* made entirely at prune time, from a fresh row read, a fresh `isGroupAlive`
|
|
1446
|
+
* proof and a compare-and-delete — never from the fact that an exit was once
|
|
1447
|
+
* observed.
|
|
1448
|
+
*/
|
|
1449
|
+
#schedulePruneOfExitedSession(record, because, attempt = 1) {
|
|
1450
|
+
setTimeout(() => {
|
|
1451
|
+
const outcome = this.#pruneDurableRowIfGroupEmpty(record, because, {
|
|
1452
|
+
waitForLock: false,
|
|
1453
|
+
});
|
|
1454
|
+
if (outcome !== "lock-unavailable")
|
|
1455
|
+
return;
|
|
1456
|
+
if (attempt >= NATIVE_TERMINAL_PRUNE_MAX_ATTEMPTS) {
|
|
1457
|
+
this.#log(`giving up on pruning the durable pgid row of terminal session ${record.id} (pgid=${record.pty.pgid}) after ${attempt} attempts: the registry lock stayed held. The stale row is left for a later pass.`);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
this.#schedulePruneOfExitedSession(record, because, attempt + 1);
|
|
1461
|
+
}, attempt === 1 ? 0 : NATIVE_TERMINAL_PRUNE_RETRY_DELAY_MS);
|
|
1462
|
+
}
|
|
894
1463
|
#requireSession(id) {
|
|
895
1464
|
const record = this.#sessions.get(id);
|
|
896
1465
|
if (!record)
|
|
@@ -989,13 +1558,22 @@ export class NativeTerminalHost {
|
|
|
989
1558
|
* (unresolvable is treated exactly like alive: never guess).
|
|
990
1559
|
* - `ownerProbe` reports "dead" (pid gone, or recycled — a live pid whose
|
|
991
1560
|
* start-time no longer matches) -> reap that entry's orphan process group
|
|
992
|
-
* via `reap`, then prune the entry once the reap is CONFIRMED (`"reaped"`
|
|
993
|
-
*
|
|
994
|
-
*
|
|
1561
|
+
* via `reap`, then prune the entry once the reap is CONFIRMED (`"reaped"`)
|
|
1562
|
+
* or once the refusal itself PROVED the original group gone
|
|
1563
|
+
* (`cause: "recycled"` -> `"pruned-recycled-pgid"`; `cause: "stale-boot"`
|
|
1564
|
+
* -> `"pruned-stale-boot-row"`). A `"reap-timed-out"`, or a refusal that
|
|
1565
|
+
* leaves the group's fate unknown, keeps the row for a future pass instead
|
|
1566
|
+
* of losing the only durable record of an unconfirmed pgid.
|
|
995
1567
|
*
|
|
996
|
-
* Best-effort per entry
|
|
997
|
-
*
|
|
998
|
-
*
|
|
1568
|
+
* Best-effort per entry, and this is a SAFETY property, not politeness: every
|
|
1569
|
+
* entry's evaluation (owner probe, reap, row re-read, prune) is wrapped, so one
|
|
1570
|
+
* entry's failure never stops the pass over the rest. Each entry is an
|
|
1571
|
+
* independent host/session, and the socket-scoped containment decision a caller
|
|
1572
|
+
* derives from this pass can only be right if the pass actually reached the row
|
|
1573
|
+
* that concerns it — an abort half-way through leaves every later row
|
|
1574
|
+
* unevaluated while the caller reads one failure as a cleanup hiccup. A failure
|
|
1575
|
+
* is reported as `"reap-failed"` for that entry, WITH its attribution, and its
|
|
1576
|
+
* row is kept.
|
|
999
1577
|
*/
|
|
1000
1578
|
export async function reconcileDeadHostOrphans(options = {}) {
|
|
1001
1579
|
const signal = options.signal ?? "SIGKILL";
|
|
@@ -1015,13 +1593,58 @@ export async function reconcileDeadHostOrphans(options = {}) {
|
|
|
1015
1593
|
...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
|
|
1016
1594
|
log,
|
|
1017
1595
|
});
|
|
1018
|
-
return (sessionId, pgid, sig) => reconcileHost.reapOrphan(sessionId, sig, pgid);
|
|
1596
|
+
return (sessionId, pgid, sig, expected) => reconcileHost.reapOrphan(sessionId, sig, pgid, expected);
|
|
1019
1597
|
})();
|
|
1020
1598
|
const snapshot = listNativeTerminalPgidEntries(options.registryPath);
|
|
1021
1599
|
if (!snapshot.known) {
|
|
1022
1600
|
log(`REFUSING to reconcile native-terminal orphans: registry unreadable (${snapshot.reason}). NOTHING was reaped.`);
|
|
1023
1601
|
return { status: "refused", reason: snapshot.reason };
|
|
1024
1602
|
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Re-read ONE row from the durable store, for the `pgid-mismatch` path
|
|
1605
|
+
* below: the reap resolved a pgid different from this pass's snapshot, so
|
|
1606
|
+
* the row was rewritten (a same-id session recreated) in between.
|
|
1607
|
+
*
|
|
1608
|
+
* THREE-STATE, and the three must never be collapsed. "The store reads fine
|
|
1609
|
+
* and holds no such row" is a POSITIVE answer — nothing is owed — while "the
|
|
1610
|
+
* store could not be read at all" is an absence of information about a row
|
|
1611
|
+
* whose owner is not known to be alive and whose group was never probed.
|
|
1612
|
+
* Flattening the second into the first downgrades a blocking verdict to a
|
|
1613
|
+
* non-blocking `skipped-row-changed` and lets a caller start a host over a
|
|
1614
|
+
* group whose fate was never decided.
|
|
1615
|
+
*/
|
|
1616
|
+
const rereadEntry = (sessionId) => {
|
|
1617
|
+
const fresh = listNativeTerminalPgidEntries(options.registryPath);
|
|
1618
|
+
if (!fresh.known)
|
|
1619
|
+
return { known: false, reason: fresh.reason };
|
|
1620
|
+
return {
|
|
1621
|
+
known: true,
|
|
1622
|
+
entry: fresh.entries.find((candidate) => candidate.sessionId === sessionId),
|
|
1623
|
+
};
|
|
1624
|
+
};
|
|
1625
|
+
/**
|
|
1626
|
+
* Drop one row whose group this pass PROVED gone. Never throws: at this point
|
|
1627
|
+
* the proof already holds, so a registry that cannot be written must not turn
|
|
1628
|
+
* a confirmed containment into a failure — it leaves a stale row a later pass
|
|
1629
|
+
* re-derives the same verdict from, which is the pre-existing behaviour, not a
|
|
1630
|
+
* surviving group. Compare-and-delete (see `pruneNativeTerminalPgidEntry`): a
|
|
1631
|
+
* row rewritten for the same session id by a LIVE host is never deleted.
|
|
1632
|
+
*/
|
|
1633
|
+
const pruneProvenGoneRow = (entry) => {
|
|
1634
|
+
const { sessionId } = entry;
|
|
1635
|
+
try {
|
|
1636
|
+
const outcome = pruneNativeTerminalPgidEntry(sessionId, options.registryPath, {
|
|
1637
|
+
pgid: entry.pgid,
|
|
1638
|
+
...(entry.groupToken !== undefined ? { groupToken: entry.groupToken } : {}),
|
|
1639
|
+
});
|
|
1640
|
+
if (outcome === "kept") {
|
|
1641
|
+
log(`native-terminal session ${sessionId}: nothing was pruned — the durable row is either already gone (a concurrent pass pruned it) or no longer describes the group this pass proved gone (pgid=${entry.pgid}), i.e. a live host owns that session id now. Either way it is not ours to delete.`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
catch (error) {
|
|
1645
|
+
log(`native-terminal session ${sessionId}: could not prune the durable row of a group this pass PROVED gone (${String(error)}) — the containment verdict stands; the stale row is left for a later pass.`);
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1025
1648
|
const outcomes = [];
|
|
1026
1649
|
for (const entry of snapshot.entries) {
|
|
1027
1650
|
if (!entry.owner) {
|
|
@@ -1030,50 +1653,173 @@ export async function reconcileDeadHostOrphans(options = {}) {
|
|
|
1030
1653
|
continue;
|
|
1031
1654
|
}
|
|
1032
1655
|
const owner = entry.owner;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1656
|
+
// From here on, EVERY failure is contained to THIS entry: each entry is an
|
|
1657
|
+
// independent host/session, and a pass that aborts half-way never evaluates
|
|
1658
|
+
// the rows it had not reached — including the one that would have contained
|
|
1659
|
+
// the caller's own socket. A caller reading such a failure as a cleanup
|
|
1660
|
+
// hiccup then starts a host over a PTY group whose fate was never decided.
|
|
1661
|
+
const attribution = {
|
|
1662
|
+
ownerPid: owner.pid,
|
|
1663
|
+
...(owner.socketPath !== undefined ? { ownerSocketPath: owner.socketPath } : {}),
|
|
1664
|
+
pgid: entry.pgid,
|
|
1665
|
+
};
|
|
1666
|
+
// Whether THIS entry's owner was positively proven dead before anything
|
|
1667
|
+
// else was attempted. A failure below is contained either way, but a
|
|
1668
|
+
// diagnostic must not assert a death the probe never returned (see
|
|
1669
|
+
// `ownerDeathUnproven`).
|
|
1670
|
+
let ownerProvenDead = false;
|
|
1671
|
+
try {
|
|
1672
|
+
const verdict = ownerProbe(owner);
|
|
1673
|
+
if (verdict === "alive") {
|
|
1674
|
+
log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is still alive — reaping nothing.`);
|
|
1675
|
+
outcomes.push({ sessionId: entry.sessionId, status: "skipped-alive", ownerPid: owner.pid });
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (verdict === "unresolvable") {
|
|
1679
|
+
log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} liveness is unresolvable — treating conservatively as alive, reaping nothing.`);
|
|
1680
|
+
outcomes.push({
|
|
1681
|
+
sessionId: entry.sessionId,
|
|
1682
|
+
status: "skipped-unresolvable",
|
|
1683
|
+
ownerPid: owner.pid,
|
|
1684
|
+
});
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
// verdict === "dead": PROVEN — the owner pid is gone, or was recycled.
|
|
1688
|
+
ownerProvenDead = true;
|
|
1689
|
+
log(`native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is PROVEN DEAD — reaping its orphan process group pgid=${entry.pgid}.`);
|
|
1690
|
+
// A row that carries a token constrains the reap on pgid AND token; a
|
|
1691
|
+
// legacy row carries none and is called exactly as before.
|
|
1692
|
+
const outcome = entry.groupToken === undefined
|
|
1693
|
+
? await reap(entry.sessionId, entry.pgid, signal)
|
|
1694
|
+
: await reap(entry.sessionId, entry.pgid, signal, {
|
|
1695
|
+
groupToken: entry.groupToken,
|
|
1696
|
+
});
|
|
1697
|
+
if (outcome.status === "refused" && outcome.cause === "pgid-mismatch") {
|
|
1698
|
+
// The row was REWRITTEN between this pass's snapshot and the reap's own
|
|
1699
|
+
// lookup, so `reapOrphan` refused BEFORE probing any group — the one
|
|
1700
|
+
// refusal cause that never observed liveness at all. Blocking a caller
|
|
1701
|
+
// on it would be a verdict derived from a row that no longer exists, so
|
|
1702
|
+
// re-read the row once and decide from what it says NOW.
|
|
1703
|
+
//
|
|
1704
|
+
// RE-READ, NEVER RE-TARGET: the re-read is used only to decide whether
|
|
1705
|
+
// anything is still owed, never as the pgid to act on. Killing the group
|
|
1706
|
+
// a rewritten row now points at is exactly the TOCTOU the immutable
|
|
1707
|
+
// snapshot exists to prevent (`reapOrphan`'s `expectedPgid`), and the
|
|
1708
|
+
// functional regression "should refuse reconciliation when the fresh
|
|
1709
|
+
// PGID lookup differs from its immutable snapshot" holds that line. A
|
|
1710
|
+
// row that still names a proven-dead owner therefore keeps the caused
|
|
1711
|
+
// refusal — fail closed — and the NEXT pass snapshots the new pgid and
|
|
1712
|
+
// reaps it properly.
|
|
1713
|
+
const reread = rereadEntry(entry.sessionId);
|
|
1714
|
+
if (!reread.known) {
|
|
1715
|
+
// NOT "the row is gone": the store answered nothing at all, about a
|
|
1716
|
+
// row whose owner is proven dead and whose group was never probed.
|
|
1717
|
+
// Fail closed on this entry's own attribution, like every other
|
|
1718
|
+
// per-entry failure.
|
|
1719
|
+
throw new Error(`the durable store could not be re-read after a pgid-mismatch refusal (${reread.reason})`);
|
|
1720
|
+
}
|
|
1721
|
+
const fresh = reread.entry;
|
|
1722
|
+
if (fresh?.owner === undefined) {
|
|
1723
|
+
const reason = fresh === undefined
|
|
1724
|
+
? "the row is gone"
|
|
1725
|
+
: "the row lost its owning-host attribution";
|
|
1726
|
+
log(`native-terminal session ${entry.sessionId}: the durable row changed under this pass (${reason}) — nothing was signalled, reaping nothing.`);
|
|
1727
|
+
outcomes.push({
|
|
1728
|
+
sessionId: entry.sessionId,
|
|
1729
|
+
status: "skipped-row-changed",
|
|
1730
|
+
reason,
|
|
1731
|
+
});
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
const freshVerdict = ownerProbe(fresh.owner);
|
|
1735
|
+
if (freshVerdict !== "dead") {
|
|
1736
|
+
log(`native-terminal session ${entry.sessionId}: the durable row was rewritten under this pass and now names owning host pid=${fresh.owner.pid}, whose liveness is "${freshVerdict}" — reaping nothing.`);
|
|
1737
|
+
outcomes.push({
|
|
1738
|
+
sessionId: entry.sessionId,
|
|
1739
|
+
status: freshVerdict === "alive" ? "skipped-alive" : "skipped-unresolvable",
|
|
1740
|
+
ownerPid: fresh.owner.pid,
|
|
1741
|
+
});
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
log(`native-terminal session ${entry.sessionId}: the durable row was rewritten under this pass (snapshot pgid=${entry.pgid}, now pgid=${fresh.pgid}) and still names a PROVEN DEAD owner pid=${fresh.owner.pid} — keeping the refusal; NOTHING was signalled, and the next pass will act on the row it snapshots itself.`);
|
|
1745
|
+
}
|
|
1746
|
+
if (outcome.status === "refused" && outcome.cause === "recycled") {
|
|
1747
|
+
// POSITIVE proof that the original group is GONE, not that one
|
|
1748
|
+
// survived: the pgid number is held by an unrelated live leader of a
|
|
1749
|
+
// different start-time, which the kernel could not have issued while any
|
|
1750
|
+
// member of the original group still referenced that number. Prune the
|
|
1751
|
+
// stale row — leaving it would re-derive this same verdict on every
|
|
1752
|
+
// later pass — and never point an operator at that pgid.
|
|
1753
|
+
pruneProvenGoneRow(entry);
|
|
1754
|
+
log(`native-terminal session ${entry.sessionId}: pgid=${entry.pgid} is now held by an UNRELATED live process group leader, which PROVES the original process group of this session is gone — pruning the stale durable row. NOTHING was signalled; pgid=${entry.pgid} must NOT be killed, it does not belong to this session.`);
|
|
1755
|
+
outcomes.push({
|
|
1756
|
+
sessionId: entry.sessionId,
|
|
1757
|
+
status: "pruned-recycled-pgid",
|
|
1758
|
+
...attribution,
|
|
1759
|
+
reason: outcome.reason,
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
else if (outcome.status === "refused" && outcome.cause === "stale-boot") {
|
|
1763
|
+
// POSITIVE proof that the original group is GONE, exactly like
|
|
1764
|
+
// `recycled` and for a stronger reason: the row was written before this
|
|
1765
|
+
// boot, in this reader's own pid namespace on this same machine (the
|
|
1766
|
+
// machine id is what rules out another machine sharing this registry
|
|
1767
|
+
// — see `#verifyGroupLeaderIdentity`), and a reboot ends every
|
|
1768
|
+
// process of the boot before it. Prune the stale row — leaving it would
|
|
1769
|
+
// re-derive this same verdict on every later pass, holding the socket
|
|
1770
|
+
// until whatever unrelated process inherited the pgid NUMBER exits —
|
|
1771
|
+
// and never point an operator at that number.
|
|
1772
|
+
pruneProvenGoneRow(entry);
|
|
1773
|
+
log(`native-terminal session ${entry.sessionId}: this durable row was written under a PREVIOUS boot, which ENDED every process it describes — pruning the stale durable row. NOTHING was signalled; pgid=${entry.pgid} must NOT be killed, a live unrelated group may hold that number now.`);
|
|
1774
|
+
outcomes.push({
|
|
1775
|
+
sessionId: entry.sessionId,
|
|
1776
|
+
status: "pruned-stale-boot-row",
|
|
1777
|
+
...attribution,
|
|
1778
|
+
reason: outcome.reason,
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
else if (outcome.status === "reaped") {
|
|
1782
|
+
pruneProvenGoneRow(entry);
|
|
1783
|
+
outcomes.push({
|
|
1784
|
+
sessionId: entry.sessionId,
|
|
1785
|
+
status: "reaped",
|
|
1786
|
+
...attribution,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
else if (outcome.status === "reap-timed-out") {
|
|
1790
|
+
log(`native-terminal session ${entry.sessionId}: reap did not confirm dead within the timeout — leaving the record for a future pass.`);
|
|
1791
|
+
outcomes.push({
|
|
1792
|
+
sessionId: entry.sessionId,
|
|
1793
|
+
status: "reap-timed-out",
|
|
1794
|
+
...attribution,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
log(`native-terminal session ${entry.sessionId}: reap refused (${outcome.reason}).`);
|
|
1799
|
+
outcomes.push({
|
|
1800
|
+
sessionId: entry.sessionId,
|
|
1801
|
+
status: "reap-refused",
|
|
1802
|
+
...attribution,
|
|
1803
|
+
reason: outcome.reason,
|
|
1804
|
+
...(outcome.cause !== undefined ? { cause: outcome.cause } : {}),
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1068
1807
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1808
|
+
catch (error) {
|
|
1809
|
+
// Nothing was PROVEN about this row's group: the probe, the reap or the
|
|
1810
|
+
// re-read failed. The durable row is kept, nothing was signalled, and the
|
|
1811
|
+
// failure is reported WITH this row's attribution so a socket-scoped
|
|
1812
|
+
// caller can fail closed on exactly the socket it belongs to. When the
|
|
1813
|
+
// OWNER PROBE is what failed, the owner's own liveness is unknown too —
|
|
1814
|
+
// the containment is the same, but it is flagged so no diagnostic calls
|
|
1815
|
+
// that owner proven dead.
|
|
1816
|
+
log(`native-terminal session ${entry.sessionId}: reap FAILED before proving anything about pgid=${entry.pgid} (${String(error)})${ownerProvenDead ? "" : " — the OWNING HOST's own liveness was never proven either"} — NOTHING was signalled or confirmed, and the durable row is kept for a later pass.`);
|
|
1071
1817
|
outcomes.push({
|
|
1072
1818
|
sessionId: entry.sessionId,
|
|
1073
|
-
status: "reap-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1819
|
+
status: "reap-failed",
|
|
1820
|
+
...attribution,
|
|
1821
|
+
reason: String(error),
|
|
1822
|
+
...(ownerProvenDead ? {} : { ownerDeathUnproven: true }),
|
|
1077
1823
|
});
|
|
1078
1824
|
}
|
|
1079
1825
|
}
|