@sentropic/h2a-runtime 0.97.11 → 0.98.0

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.
Files changed (57) hide show
  1. package/dist/cli-help-groups.js +1 -1
  2. package/dist/cli-help-groups.js.map +1 -1
  3. package/dist/file-lock.d.ts +12 -0
  4. package/dist/file-lock.d.ts.map +1 -1
  5. package/dist/file-lock.js +37 -17
  6. package/dist/file-lock.js.map +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +79 -7
  9. package/dist/index.js.map +1 -1
  10. package/dist/launch-guard.d.ts +31 -0
  11. package/dist/launch-guard.d.ts.map +1 -0
  12. package/dist/launch-guard.js +135 -0
  13. package/dist/launch-guard.js.map +1 -0
  14. package/dist/native-host.d.ts +11 -4
  15. package/dist/native-host.d.ts.map +1 -1
  16. package/dist/native-host.js +19 -6
  17. package/dist/native-host.js.map +1 -1
  18. package/dist/native-terminal/client.d.ts.map +1 -1
  19. package/dist/native-terminal/client.js +7 -0
  20. package/dist/native-terminal/client.js.map +1 -1
  21. package/dist/native-terminal/host.d.ts +258 -17
  22. package/dist/native-terminal/host.d.ts.map +1 -1
  23. package/dist/native-terminal/host.js +814 -68
  24. package/dist/native-terminal/host.js.map +1 -1
  25. package/dist/native-terminal/op.d.ts.map +1 -1
  26. package/dist/native-terminal/op.js +8 -2
  27. package/dist/native-terminal/op.js.map +1 -1
  28. package/dist/native-terminal/process.d.ts.map +1 -1
  29. package/dist/native-terminal/process.js +1 -0
  30. package/dist/native-terminal/process.js.map +1 -1
  31. package/dist/native-terminal/protocol.d.ts +1 -0
  32. package/dist/native-terminal/protocol.d.ts.map +1 -1
  33. package/dist/native-terminal/protocol.js.map +1 -1
  34. package/dist/native-terminal/screen.d.ts +3 -0
  35. package/dist/native-terminal/screen.d.ts.map +1 -0
  36. package/dist/native-terminal/screen.js +18 -0
  37. package/dist/native-terminal/screen.js.map +1 -0
  38. package/dist/native-terminal/server.d.ts.map +1 -1
  39. package/dist/native-terminal/server.js +5 -0
  40. package/dist/native-terminal/server.js.map +1 -1
  41. package/dist/native-terminal/supervisor.d.ts +34 -2
  42. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  43. package/dist/native-terminal/supervisor.js +420 -42
  44. package/dist/native-terminal/supervisor.js.map +1 -1
  45. package/dist/proc-identity.d.ts +67 -0
  46. package/dist/proc-identity.d.ts.map +1 -0
  47. package/dist/proc-identity.js +106 -0
  48. package/dist/proc-identity.js.map +1 -0
  49. package/dist/prompt-delivery.d.ts +11 -1
  50. package/dist/prompt-delivery.d.ts.map +1 -1
  51. package/dist/prompt-delivery.js +62 -6
  52. package/dist/prompt-delivery.js.map +1 -1
  53. package/dist/registry.d.ts +111 -4
  54. package/dist/registry.d.ts.map +1 -1
  55. package/dist/registry.js +86 -11
  56. package/dist/registry.js.map +1 -1
  57. package/package.json +4 -3
@@ -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
- const NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS = 30_000;
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 four DISCERNIBLE outcomes of 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`. Never
354
- * collapsed: a proven kill and an unproven one must never look alike
355
- * here, exactly as a refusal and a proceed must never look alike — see
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) rides along
403
- // on the same durable write: it is what lets a LATER reconcile pass
404
- // prove THIS host is dead (not just unreachable) before ever reaping
405
- // this row — see `reconcileDeadHostOrphans`. The GROUP-LEADER's own
406
- // start-time (leader pid == pgid) and the GROUP token both ride along
407
- // the same write for the SAME reason one level down: they are what
408
- // let a LATER kill re-prove the group itself is still the one this
409
- // row was written for, not a recycled pgid — see
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
- * cannot positively prove this group's identity by any means ->
778
- * REFUSE, cause `"membership-unprovable"` (conservative
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
- * only — a `"reap-timed-out"` leaves the row for a future pass instead of
994
- * losing the only durable record of an unconfirmed pgid).
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: one entry's failure does not stop the pass over the
997
- * rest (each entry is an independent host/session; a single problem entry
998
- * must never mask the others).
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
- const verdict = ownerProbe(owner);
1034
- if (verdict === "alive") {
1035
- log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is still alive — reaping nothing.`);
1036
- outcomes.push({ sessionId: entry.sessionId, status: "skipped-alive", ownerPid: owner.pid });
1037
- continue;
1038
- }
1039
- if (verdict === "unresolvable") {
1040
- log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} liveness is unresolvable — treating conservatively as alive, reaping nothing.`);
1041
- outcomes.push({
1042
- sessionId: entry.sessionId,
1043
- status: "skipped-unresolvable",
1044
- ownerPid: owner.pid,
1045
- });
1046
- continue;
1047
- }
1048
- // verdict === "dead": PROVEN — the owner pid is gone, or was recycled.
1049
- log(`native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is PROVEN DEAD — reaping its orphan process group pgid=${entry.pgid}.`);
1050
- const outcome = await reap(entry.sessionId, entry.pgid, signal);
1051
- if (outcome.status === "reaped") {
1052
- pruneNativeTerminalPgidEntry(entry.sessionId, options.registryPath);
1053
- outcomes.push({
1054
- sessionId: entry.sessionId,
1055
- status: "reaped",
1056
- ownerPid: owner.pid,
1057
- pgid: entry.pgid,
1058
- });
1059
- }
1060
- else if (outcome.status === "reap-timed-out") {
1061
- log(`native-terminal session ${entry.sessionId}: reap did not confirm dead within the timeout — leaving the record for a future pass.`);
1062
- outcomes.push({
1063
- sessionId: entry.sessionId,
1064
- status: "reap-timed-out",
1065
- ownerPid: owner.pid,
1066
- pgid: entry.pgid,
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
- else {
1070
- log(`native-terminal session ${entry.sessionId}: reap refused (${outcome.reason}).`);
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-refused",
1074
- ownerPid: owner.pid,
1075
- pgid: entry.pgid,
1076
- reason: outcome.reason,
1819
+ status: "reap-failed",
1820
+ ...attribution,
1821
+ reason: String(error),
1822
+ ...(ownerProvenDead ? {} : { ownerDeathUnproven: true }),
1077
1823
  });
1078
1824
  }
1079
1825
  }