@sentropic/h2a-runtime 0.97.10 → 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.
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,7 +1,37 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  import { NativeTerminalClient } from "./client.js";
3
- import { type NativeTerminalOwnerHostProbe, type NativeTerminalReapOutcome } from "./host.js";
3
+ import { type NativeTerminalOwnerHostProbe, type NativeTerminalReapOutcome, type NativeTerminalReapRefusalCause } from "./host.js";
4
4
  import { type NativeTerminalStopSignal } from "./protocol.js";
5
+ /**
6
+ * A supervisor REFUSED to hand out, publish or adopt a host because a durable
7
+ * PTY group attributed to its socket is known to have outlived its host — the
8
+ * socket-scoped fail-closed containment verdict (see
9
+ * `NativeTerminalHostSupervisor#reconcileDeadHostOrphans`).
10
+ *
11
+ * Typed, and distinct from every startup/connection failure, because it is a
12
+ * VERDICT rather than a hiccup: a caller that retries a lost connection must
13
+ * NOT retry this. The supervisor's own retry paths rethrow it immediately
14
+ * instead of treating it as "the host is not ready yet", so it never becomes a
15
+ * reconcile storm, and it is never recorded as a spawn failure (nothing failed
16
+ * to start — starting anything is exactly what was refused).
17
+ */
18
+ export declare class NativeTerminalContainmentError extends Error {
19
+ readonly socketPath: string;
20
+ readonly sessionId: string | undefined;
21
+ readonly ownerPid: number | undefined;
22
+ readonly pgid: number | undefined;
23
+ readonly outcomeStatus: string | undefined;
24
+ /** `reapOrphan`'s refusal cause, when the verdict came from a refusal. */
25
+ readonly refusalCause: NativeTerminalReapRefusalCause | undefined;
26
+ constructor(message: string, details: {
27
+ socketPath: string;
28
+ sessionId?: string;
29
+ ownerPid?: number;
30
+ pgid?: number;
31
+ outcomeStatus?: string;
32
+ refusalCause?: NativeTerminalReapRefusalCause;
33
+ });
34
+ }
5
35
  export type NativeTerminalHostSpawn = (options: {
6
36
  socketPath: string;
7
37
  generation: string;
@@ -28,7 +58,9 @@ export declare class NativeTerminalHostSupervisor {
28
58
  ownerProbe?: NativeTerminalOwnerHostProbe;
29
59
  /** Injectable so tests never send real signals; defaults to a throwaway
30
60
  * NativeTerminalHost's real reapOrphan (see reconcileDeadHostOrphans). */
31
- reapOrphan?: (sessionId: string, pgid: number, signal: NativeTerminalStopSignal) => Promise<NativeTerminalReapOutcome>;
61
+ reapOrphan?: (sessionId: string, pgid: number, signal: NativeTerminalStopSignal, expected?: {
62
+ groupToken?: string;
63
+ }) => Promise<NativeTerminalReapOutcome>;
32
64
  /** Diagnostic sink for the orphan-reconcile pass; defaults to prefixed stderr. */
33
65
  log?: (line: string) => void;
34
66
  });
@@ -1 +1 @@
1
- {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../../src/native-terminal/supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAGL,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAC/B,MAAM,WAAW,CAAC;AACnB,OAAO,EAQL,KAAK,wBAAwB,EAC9B,MAAM,eAAe,CAAC;AAQvB,MAAM,MAAM,uBAAuB,GAAG,CAAC,OAAO,EAAE;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,KAAK,YAAY,CAAC;AA8FnB,qBAAa,4BAA4B;;gBAwC3B,OAAO,EAAE;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,SAAS,CAAC,EAAE,uBAAuB,CAAC;QACpC,iBAAiB,CAAC,EAAE,MAAM,MAAM,CAAC;QACjC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;QACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,0FAA0F;QAC1F,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,gFAAgF;QAChF,UAAU,CAAC,EAAE,4BAA4B,CAAC;QAC1C;kFAC0E;QAC1E,UAAU,CAAC,EAAE,CACX,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,wBAAwB,KAC7B,OAAO,CAAC,yBAAyB,CAAC,CAAC;QACxC,kFAAkF;QAClF,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;KAC9B;IA4DD,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAGnC;IAEK,MAAM,IAAI,OAAO,CAAC,oBAAoB,CAAC;IAiB7C,UAAU,IAAI,IAAI;CAyQnB"}
1
+ {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../../src/native-terminal/supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAM9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAGL,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAC9B,KAAK,8BAA8B,EAEpC,MAAM,WAAW,CAAC;AACnB,OAAO,EAQL,KAAK,wBAAwB,EAC9B,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;GAYG;AACH,qBAAa,8BAA+B,SAAQ,KAAK;IACvD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,8BAA8B,GAAG,SAAS,CAAC;gBAGhE,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,YAAY,CAAC,EAAE,8BAA8B,CAAC;KAC/C;CAWJ;AA6HD,MAAM,MAAM,uBAAuB,GAAG,CAAC,OAAO,EAAE;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,KAAK,YAAY,CAAC;AA8FnB,qBAAa,4BAA4B;;gBA0C3B,OAAO,EAAE;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,SAAS,CAAC,EAAE,uBAAuB,CAAC;QACpC,iBAAiB,CAAC,EAAE,MAAM,MAAM,CAAC;QACjC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;QACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,0FAA0F;QAC1F,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,gFAAgF;QAChF,UAAU,CAAC,EAAE,4BAA4B,CAAC;QAC1C;kFAC0E;QAC1E,UAAU,CAAC,EAAE,CACX,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,wBAAwB,EAChC,QAAQ,CAAC,EAAE;YAAE,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,KAC/B,OAAO,CAAC,yBAAyB,CAAC,CAAC;QACxC,kFAAkF;QAClF,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;KAC9B;IAoED,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAGnC;IAEK,MAAM,IAAI,OAAO,CAAC,oBAAoB,CAAC;IAiB7C,UAAU,IAAI,IAAI;CAmgBnB"}
@@ -1,10 +1,141 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { once } from "node:events";
4
+ import { resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { NativeTerminalClient } from "./client.js";
6
7
  import { defaultOwnerHostProbe, reconcileDeadHostOrphans, } from "./host.js";
7
8
  import { NATIVE_TERMINAL_DEFAULT_MAX_SESSIONS, NATIVE_TERMINAL_DEFAULT_REQUEST_TIMEOUT_MS, NATIVE_TERMINAL_HEALTH_TIMEOUT_MS, NATIVE_TERMINAL_MAX_REPLAY_BYTES_PER_SESSION, NATIVE_TERMINAL_MAX_SESSIONS, NATIVE_TERMINAL_PROTOCOL_VERSION, } from "./protocol.js";
9
+ /**
10
+ * A supervisor REFUSED to hand out, publish or adopt a host because a durable
11
+ * PTY group attributed to its socket is known to have outlived its host — the
12
+ * socket-scoped fail-closed containment verdict (see
13
+ * `NativeTerminalHostSupervisor#reconcileDeadHostOrphans`).
14
+ *
15
+ * Typed, and distinct from every startup/connection failure, because it is a
16
+ * VERDICT rather than a hiccup: a caller that retries a lost connection must
17
+ * NOT retry this. The supervisor's own retry paths rethrow it immediately
18
+ * instead of treating it as "the host is not ready yet", so it never becomes a
19
+ * reconcile storm, and it is never recorded as a spawn failure (nothing failed
20
+ * to start — starting anything is exactly what was refused).
21
+ */
22
+ export class NativeTerminalContainmentError extends Error {
23
+ socketPath;
24
+ sessionId;
25
+ ownerPid;
26
+ pgid;
27
+ outcomeStatus;
28
+ /** `reapOrphan`'s refusal cause, when the verdict came from a refusal. */
29
+ refusalCause;
30
+ constructor(message, details) {
31
+ super(message);
32
+ this.name = "NativeTerminalContainmentError";
33
+ this.socketPath = details.socketPath;
34
+ this.sessionId = details.sessionId;
35
+ this.ownerPid = details.ownerPid;
36
+ this.pgid = details.pgid;
37
+ this.outcomeStatus = details.outcomeStatus;
38
+ this.refusalCause = details.refusalCause;
39
+ }
40
+ }
41
+ /**
42
+ * Whether a reconcile outcome discharges the proof THIS supervisor owes for a
43
+ * host it force-killed itself. Deliberately stricter than the socket-scoped
44
+ * rule below: on this path, anything short of "the group is gone" keeps failing
45
+ * closed — an absence of information included — because the obligation exists
46
+ * precisely because we destroyed the only process that could have cleaned up.
47
+ *
48
+ * Exactly two kinds of outcome end it: a confirmed reap, and a refusal whose
49
+ * cause PROVED the original group gone — a recycled pgid number (reported by
50
+ * reconcile as `pruned-recycled-pgid`) or a row written under a previous boot
51
+ * of this same machine (`pruned-stale-boot-row`); see
52
+ * `NativeTerminalReconcileOutcome`. The two raw refusals are accepted too, as
53
+ * defence in depth for a reap that returns them directly.
54
+ */
55
+ function dischargesOwnedHostProof(outcome) {
56
+ return (outcome.status === "reaped" ||
57
+ outcome.status === "pruned-recycled-pgid" ||
58
+ outcome.status === "pruned-stale-boot-row" ||
59
+ (outcome.status === "reap-refused" &&
60
+ (outcome.cause === "recycled" || outcome.cause === "stale-boot")));
61
+ }
62
+ /**
63
+ * Whether a `reap-refused` cause is positive evidence that a PTY group
64
+ * outlived its host and is STILL ALIVE. `reapOrphan` short-circuits to
65
+ * `reaped` whenever the OS already reports the group empty, so:
66
+ *
67
+ * - `membership-unprovable` / `unsupported-process-groups` — the group was
68
+ * observed alive and could not be proven to be (or killed as) this
69
+ * session's: blocking evidence.
70
+ * - `pgid-mismatch` — taken BEFORE any liveness probe, and only after
71
+ * reconcile re-read the row once (see `reconcileDeadHostOrphans`); it
72
+ * survives that re-read only while the row keeps being rewritten under a
73
+ * proven-dead owner, which is not a state to hand a terminal out in.
74
+ * - `recycled` — the OPPOSITE of a survivor: the pgid number is held by an
75
+ * unrelated live leader, which proves the original group is gone. Reconcile
76
+ * already reports it as `pruned-recycled-pgid`; this stays defence in depth
77
+ * for an injected or legacy reap that returns the refusal directly.
78
+ * - `stale-boot` — the OPPOSITE of a survivor too, and for a stronger reason:
79
+ * the row was written before this boot, in this reader's own pid namespace
80
+ * on this same machine, and a reboot ends every process of the boot before
81
+ * it. Reconcile reports it as `pruned-stale-boot-row`; same defence in
82
+ * depth as `recycled`.
83
+ * - `foreign-frame` — the pgid number answered ALIVE here, but the row was
84
+ * written in a frame this reader cannot prove is its own (another or an
85
+ * unknown pid namespace, or another boot of a machine not proven to be this
86
+ * one — e.g. another machine sharing this registry). Not a proof of a
87
+ * survivor, but not a proof of anything else either: the row's own group
88
+ * may be alive where it was written, and this reader cannot decide it, so
89
+ * it blocks — the fail-closed side of an undecidable verdict.
90
+ * - NO cause — the pgid could not be resolved at all, which is what the loser
91
+ * of a race between two reconcile passes sees once the winner confirmed the
92
+ * reap and pruned the row. An absence of information, not a survivor.
93
+ */
94
+ function provesASurvivingGroup(cause) {
95
+ return (cause === "membership-unprovable" ||
96
+ cause === "foreign-frame" ||
97
+ cause === "unsupported-process-groups" ||
98
+ cause === "pgid-mismatch");
99
+ }
100
+ /**
101
+ * Cause-specific recovery line carried by a containment verdict. NEVER tells
102
+ * an operator to kill a pgid that was not proven to belong to the dead host:
103
+ * `membership-unprovable` is precisely the state where that group may be an
104
+ * unrelated process, so it asks for inspection first. Every hint also names
105
+ * how the block lifts on its own (a later pass finding the group empty), so a
106
+ * refusal is never a dead end.
107
+ */
108
+ function recoveryHint(outcome) {
109
+ const inspect = `Inspect it first: ps -o pid,pgid,stat,args -g ${outcome.pgid}.`;
110
+ const lifts = `The block lifts as soon as any later pass finds pgid=${outcome.pgid} empty and prunes the durable row for session ${outcome.sessionId}.`;
111
+ /** The one recovery that never waits on a third party: drop the row itself. */
112
+ const byHand = `If inspection shows pgid=${outcome.pgid} is NOT this session's PTY tree, do not wait for that unrelated process to exit: remove the durable row for session ${outcome.sessionId} from the registry by hand (its id is "native-terminal-pty:${outcome.sessionId}").`;
113
+ if (outcome.status === "reap-failed") {
114
+ const owner = outcome.ownerDeathUnproven === true
115
+ ? `The OWNING HOST's own liveness was never proven either (its probe failed), so this row's owner is not known to be dead.`
116
+ : `The owning host is proven dead.`;
117
+ return `The evaluation of this row FAILED before proving anything (the durable store or a probe failed), so the group was never identified and nothing was signalled: it may be alive. ${owner} Fix the durable store first, then retry. ${inspect} ${lifts}`;
118
+ }
119
+ if (outcome.status === "reap-timed-out") {
120
+ return `The group WAS proven to be this session's PTY tree and did not die within the force-kill timeout (an unkillable or uninterruptible-sleep member). ${inspect} ${lifts}`;
121
+ }
122
+ switch (outcome.cause) {
123
+ case "membership-unprovable":
124
+ return `The group at pgid=${outcome.pgid} is alive but could NOT be proven to be this session's PTY tree, so it may belong to an unrelated process: do NOT signal it on the strength of this message. ${inspect} ${byHand} ${lifts}`;
125
+ case "foreign-frame":
126
+ // No `inspect`/`byHand` here: both rest on a local `ps`, and this row's
127
+ // pids name a DIFFERENT process space than this reader's, so a local
128
+ // "that is not our tree" would lead to deleting the only record of a
129
+ // group that may be alive where the row was written.
130
+ return `The durable row for session ${outcome.sessionId} was written in a different pid namespace, boot or machine than this reader's (its pgidPidNamespace/pgidBootId/pgidMachineId fields name it), so whatever answers at pgid=${outcome.pgid} HERE cannot be identified as that row's group and nothing was signalled: a local ps of it proves nothing either way, and it must NOT be signalled. Check the group from the machine and pid namespace that wrote the row; remove the row (id "native-terminal-pty:${outcome.sessionId}") by hand only once that frame no longer exists or its group is confirmed gone there. ${lifts}`;
131
+ case "pgid-mismatch":
132
+ return `The durable row for session ${outcome.sessionId} is being rewritten while its owner is proven dead, so no group was identified and nothing was signalled. Retry once the writer settles; ${lifts}`;
133
+ case "unsupported-process-groups":
134
+ return `This platform has no POSIX process groups, so neither a group kill nor a group-empty proof exists here; nothing was signalled.`;
135
+ default:
136
+ return `Nothing was signalled. ${inspect} ${lifts}`;
137
+ }
138
+ }
8
139
  const NATIVE_TERMINAL_SPAWN_BACKOFF_BASE_MS = 250;
9
140
  const NATIVE_TERMINAL_SPAWN_BACKOFF_MAX_MS = 5_000;
10
141
  const NATIVE_TERMINAL_STARTUP_TIMEOUT_MS = 5_000;
@@ -112,10 +243,11 @@ export class NativeTerminalHostSupervisor {
112
243
  #lastSpawnGeneration;
113
244
  // A health-checked host that has disappeared may have left a PTY guardian
114
245
  // (or, after that guardian's own parent-death race, its reparented
115
- // descendants) behind. Keep its identity until the next takeover has
116
- // positively reaped every durable group it owned. Clearing #spawned alone
117
- // would discard the fact that this is containment work rather than an
118
- // ordinary best-effort stale-registry sweep.
246
+ // descendants) behind. Keep its identity until #containDeadOwnedHost has
247
+ // positively reaped every durable group it owned — NOT merely until the
248
+ // next takeover: an adoption is just as much a hand-out of this socket.
249
+ // Clearing #spawned alone would discard the fact that this is containment
250
+ // work rather than an ordinary best-effort stale-registry sweep.
119
251
  #pendingDeadOwnedHostPid;
120
252
  constructor(options) {
121
253
  if (!Number.isSafeInteger(options.replayBytesPerSession)
@@ -144,7 +276,15 @@ export class NativeTerminalHostSupervisor {
144
276
  throw new RangeError(`${label} must be a positive safe integer`);
145
277
  }
146
278
  }
147
- this.#socketPath = options.socketPath;
279
+ // NORMALIZED once, here, because this string is not only a connect target
280
+ // but also the IDENTITY a durable row is attributed to (see the
281
+ // socket-scoped rule in #reconcileDeadHostOrphans). Two spellings of the
282
+ // same socket — a doubled separator, a "." segment, a relative path —
283
+ // would otherwise compare unequal and fail OPEN. The host normalizes the
284
+ // spelling it records the same way (see NativeTerminalHost), and the
285
+ // comparison normalizes the row's value too, so a row written by an older
286
+ // host is still attributed correctly.
287
+ this.#socketPath = resolve(options.socketPath);
148
288
  this.#generationFactory = options.generationFactory ?? randomUUID;
149
289
  this.#replayBytesPerSession = options.replayBytesPerSession;
150
290
  this.#maxSessions = maxSessions;
@@ -192,15 +332,22 @@ export class NativeTerminalHostSupervisor {
192
332
  const connected = await connectHealthy(this.#socketPath, this.#requestTimeoutMs);
193
333
  return await this.#adoptHealthyConnection(connected);
194
334
  }
195
- catch {
196
- this.#clearGoneSpawn();
197
- if (this.#pendingDeadOwnedHostPid !== undefined) {
198
- const ownerPid = this.#pendingDeadOwnedHostPid;
199
- await this.#reconcileDeadHostOrphans({
200
- requireReapedForOwnerPid: ownerPid,
201
- });
202
- this.#pendingDeadOwnedHostPid = undefined;
203
- }
335
+ catch (error) {
336
+ // A containment verdict is NOT a connection failure. The adoption above
337
+ // can reach one, and treating it as "nothing answered" would start
338
+ // takeover — which, on a pass that no longer carries blocking evidence,
339
+ // spawns a replacement host beside the perfectly healthy one whose
340
+ // adoption was just refused. Surface the verdict instead; the caller owes
341
+ // a proof, not a retry.
342
+ if (error instanceof NativeTerminalContainmentError)
343
+ throw error;
344
+ // TAKEOVER preflight. A lost connection is the UNKNOWN, never proof
345
+ // that the previous host died, so this pass reaps only entries whose
346
+ // owning host is independently PROVEN dead. It runs BEFORE the backoff
347
+ // and generation checks because containment is not restart work: a
348
+ // backed-off caller must still not be told "come back later" while one
349
+ // of this socket's proven-dead owners is unconfirmed.
350
+ await this.#containDeadOwnedHost();
204
351
  if (!this.#spawned || this.#spawned.exitCode !== null || this.#spawned.signalCode !== null) {
205
352
  const now = this.#now();
206
353
  if (now < this.#nextSpawnAllowedAt) {
@@ -215,15 +362,11 @@ export class NativeTerminalHostSupervisor {
215
362
  throw new Error("native terminal host generation must change after a restart");
216
363
  }
217
364
  this.#lastSpawnGeneration = generation;
218
- // TAKEOVER point: we are about to spawn a REPLACEMENT host because
219
- // the previous connection attempt failed and no live owned spawn
220
- // exists. This is the moment a stale registry might exist — and
221
- // exactly the moment a lost connection must NOT be mistaken for
222
- // proof of death. Reconcile FIRST: reap only entries whose owning
223
- // host is independently PROVEN dead (see reconcileDeadHostOrphans);
224
- // best-effort — a reconcile failure must never block spawning the
225
- // replacement host.
226
- await this.#reconcileDeadHostOrphans();
365
+ // We are about to spawn a REPLACEMENT host. The containment pass
366
+ // above has already run — and, had it found an unconfirmed
367
+ // proven-dead owner of THIS socket, has already thrown — so reaching
368
+ // this line is the positive statement "no durable group attributed to
369
+ // this socket is known to have survived its dead owner".
227
370
  this.#spawnError = undefined;
228
371
  this.#spawnDiagnostic = "";
229
372
  this.#spawned = this.#spawnHost({
@@ -254,6 +397,16 @@ export class NativeTerminalHostSupervisor {
254
397
  return await this.#adoptHealthyConnection(connected);
255
398
  }
256
399
  catch (error) {
400
+ // Same discrimination as the takeover preflight above, and the reason
401
+ // this loop must make it: retrying a containment verdict runs one
402
+ // reconcile pass per iteration (a pass that reaps a live group can take
403
+ // the whole force-kill timeout), then reports the verdict as a startup
404
+ // failure and reaps a host that is healthy. Surface it now — and abandon
405
+ // an owned launch that never became usable, rather than leak it.
406
+ if (error instanceof NativeTerminalContainmentError) {
407
+ await this.#abandonUnhealthyOwnedSpawn();
408
+ throw error;
409
+ }
257
410
  lastError = error;
258
411
  }
259
412
  if (this.#spawnError) {
@@ -262,6 +415,18 @@ export class NativeTerminalHostSupervisor {
262
415
  throw error;
263
416
  }
264
417
  if (this.#spawned?.exitCode !== null || this.#spawned.signalCode !== null) {
418
+ if (this.#spawnedReachedHealth) {
419
+ // This host completed a health handshake before it died, so it is
420
+ // not a failed replacement. It exited after #clearGoneSpawn's
421
+ // first observation; complete its required containment pass before
422
+ // starting takeover again.
423
+ this.#clearGoneSpawn();
424
+ if (this.#pendingDeadOwnedHostPid === undefined) {
425
+ throw new Error("known healthy native terminal host exited without a pid");
426
+ }
427
+ await this.#containDeadOwnedHost();
428
+ return this.#connectOrStart();
429
+ }
265
430
  const diagnostic = this.#spawnDiagnostic.trim();
266
431
  const error = new Error(`native terminal host exited before accepting connections (${this.#spawned?.exitCode ?? this.#spawned?.signalCode ?? "unknown"})${diagnostic ? `: ${diagnostic}` : ""}`);
267
432
  this.#recordSpawnFailure(error);
@@ -276,6 +441,12 @@ export class NativeTerminalHostSupervisor {
276
441
  await this.#terminateOwnedSpawn(spawned);
277
442
  }
278
443
  catch (terminationError) {
444
+ // A containment verdict raised while reaping this child outranks the
445
+ // readiness failure: it names the session, pgid and owner an operator
446
+ // needs, and it must not be recorded as one more startup failure.
447
+ if (terminationError instanceof NativeTerminalContainmentError) {
448
+ throw terminationError;
449
+ }
279
450
  const combined = new Error(`${error.message}; failed to reap owned child: ${String(terminationError)}`);
280
451
  this.#recordSpawnFailure(combined);
281
452
  throw combined;
@@ -299,16 +470,61 @@ export class NativeTerminalHostSupervisor {
299
470
  }
300
471
  catch (error) {
301
472
  connected.client.close();
473
+ // Reaping the loser can itself hit the containment gate (a forced kill
474
+ // requires the proof). That verdict must reach the caller as itself:
475
+ // wrapped in an adoption message it would look like a retryable
476
+ // adoption hiccup and be retried.
477
+ if (error instanceof NativeTerminalContainmentError)
478
+ throw error;
302
479
  throw new Error(`native terminal host adoption could not reap losing owned child: ${String(error)}`);
303
480
  }
304
481
  }
305
- else if (spawned && childExited(spawned) && this.#spawned === spawned) {
306
- this.#spawned = undefined;
307
- this.#spawnedReachedHealth = false;
482
+ // A healthy connection is the LAST gate before a caller can use this
483
+ // socket again, and it is reached by two paths that both bypass the
484
+ // takeover preflight: this host may be a replacement a COMPETING
485
+ // supervisor published while our own health-checked child was still
486
+ // alive, and our child may have died only afterwards. Containment
487
+ // therefore runs here too, on the same helper — an owned child that has
488
+ // already exited is never silently discarded (that would drop the only
489
+ // attribution turning the next pass into required containment), and a
490
+ // rejected containment closes this candidate rather than hand out a
491
+ // working terminal over an unreaped PTY group.
492
+ try {
493
+ await this.#containDeadOwnedHost();
494
+ }
495
+ catch (error) {
496
+ connected.client.close();
497
+ throw error;
308
498
  }
309
499
  this.#resetSpawnBackoff();
310
500
  return connected.client;
311
501
  }
502
+ /**
503
+ * Give up on an owned spawn that never completed a health handshake, because
504
+ * this call is about to reject instead of waiting for it: a child nobody will
505
+ * ever connect to must not be left behind.
506
+ *
507
+ * A child that DID reach health is deliberately left alone. It is a published,
508
+ * healthy host serving this socket — not the thing a containment verdict
509
+ * objects to — and reaping it would only turn one containment obligation into
510
+ * two (its own durable rows would then have a dead owner) while destroying a
511
+ * host the socket can reuse the moment the block lifts. Nothing is handed out
512
+ * either way: the verdict still rejects.
513
+ *
514
+ * Termination failures are logged, never substituted for the verdict that is
515
+ * about to be thrown.
516
+ */
517
+ async #abandonUnhealthyOwnedSpawn() {
518
+ const spawned = this.#spawned;
519
+ if (!spawned || childExited(spawned) || this.#spawnedReachedHealth)
520
+ return;
521
+ try {
522
+ await this.#terminateOwnedSpawn(spawned);
523
+ }
524
+ catch (error) {
525
+ this.#log(`failed to reap the owned native-terminal host abandoned on a containment refusal: ${String(error)}`);
526
+ }
527
+ }
312
528
  async #terminateOwnedSpawn(spawned) {
313
529
  if (this.#spawned !== spawned)
314
530
  return;
@@ -342,9 +558,57 @@ export class NativeTerminalHostSupervisor {
342
558
  if (spawned.pid === undefined) {
343
559
  throw new Error("forced native-terminal host reap cannot identify the killed host pid");
344
560
  }
345
- await this.#reconcileDeadHostOrphans({
346
- requireReapedForOwnerPid: spawned.pid,
347
- });
561
+ // Record the pid BEFORE requiring its reap: if this pass cannot confirm
562
+ // it, the obligation must outlive this call instead of dying with the
563
+ // local variable, so a later connection re-requires the same proof.
564
+ this.#pendingDeadOwnedHostPid ??= spawned.pid;
565
+ await this.#containDeadOwnedHost();
566
+ }
567
+ }
568
+ /**
569
+ * The ONE containment gate. Every path that can hand a client to a caller —
570
+ * takeover preflight, the readiness loop's healthy-host-died branch, and
571
+ * the adoption of any healthy connection — goes through this helper rather
572
+ * than through its own copy of the rule, because the defect it closes was
573
+ * exactly a path that consulted `#pendingDeadOwnedHostPid` too late (or not
574
+ * at all).
575
+ *
576
+ * Two obligations, both discharged by the single reconcile pass below:
577
+ *
578
+ * - OUR OWN dead host: `#clearGoneSpawn` records the pid of a child that
579
+ * completed a health handshake and has since disappeared. That pid is a
580
+ * REQUIREMENT, not a hint: the pass must report `reaped` for every row
581
+ * it owns, and the pending pid is cleared ONLY after that succeeds — so
582
+ * a refused or timed-out reap keeps failing closed on every later call
583
+ * instead of being forgotten by the first one.
584
+ * - ANY proven-dead owner of THIS socket, whoever started it (see
585
+ * `#reconcileDeadHostOrphans`). A competing supervisor's host is not our
586
+ * child, so no pending pid exists for it, yet handing out a terminal on
587
+ * this socket while its PTY group is known to have outlived its host is
588
+ * the same containment failure.
589
+ *
590
+ * One pass per connection HAND-OUT, not per request: within one supervisor,
591
+ * `client()` returns its cached client after a plain ping, so this runs when
592
+ * a connection is newly established — startup, and each host replacement.
593
+ * Production, however, builds a FRESH supervisor per `create` /
594
+ * `ensure-host` op process (see op.ts), so every such op runs one full pass,
595
+ * including the destructive reaps of other sockets' proven-dead rows. The
596
+ * cost is O(durable rows) per op, which is why the in-memory
597
+ * `#pendingDeadOwnedHostPid` cannot be the durable half of the rule: it
598
+ * never outlives one op process, and the socket-scoped verdict re-derived
599
+ * from the store is what holds across invocations.
600
+ */
601
+ async #containDeadOwnedHost() {
602
+ this.#clearGoneSpawn();
603
+ const ownerPid = this.#pendingDeadOwnedHostPid;
604
+ await this.#reconcileDeadHostOrphans(ownerPid === undefined ? {} : { requireReapedForOwnerPid: ownerPid });
605
+ // Discharge exactly the obligation this pass proved. `#clearGoneSpawn` is
606
+ // also reachable synchronously from the `spawnedPid` getter, so an owned
607
+ // host that died DURING the await above can have recorded a new pending
608
+ // pid that nothing has reaped yet; a blanket clear here would silently
609
+ // drop it.
610
+ if (this.#pendingDeadOwnedHostPid === ownerPid) {
611
+ this.#pendingDeadOwnedHostPid = undefined;
348
612
  }
349
613
  }
350
614
  #clearGoneSpawn() {
@@ -358,12 +622,63 @@ export class NativeTerminalHostSupervisor {
358
622
  }
359
623
  /**
360
624
  * Run one `reconcileDeadHostOrphans` pass against the shared registry
361
- * (see host.ts for the full contract). At the normal takeover point this
362
- * stays best-effort: a reconcile hiccup must never prevent spawning the
363
- * replacement host that takeover already needs. After this supervisor has
625
+ * (see host.ts for the full contract). A reconcile HICCUP — the registry
626
+ * unreadable, the pass itself throwing — stays best-effort: an absence of
627
+ * information must never prevent spawning the replacement host that
628
+ * takeover already needs. What follows is about the opposite, a completed
629
+ * pass that returned positive evidence. After this supervisor has
364
630
  * forcibly SIGKILLed its OWN host, though, an unconfirmed row belonging to
365
631
  * that host is a containment failure, not a cleanup hiccup; surface it to
366
632
  * the caller rather than claim forced reaping completed.
633
+ *
634
+ * SOCKET-SCOPED FAIL-CLOSED PUBLICATION. The same is true one step wider,
635
+ * and this is what makes the guarantee hold ACROSS supervisors rather than
636
+ * only inside the one that happened to own the dead host. A completed pass
637
+ * that PROVED an owner dead and then reported an outcome carrying positive
638
+ * evidence that the PTY group is STILL ALIVE (`reap-timed-out`, or a
639
+ * `reap-refused` whose cause `provesASurvivingGroup` — see that predicate
640
+ * for the per-cause reasoning), or an outcome that proves NOTHING AT ALL
641
+ * about that group (`reap-failed`: this row's own evaluation threw, so the
642
+ * group was never identified and the row is still there), must not be
643
+ * followed by a hand-out:
644
+ * publishing a replacement host, or adopting a competitor's, on that socket
645
+ * would resume terminal work over that group — the invisible-orphan bug this
646
+ * whole mechanism exists to close. So any such outcome whose row is
647
+ * attributed to THIS socket throws a `NativeTerminalContainmentError`,
648
+ * whether or not the dead owner was ever this supervisor's child.
649
+ *
650
+ * The excluded outcomes are excluded because they carry no such evidence,
651
+ * and the difference is never guessed:
652
+ * - a CAUSE-LESS `reap-refused` means only that the pgid could not be
653
+ * resolved, which is exactly what the loser of a race between two
654
+ * reconcile passes sees once the winner confirmed the reap and pruned the
655
+ * row. Treating that as a surviving group would convert a successful
656
+ * concurrent containment into an outage. Note the contrast with
657
+ * `reap-failed`, which is NOT excluded: there the row is still in the
658
+ * store and its evaluation failed outright, so nothing explains the
659
+ * missing proof;
660
+ * - `recycled` PROVES the original group is gone rather than surviving (an
661
+ * unrelated live leader now holds that pgid number), so reconcile prunes
662
+ * the row and reports `pruned-recycled-pgid`. Blocking there would strand
663
+ * a socket on a stale row AND point an operator at an unrelated process
664
+ * group.
665
+ *
666
+ * Two limits are deliberate and declared rather than papered over:
667
+ * - A row with no `ownerSocketPath` (written before that attribution
668
+ * existed) cannot be proven to belong to this socket, so it keeps the
669
+ * pre-existing best-effort behaviour instead of blocking every socket in
670
+ * the store — the same "never manufacture a verdict from missing data"
671
+ * asymmetry `defaultOwnerHostProbe` and `#verifyGroupLeaderIdentity`
672
+ * already apply. Containment for legacy rows is therefore `partial`.
673
+ * - The block is not self-clearing: it lifts when the group is actually
674
+ * reaped (by any supervisor's later pass) and the row is pruned, and
675
+ * until then this socket has NO native terminal. That is the intended
676
+ * trade — availability for an unkillable or unidentifiable PTY tree is
677
+ * exactly what must not be silently granted — and every refusal names
678
+ * the session, pgid and owner pid needed to resolve it by hand, plus a
679
+ * cause-specific recovery line (`recoveryHint`) that asks an operator to
680
+ * INSPECT a group before acting whenever that group was not proven to be
681
+ * the dead host's.
367
682
  */
368
683
  async #reconcileDeadHostOrphans(options = {}) {
369
684
  let summary;
@@ -377,20 +692,83 @@ export class NativeTerminalHostSupervisor {
377
692
  }
378
693
  catch (error) {
379
694
  this.#log(`native-terminal orphan reconcile failed: ${String(error)}`);
380
- if (options.requireReapedForOwnerPid !== undefined)
695
+ if (options.requireReapedForOwnerPid === undefined)
696
+ return;
697
+ // The pass itself failed while this supervisor owed a specific proof for
698
+ // a host IT killed. That is a containment failure, not a hiccup — and it
699
+ // must be TYPED: a raw error here reaches the readiness loop as "the host
700
+ // is not ready yet" and is retried every poll interval until the
701
+ // deadline (the reconcile-storm shape), instead of being surfaced once.
702
+ // Defence in depth: `reconcileDeadHostOrphans` now contains every
703
+ // per-entry failure itself, so this path is not reachable through any
704
+ // injected seam.
705
+ if (error instanceof NativeTerminalContainmentError)
381
706
  throw error;
382
- return;
707
+ throw new NativeTerminalContainmentError(`forced native-terminal host reap could not complete its durable PTY inspection: ${String(error)}`, {
708
+ socketPath: this.#socketPath,
709
+ ownerPid: options.requireReapedForOwnerPid,
710
+ outcomeStatus: "reconcile-threw",
711
+ });
383
712
  }
384
- if (options.requireReapedForOwnerPid === undefined)
385
- return;
386
713
  if (summary.status === "refused") {
387
- throw new Error(`forced native-terminal host reap could not inspect durable PTY groups: ${summary.reason}`);
714
+ // An unreadable registry is an absence of information, not evidence of
715
+ // a surviving group: it stays best-effort unless this supervisor owes a
716
+ // specific proof for its own killed host.
717
+ if (options.requireReapedForOwnerPid === undefined)
718
+ return;
719
+ throw new NativeTerminalContainmentError(`forced native-terminal host reap could not inspect durable PTY groups: ${summary.reason}`, {
720
+ socketPath: this.#socketPath,
721
+ ownerPid: options.requireReapedForOwnerPid,
722
+ outcomeStatus: "registry-unreadable",
723
+ });
388
724
  }
389
- const unconfirmed = summary.outcomes.find((outcome) => "ownerPid" in outcome &&
390
- outcome.ownerPid === options.requireReapedForOwnerPid &&
391
- outcome.status !== "reaped");
392
- if (unconfirmed !== undefined) {
393
- throw new Error(`forced native-terminal host reap did not confirm owner pid=${options.requireReapedForOwnerPid} session ${unconfirmed.sessionId}: ${unconfirmed.status}`);
725
+ if (options.requireReapedForOwnerPid !== undefined) {
726
+ const unconfirmed = summary.outcomes.find((outcome) => "ownerPid" in outcome &&
727
+ outcome.ownerPid === options.requireReapedForOwnerPid &&
728
+ !dischargesOwnedHostProof(outcome));
729
+ if (unconfirmed !== undefined) {
730
+ throw new NativeTerminalContainmentError(`forced native-terminal host reap did not confirm owner pid=${options.requireReapedForOwnerPid} session ${unconfirmed.sessionId}: ${unconfirmed.status}`, {
731
+ socketPath: this.#socketPath,
732
+ sessionId: unconfirmed.sessionId,
733
+ outcomeStatus: unconfirmed.status,
734
+ ownerPid: options.requireReapedForOwnerPid,
735
+ ...("pgid" in unconfirmed ? { pgid: unconfirmed.pgid } : {}),
736
+ ...("cause" in unconfirmed && unconfirmed.cause !== undefined
737
+ ? { refusalCause: unconfirmed.cause }
738
+ : {}),
739
+ });
740
+ }
741
+ }
742
+ // Checked after the owner-specific proof above so this supervisor's own
743
+ // killed host keeps its precise diagnostic; this one covers every OTHER
744
+ // proven-dead owner of the same socket.
745
+ for (const outcome of summary.outcomes) {
746
+ if (outcome.status !== "reap-refused" &&
747
+ outcome.status !== "reap-timed-out" &&
748
+ outcome.status !== "reap-failed")
749
+ continue;
750
+ if (outcome.ownerSocketPath === undefined ||
751
+ resolve(outcome.ownerSocketPath) !== this.#socketPath)
752
+ continue;
753
+ if (outcome.status === "reap-refused" && !provesASurvivingGroup(outcome.cause)) {
754
+ continue;
755
+ }
756
+ // A `reap-failed` taken before the owner probe answered proves nothing
757
+ // about the owner either — the containment is identical, but the message
758
+ // must not assert a death nobody proved.
759
+ const ownership = outcome.status === "reap-failed" && outcome.ownerDeathUnproven === true
760
+ ? `owned by host pid=${outcome.ownerPid} (whose own death was never proven)`
761
+ : `owned by proven-dead host pid=${outcome.ownerPid}`;
762
+ throw new NativeTerminalContainmentError(`native terminal socket ${this.#socketPath} is contained: session ${outcome.sessionId} pgid=${outcome.pgid} ${ownership} was not confirmed reaped (${outcome.status}${outcome.status === "reap-refused" && outcome.cause !== undefined ? `/${outcome.cause}` : ""}); refusing to start or adopt a host over a surviving PTY group. ${recoveryHint(outcome)}`, {
763
+ socketPath: this.#socketPath,
764
+ sessionId: outcome.sessionId,
765
+ outcomeStatus: outcome.status,
766
+ ownerPid: outcome.ownerPid,
767
+ pgid: outcome.pgid,
768
+ ...(outcome.status === "reap-refused" && outcome.cause !== undefined
769
+ ? { refusalCause: outcome.cause }
770
+ : {}),
771
+ });
394
772
  }
395
773
  }
396
774
  #recordSpawnFailure(error) {