@sentropic/h2a-runtime 0.97.11 → 0.97.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-help-groups.js +1 -1
- package/dist/cli-help-groups.js.map +1 -1
- package/dist/file-lock.d.ts +12 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +37 -17
- package/dist/file-lock.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -7
- package/dist/index.js.map +1 -1
- package/dist/launch-guard.d.ts +31 -0
- package/dist/launch-guard.d.ts.map +1 -0
- package/dist/launch-guard.js +135 -0
- package/dist/launch-guard.js.map +1 -0
- package/dist/native-host.d.ts +11 -4
- package/dist/native-host.d.ts.map +1 -1
- package/dist/native-host.js +19 -6
- package/dist/native-host.js.map +1 -1
- package/dist/native-terminal/client.d.ts.map +1 -1
- package/dist/native-terminal/client.js +7 -0
- package/dist/native-terminal/client.js.map +1 -1
- package/dist/native-terminal/host.d.ts +258 -17
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +814 -68
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts.map +1 -1
- package/dist/native-terminal/op.js +8 -2
- package/dist/native-terminal/op.js.map +1 -1
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +1 -0
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/protocol.d.ts +1 -0
- package/dist/native-terminal/protocol.d.ts.map +1 -1
- package/dist/native-terminal/protocol.js.map +1 -1
- package/dist/native-terminal/screen.d.ts +3 -0
- package/dist/native-terminal/screen.d.ts.map +1 -0
- package/dist/native-terminal/screen.js +18 -0
- package/dist/native-terminal/screen.js.map +1 -0
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +5 -0
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +34 -2
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +420 -42
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/proc-identity.d.ts +67 -0
- package/dist/proc-identity.d.ts.map +1 -0
- package/dist/proc-identity.js +106 -0
- package/dist/proc-identity.js.map +1 -0
- package/dist/prompt-delivery.d.ts +11 -1
- package/dist/prompt-delivery.d.ts.map +1 -1
- package/dist/prompt-delivery.js +62 -6
- package/dist/prompt-delivery.js.map +1 -1
- package/dist/registry.d.ts +111 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -11
- package/dist/registry.js.map +1 -1
- package/package.json +3 -2
|
@@ -1,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
|
|
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;
|
|
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
|
|
116
|
-
// positively reaped every durable group it owned
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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).
|
|
362
|
-
* stays best-effort:
|
|
363
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
outcome
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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) {
|