@sentropic/h2a-runtime 0.92.0 → 0.93.1
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/conv-guard.d.ts +6 -1
- package/dist/conv-guard.d.ts.map +1 -1
- package/dist/conv-guard.js +33 -8
- package/dist/conv-guard.js.map +1 -1
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +12 -2
- package/dist/enroll.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +743 -97
- package/dist/index.js.map +1 -1
- package/dist/launch-layout.d.ts +74 -0
- package/dist/launch-layout.d.ts.map +1 -0
- package/dist/launch-layout.js +324 -0
- package/dist/launch-layout.js.map +1 -0
- package/dist/llm-mesh.d.ts +13 -7
- package/dist/llm-mesh.d.ts.map +1 -1
- package/dist/llm-mesh.js +6 -4
- package/dist/llm-mesh.js.map +1 -1
- package/dist/native-host.d.ts +146 -0
- package/dist/native-host.d.ts.map +1 -0
- package/dist/native-host.js +456 -0
- package/dist/native-host.js.map +1 -0
- package/dist/native-terminal/host.d.ts +317 -1
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +696 -3
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts +5 -0
- package/dist/native-terminal/op.d.ts.map +1 -0
- package/dist/native-terminal/op.js +408 -0
- package/dist/native-terminal/op.js.map +1 -0
- package/dist/native-terminal/process.d.ts +2 -0
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +16 -2
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +6 -2
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +8 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +31 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +13 -0
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +73 -1
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/pty.d.ts +23 -0
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +39 -2
- package/dist/pty.js.map +1 -1
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +12 -9
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +317 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +622 -19
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +91 -32
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +226 -186
- package/dist/restore.js.map +1 -1
- package/dist/tmux.d.ts +2 -1
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +1 -1
- package/dist/tmux.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,306 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
// Durable session identity (incl. pgid) lives in the single registry store by
|
|
4
|
+
// design — one local store per the owner's durable-state direction (see the
|
|
5
|
+
// longer rationale on `persistNativeTerminalPgid`/`readNativeTerminalPgid` in
|
|
6
|
+
// registry.ts). This is NOT a layer leak; do not remove this coupling. The
|
|
7
|
+
// PTY host stays otherwise decoupled from the registry — this import exists
|
|
8
|
+
// ONLY for pgid durability across a host crash, nothing else here reads or
|
|
9
|
+
// writes registry state.
|
|
10
|
+
import { listNativeTerminalPgidEntries, persistNativeTerminalPgid, pruneNativeTerminalPgidEntry, readNativeTerminalPgid, } from "../registry.js";
|
|
2
11
|
import { TerminalReplayBuffer, } from "./replay-buffer.js";
|
|
3
12
|
import { NATIVE_TERMINAL_DEFAULT_MAX_SESSIONS, NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS, NATIVE_TERMINAL_MAX_SESSIONS, } from "./protocol.js";
|
|
13
|
+
function isErrnoException(error) {
|
|
14
|
+
return error instanceof Error && "code" in error;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Real POSIX implementation: `process.kill(-pgid, signal)` signals every
|
|
18
|
+
* process in the group in one syscall, regardless of whether any of them is
|
|
19
|
+
* stuck, in D-state, or has overwritten its own trap handlers — it asks
|
|
20
|
+
* nothing of the target. `kill(-pgid, 0)` is the standard "is anything still
|
|
21
|
+
* in this group" probe (ESRCH = empty). Not meaningful on win32 (no POSIX
|
|
22
|
+
* process groups); callers on that platform fall back to per-process kill.
|
|
23
|
+
*/
|
|
24
|
+
export const posixProcessGroupReaper = {
|
|
25
|
+
killGroup(pgid, signal) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-pgid, signal);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isErrnoException(error) && error.code === "ESRCH")
|
|
31
|
+
return;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
isGroupAlive(pgid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(-pgid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (isErrnoException(error) && error.code === "ESRCH")
|
|
42
|
+
return false;
|
|
43
|
+
// EPERM or anything else: we cannot prove death — stay conservative.
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
describeGroup(pgid) {
|
|
48
|
+
if (process.platform !== "linux") {
|
|
49
|
+
return "diagnostic unavailable on this platform";
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const members = [];
|
|
53
|
+
for (const name of readdirSync("/proc")) {
|
|
54
|
+
if (!/^\d+$/.test(name))
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(`/proc/${name}/stat`, "utf8");
|
|
58
|
+
const commandEnd = raw.lastIndexOf(")");
|
|
59
|
+
const fields = raw.slice(commandEnd + 2).split(" ");
|
|
60
|
+
if (Number(fields[2]) === pgid) {
|
|
61
|
+
members.push(`${name}(state=${fields[0]})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// The process exited mid-scan; skip it.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return members.length > 0
|
|
69
|
+
? members.join(", ")
|
|
70
|
+
: "no /proc members found (race with exit?)";
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return `diagnostic scan failed: ${String(error)}`;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
function supportsProcessGroupSignals() {
|
|
78
|
+
return process.platform !== "win32";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read a process's start-time — Linux `/proc/<pid>/stat` field 22
|
|
82
|
+
* ("starttime", clock ticks since boot) — the pid-recycling-proof
|
|
83
|
+
* discriminant used for owning-host attribution
|
|
84
|
+
* (`RegistryEntry.ownerHostPid`/`ownerHostStartTime`). An immutable property
|
|
85
|
+
* of a process for its entire lifetime: two DIFFERENT processes that ever
|
|
86
|
+
* held the same pid cannot both report the same start-time (barring a read
|
|
87
|
+
* error), which is what makes "pid matches AND start-time matches" a safe
|
|
88
|
+
* proof of "still the same process", not just "some process now owns this
|
|
89
|
+
* pid".
|
|
90
|
+
*
|
|
91
|
+
* Parsing anchors on the LAST occurrence of the two-character sequence ") "
|
|
92
|
+
* rather than a naive whitespace split: field 2 (`comm`, the executable
|
|
93
|
+
* name) is parenthesized and CAN itself contain spaces and parentheses
|
|
94
|
+
* (e.g. a process named "node (pty host)"), which would shift every
|
|
95
|
+
* subsequent field under a plain split — silently reading the wrong number
|
|
96
|
+
* "most of the time" and breaking on the one process with a weird name. That
|
|
97
|
+
* would be a confident wrong answer, worse than no discriminant.
|
|
98
|
+
*
|
|
99
|
+
* After locating the split point, man(5) proc's field 3 ("state") is
|
|
100
|
+
* `rest[0]`; field 22 ("starttime") is therefore `rest[19]`.
|
|
101
|
+
*
|
|
102
|
+
* A mandatory sanity check follows: `starttime` (in clock ticks, USER_HZ=100
|
|
103
|
+
* on Linux) divided by 100 must not exceed the system's current uptime — a
|
|
104
|
+
* process cannot have started in the future relative to boot. If that check
|
|
105
|
+
* fails, the parse is treated as shifted/malformed and this function returns
|
|
106
|
+
* undefined (never answers from a mis-parsed field) rather than guessing.
|
|
107
|
+
*
|
|
108
|
+
* Returns undefined — never throws — on any read/parse failure or on a
|
|
109
|
+
* non-Linux platform (no /proc); callers must treat "unavailable" the same
|
|
110
|
+
* conservative way `posixProcessGroupReaper.isGroupAlive` treats EPERM: as
|
|
111
|
+
* "cannot prove", never as "prove me dead".
|
|
112
|
+
*/
|
|
113
|
+
export function readProcessStartTime(pid) {
|
|
114
|
+
if (process.platform !== "linux")
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
118
|
+
const splitAt = raw.lastIndexOf(") ");
|
|
119
|
+
if (splitAt < 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
const rest = raw.slice(splitAt + 2).split(" ");
|
|
122
|
+
const starttime = Number(rest[19]);
|
|
123
|
+
if (!Number.isFinite(starttime) || starttime < 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
const uptimeRaw = readFileSync("/proc/uptime", "utf8");
|
|
126
|
+
const uptimeSeconds = Number(uptimeRaw.trim().split(/\s+/)[0]);
|
|
127
|
+
if (!Number.isFinite(uptimeSeconds) || starttime / 100 > uptimeSeconds) {
|
|
128
|
+
// Sanity check failed: refuse rather than answer from a shifted parse.
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return starttime;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Env var name the spawned tree's session token travels under (see `create()`). */
|
|
138
|
+
export const H2A_SESSION_TOKEN_ENV_VAR = "H2A_SESSION_TOKEN";
|
|
139
|
+
/**
|
|
140
|
+
* Enumerate every process currently in Linux process group `pgid` — the
|
|
141
|
+
* pid-recycling-proof discriminant of last resort when the group LEADER
|
|
142
|
+
* itself (pid==pgid) is gone and there is therefore no start-time left to
|
|
143
|
+
* re-read at all (see `#verifyGroupLeaderIdentity`'s leader-absent branch,
|
|
144
|
+
* and the ORDINARY orphan it exists for: a shell that exits normally
|
|
145
|
+
* leaving a backgrounded descendant — leader gone, group alive, no
|
|
146
|
+
* containment ever triggered). Scans `/proc` directly, matching
|
|
147
|
+
* `posixProcessGroupReaper.describeGroup`'s own parsing (field 3, the pgrp,
|
|
148
|
+
* via the same "last ')'" split `readProcessStartTime` above documents at
|
|
149
|
+
* length — a `comm` field can itself contain parens/spaces). Best-effort: a
|
|
150
|
+
* process that exits mid-scan is silently skipped, never treated as a parse
|
|
151
|
+
* failure.
|
|
152
|
+
*/
|
|
153
|
+
function listProcessGroupMemberPids(pgid) {
|
|
154
|
+
if (process.platform !== "linux")
|
|
155
|
+
return [];
|
|
156
|
+
let names;
|
|
157
|
+
try {
|
|
158
|
+
names = readdirSync("/proc");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const members = [];
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
if (!/^\d+$/.test(name))
|
|
166
|
+
continue;
|
|
167
|
+
try {
|
|
168
|
+
const raw = readFileSync(`/proc/${name}/stat`, "utf8");
|
|
169
|
+
const commandEnd = raw.lastIndexOf(")");
|
|
170
|
+
const fields = raw.slice(commandEnd + 2).split(" ");
|
|
171
|
+
if (Number(fields[2]) === pgid)
|
|
172
|
+
members.push(Number(name));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Exited mid-scan; not a member we can act on either way.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return members;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Read the session token a single process currently carries in its own
|
|
182
|
+
* environment (`H2A_SESSION_TOKEN`), by parsing `/proc/<pid>/environ`
|
|
183
|
+
* (NUL-separated `KEY=VALUE` entries — never a shell-quoted string, so no
|
|
184
|
+
* escaping to worry about). Returns undefined on ANY read/parse failure or
|
|
185
|
+
* a non-Linux platform — same "never guess" contract as `readProcessStartTime`.
|
|
186
|
+
*/
|
|
187
|
+
function readProcessSessionToken(pid) {
|
|
188
|
+
if (process.platform !== "linux")
|
|
189
|
+
return undefined;
|
|
190
|
+
try {
|
|
191
|
+
const raw = readFileSync(`/proc/${pid}/environ`, "utf8");
|
|
192
|
+
const prefix = `${H2A_SESSION_TOKEN_ENV_VAR}=`;
|
|
193
|
+
for (const entry of raw.split("\0")) {
|
|
194
|
+
if (entry.startsWith(prefix))
|
|
195
|
+
return entry.slice(prefix.length);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The group-MEMBERSHIP anchor of last resort: true iff AT LEAST ONE process
|
|
205
|
+
* currently in group `pgid` carries `expectedToken` in its own environment
|
|
206
|
+
* (see `RegistryEntry.pgidGroupToken`'s doc comment for the full rationale).
|
|
207
|
+
* Used ONLY when the group leader itself is unreadable — the rare path —
|
|
208
|
+
* never as a substitute for the fast leader-start-time check above it.
|
|
209
|
+
*
|
|
210
|
+
* DECLARED LIMITS (do not let a reviewer discover these):
|
|
211
|
+
* - `/proc/<pid>/environ` is only readable for processes owned by the SAME
|
|
212
|
+
* uid as this host process — true here (the host and every PTY tree it
|
|
213
|
+
* spawns share a uid).
|
|
214
|
+
* - A process can freely rewrite its own environ after exec; this is
|
|
215
|
+
* therefore a NON-adversarial membership proof. That is sufficient here:
|
|
216
|
+
* the threat this guards against is the CHANCE of the OS recycling a
|
|
217
|
+
* pgid to an unrelated process, not an adversary actively forging a
|
|
218
|
+
* token — an actual attacker with same-uid code execution already has
|
|
219
|
+
* far more direct ways to interfere than faking this one env var.
|
|
220
|
+
* - This enumeration runs ONLY on the leader-absent path (never on every
|
|
221
|
+
* reap), keeping it rare in practice — the fast leader-start-time check
|
|
222
|
+
* above it covers the common case.
|
|
223
|
+
*/
|
|
224
|
+
export function groupCarriesSessionToken(pgid, expectedToken) {
|
|
225
|
+
for (const pid of listProcessGroupMemberPids(pgid)) {
|
|
226
|
+
if (readProcessSessionToken(pid) === expectedToken)
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
function processExists(pid) {
|
|
232
|
+
try {
|
|
233
|
+
process.kill(pid, 0);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (isErrnoException(error) && error.code === "ESRCH")
|
|
238
|
+
return false;
|
|
239
|
+
// EPERM or anything else: the process DOES exist (we simply may not
|
|
240
|
+
// signal it) — conservative: existing, never gone.
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The real owner-host liveness probe. Two necessary conditions before
|
|
246
|
+
* declaring a host PROVEN DEAD (mirrors reapOrphan's own "prove death, don't
|
|
247
|
+
* just signal" conservatism one level up — see `reconcileDeadHostOrphans`):
|
|
248
|
+
*
|
|
249
|
+
* - the owner pid no longer exists at all -> DEAD (no ambiguity possible),
|
|
250
|
+
* - OR the pid exists but its CURRENT start-time differs from the one
|
|
251
|
+
* recorded at persistence time -> DEAD (the pid was recycled: a different
|
|
252
|
+
* process now holds it).
|
|
253
|
+
*
|
|
254
|
+
* Every other outcome is conservative-ALIVE: a pid that exists with a
|
|
255
|
+
* matching start-time is alive (possibly just unreachable — NOT proof of
|
|
256
|
+
* death); a pid that exists but whose start-time cannot be read/parsed right
|
|
257
|
+
* now is unresolvable (never guess); a pid that exists with NO recorded
|
|
258
|
+
* start-time (legacy row, or a write-time read failure) cannot be checked
|
|
259
|
+
* for recycling at all, so it stays alive rather than manufacture a "dead"
|
|
260
|
+
* verdict from missing data.
|
|
261
|
+
*
|
|
262
|
+
* This asymmetry is deliberate: the DANGEROUS error (reaping a live host)
|
|
263
|
+
* would require a live process's immutable start-time to change — which
|
|
264
|
+
* cannot happen barring a read error, itself already routed to
|
|
265
|
+
* "unresolvable". The BENIGN error (a recycled pid that coincidentally
|
|
266
|
+
* shares a start-time, or a legacy row with no recorded start-time)
|
|
267
|
+
* concludes "alive" and merely leaves an orphan uncollected — the pre-fix
|
|
268
|
+
* status quo, not a regression.
|
|
269
|
+
*/
|
|
270
|
+
export const defaultOwnerHostProbe = (owner) => {
|
|
271
|
+
if (!processExists(owner.pid))
|
|
272
|
+
return "dead";
|
|
273
|
+
if (owner.startTime === undefined)
|
|
274
|
+
return "alive";
|
|
275
|
+
const currentStartTime = readProcessStartTime(owner.pid);
|
|
276
|
+
if (currentStartTime === undefined)
|
|
277
|
+
return "unresolvable";
|
|
278
|
+
return currentStartTime === owner.startTime ? "alive" : "dead";
|
|
279
|
+
};
|
|
280
|
+
function delay(milliseconds) {
|
|
281
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
282
|
+
}
|
|
283
|
+
const NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS = 30_000;
|
|
284
|
+
const NATIVE_TERMINAL_FORCE_KILL_POLL_INTERVAL_MS = 50;
|
|
4
285
|
export class NativeTerminalHost {
|
|
5
286
|
#generation;
|
|
6
287
|
#replayBytesPerSession;
|
|
7
288
|
#maxSessions;
|
|
8
289
|
#spawner;
|
|
9
290
|
#sessions = new Map();
|
|
291
|
+
#registryPath;
|
|
292
|
+
#reaper;
|
|
293
|
+
#log;
|
|
294
|
+
#forceKillTimeoutMs;
|
|
295
|
+
#forceKillPollIntervalMs;
|
|
296
|
+
#readLeaderStartTime;
|
|
297
|
+
#findGroupMemberToken;
|
|
298
|
+
#pgidGuardCounters = {
|
|
299
|
+
recycled: 0,
|
|
300
|
+
membershipUnprovable: 0,
|
|
301
|
+
unverifiedLegacy: 0,
|
|
302
|
+
tokenVerified: 0,
|
|
303
|
+
};
|
|
10
304
|
constructor(options) {
|
|
11
305
|
if (options.generation.trim().length === 0 ||
|
|
12
306
|
options.generation.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
|
|
@@ -24,10 +318,46 @@ export class NativeTerminalHost {
|
|
|
24
318
|
this.#replayBytesPerSession = options.replayBytesPerSession;
|
|
25
319
|
this.#maxSessions = maxSessions;
|
|
26
320
|
this.#spawner = options.spawner;
|
|
321
|
+
this.#registryPath = options.registryPath;
|
|
322
|
+
this.#reaper = options.reaper ?? posixProcessGroupReaper;
|
|
323
|
+
this.#log =
|
|
324
|
+
options.log ??
|
|
325
|
+
((line) => {
|
|
326
|
+
process.stderr.write(`[h2a-pty-host] ${line}\n`);
|
|
327
|
+
});
|
|
328
|
+
this.#forceKillTimeoutMs =
|
|
329
|
+
options.forceKillTimeoutMs ?? NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS;
|
|
330
|
+
this.#forceKillPollIntervalMs =
|
|
331
|
+
options.forceKillPollIntervalMs ??
|
|
332
|
+
NATIVE_TERMINAL_FORCE_KILL_POLL_INTERVAL_MS;
|
|
333
|
+
this.#readLeaderStartTime =
|
|
334
|
+
options.readLeaderStartTime ?? readProcessStartTime;
|
|
335
|
+
this.#findGroupMemberToken =
|
|
336
|
+
options.findGroupMemberToken ?? groupCarriesSessionToken;
|
|
27
337
|
}
|
|
28
338
|
get generation() {
|
|
29
339
|
return this.#generation;
|
|
30
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* COUNTABLE per arch condition 1/2: the four DISCERNIBLE outcomes of the
|
|
343
|
+
* group-leader-identity guard, so "has this guard ever refused (and why),
|
|
344
|
+
* or ever proceeded WITHOUT the fast leader-start-time proof" is
|
|
345
|
+
* answerable later without re-deriving it from raw logs.
|
|
346
|
+
* `recycled`/`membershipUnprovable` are refusals (the kill was NOT
|
|
347
|
+
* emitted). `tokenVerified` is a PROVEN proceed via the group-membership
|
|
348
|
+
* token (the leader was absent, but a surviving member carried it) —
|
|
349
|
+
* distinct from the ordinary start-time-matched proceed, which is not
|
|
350
|
+
* counted at all (it is the expected common case). `unverifiedLegacy` is
|
|
351
|
+
* an UNPROVEN proceed (the kill WAS emitted) on a row with no baseline —
|
|
352
|
+
* 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
|
|
356
|
+
* `NativeTerminalReapOutcome`'s `cause`/`verified`.
|
|
357
|
+
*/
|
|
358
|
+
get pgidGuardCounters() {
|
|
359
|
+
return { ...this.#pgidGuardCounters };
|
|
360
|
+
}
|
|
31
361
|
create(options) {
|
|
32
362
|
if (options.id.trim().length === 0 ||
|
|
33
363
|
options.id.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
|
|
@@ -44,10 +374,56 @@ export class NativeTerminalHost {
|
|
|
44
374
|
if (this.#sessions.size >= this.#maxSessions) {
|
|
45
375
|
throw new Error(`terminal host session limit reached: ${this.#maxSessions}`);
|
|
46
376
|
}
|
|
377
|
+
// A per-session token, injected into the spawned tree's OWN environment
|
|
378
|
+
// so every descendant inherits it — the group-MEMBERSHIP anchor for the
|
|
379
|
+
// case the leader's start-time cannot cover: the leader (pid==pgid)
|
|
380
|
+
// simply gone, group still alive, no containment ever triggered (an
|
|
381
|
+
// ordinary shell that exits normally leaving a backgrounded descendant).
|
|
382
|
+
// See `groupCarriesSessionToken`/`#verifyGroupLeaderIdentity`.
|
|
383
|
+
const groupToken = randomUUID();
|
|
384
|
+
const pty = this.#spawner({
|
|
385
|
+
command: options.command,
|
|
386
|
+
args: options.args,
|
|
387
|
+
cwd: options.cwd,
|
|
388
|
+
env: { ...options.env, [H2A_SESSION_TOKEN_ENV_VAR]: groupToken },
|
|
389
|
+
cols: options.cols,
|
|
390
|
+
rows: options.rows,
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
// Persist BEFORE this session is usable: "known at creation" must
|
|
394
|
+
// survive to "known at kill time" (a fresh host after this one is
|
|
395
|
+
// killed has no other way to learn this session's pgid). A session
|
|
396
|
+
// whose pgid did not durably persist would be unreapable after a host
|
|
397
|
+
// crash — refuse to create it rather than leave an untracked child.
|
|
398
|
+
// The OWNER attribution (this host's own pid + start-time) rides along
|
|
399
|
+
// on the same durable write: it is what lets a LATER reconcile pass
|
|
400
|
+
// prove THIS host is dead (not just unreachable) before ever reaping
|
|
401
|
+
// this row — see `reconcileDeadHostOrphans`. The GROUP-LEADER's own
|
|
402
|
+
// start-time (leader pid == pgid) and the GROUP token both ride along
|
|
403
|
+
// the same write for the SAME reason one level down: they are what
|
|
404
|
+
// let a LATER kill re-prove the group itself is still the one this
|
|
405
|
+
// row was written for, not a recycled pgid — see
|
|
406
|
+
// `#verifyGroupLeaderIdentity`.
|
|
407
|
+
const ownStartTime = readProcessStartTime(process.pid);
|
|
408
|
+
const leaderStartTime = this.#readLeaderStartTime(pty.pgid);
|
|
409
|
+
persistNativeTerminalPgid(options.id, pty.pgid, this.#registryPath, {
|
|
410
|
+
pid: process.pid,
|
|
411
|
+
...(ownStartTime === undefined ? {} : { startTime: ownStartTime }),
|
|
412
|
+
}, leaderStartTime, groupToken);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
try {
|
|
416
|
+
pty.kill("SIGKILL");
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Best-effort cleanup of the untracked child we are about to refuse.
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`refusing to create terminal session ${options.id}: failed to durably persist its pgid (${String(error)})`);
|
|
422
|
+
}
|
|
47
423
|
const record = {
|
|
48
424
|
id: options.id,
|
|
49
425
|
incarnation: randomUUID(),
|
|
50
|
-
pty
|
|
426
|
+
pty,
|
|
51
427
|
replay: new TerminalReplayBuffer(this.#replayBytesPerSession),
|
|
52
428
|
status: "running",
|
|
53
429
|
exit: null,
|
|
@@ -181,7 +557,17 @@ export class NativeTerminalHost {
|
|
|
181
557
|
}
|
|
182
558
|
return this.list();
|
|
183
559
|
}
|
|
184
|
-
|
|
560
|
+
/**
|
|
561
|
+
* Force-stop every non-exited session this host currently knows about — the
|
|
562
|
+
* FORCE path. Unlike `stop()`/`stopAll()` (which go through the pty's own
|
|
563
|
+
* kill(), i.e. the SIGUSR1->trap escalation on Linux — still fine for a
|
|
564
|
+
* graceful, cooperative stop), this emits the group SIGKILL itself, from
|
|
565
|
+
* the PARENT, and WAITS to PROVE the whole group is dead before returning —
|
|
566
|
+
* it does not return "done" on the strength of having merely signalled it.
|
|
567
|
+
* A poll timeout is reported as an error (an absence of information, not a
|
|
568
|
+
* claimed success), never silently swallowed.
|
|
569
|
+
*/
|
|
570
|
+
async forceStopAll(signal = "SIGKILL") {
|
|
185
571
|
const errors = [];
|
|
186
572
|
for (const record of this.#sessions.values()) {
|
|
187
573
|
if (record.status === "exited")
|
|
@@ -192,7 +578,10 @@ export class NativeTerminalHost {
|
|
|
192
578
|
}
|
|
193
579
|
record.stopSignal = signal;
|
|
194
580
|
try {
|
|
195
|
-
record.pty.
|
|
581
|
+
const outcome = await this.#killGroupAndConfirmDead(record.pty.pgid, signal, `session ${record.id}`);
|
|
582
|
+
if (outcome.status !== "dead") {
|
|
583
|
+
errors.push(new Error(`terminal session ${record.id} pgid=${record.pty.pgid} did not confirm dead within ${this.#forceKillTimeoutMs}ms`));
|
|
584
|
+
}
|
|
196
585
|
}
|
|
197
586
|
catch (error) {
|
|
198
587
|
errors.push(error);
|
|
@@ -203,6 +592,193 @@ export class NativeTerminalHost {
|
|
|
203
592
|
}
|
|
204
593
|
return this.list();
|
|
205
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* Reap a session BY ID from its durably persisted pgid, even though THIS
|
|
597
|
+
* host has no in-memory record of it — the case a fresh host faces after
|
|
598
|
+
* the host that created the session was killed. Never guesses a pgid: if
|
|
599
|
+
* it cannot be resolved (registry unreadable, or genuinely never recorded),
|
|
600
|
+
* this signals NOTHING and returns `"refused"` after logging a LOUD
|
|
601
|
+
* diagnostic — a silent no-op here would recreate the exact
|
|
602
|
+
* invisible-orphan bug this mechanism exists to close.
|
|
603
|
+
*/
|
|
604
|
+
async reapOrphan(sessionId, signal = "SIGKILL") {
|
|
605
|
+
const lookup = readNativeTerminalPgid(sessionId, this.#registryPath);
|
|
606
|
+
if (lookup.status === "unresolved") {
|
|
607
|
+
this.#log(`REFUSING to reap terminal session ${sessionId}: pgid could not be resolved (${lookup.reason}). PROCESSES MAY HAVE SURVIVED — no group kill was issued.`);
|
|
608
|
+
return { sessionId, status: "refused", reason: lookup.reason };
|
|
609
|
+
}
|
|
610
|
+
const outcome = await this.#killGroupAndConfirmDead(lookup.pgid, signal, `orphan ${sessionId}`, {
|
|
611
|
+
sessionId,
|
|
612
|
+
persistedLeaderStartTime: lookup.leaderStartTime,
|
|
613
|
+
persistedGroupToken: lookup.groupToken,
|
|
614
|
+
});
|
|
615
|
+
if (outcome.status === "dead") {
|
|
616
|
+
return {
|
|
617
|
+
sessionId,
|
|
618
|
+
status: "reaped",
|
|
619
|
+
pgid: lookup.pgid,
|
|
620
|
+
elapsedMs: outcome.elapsedMs,
|
|
621
|
+
...(outcome.verified !== undefined ? { verified: outcome.verified } : {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (outcome.status === "timed-out") {
|
|
625
|
+
return { sessionId, status: "reap-timed-out", pgid: lookup.pgid, elapsedMs: outcome.elapsedMs };
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
sessionId,
|
|
629
|
+
status: "refused",
|
|
630
|
+
reason: `group-leader identity could not be re-proven at kill-time (${outcome.cause}) for pgid=${lookup.pgid}`,
|
|
631
|
+
cause: outcome.cause,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* INV-4 applied to the GROUP (mirrors the owning-host doctrine one level
|
|
636
|
+
* up — see `defaultOwnerHostProbe`): "known at creation" (the persisted
|
|
637
|
+
* `pgidLeaderStartTime`/`pgidGroupToken`) must be RE-PROVEN at the moment
|
|
638
|
+
* of acting, never merely inherited. POSITIVE proof only (INV-1) —
|
|
639
|
+
* ambiguity always REFUSES, never kills. In order:
|
|
640
|
+
*
|
|
641
|
+
* 1. Leader **readable**, no start-time baseline was ever persisted (a
|
|
642
|
+
* legacy row, or a write-time read failure) -> nothing to compare
|
|
643
|
+
* against -> cannot be checked for recycling at all, so this PROCEEDS
|
|
644
|
+
* rather than manufacture a refusal from missing data — the exact
|
|
645
|
+
* same asymmetry `defaultOwnerHostProbe` applies to a missing
|
|
646
|
+
* `ownerHostStartTime` (pre-fix status quo, not a regression).
|
|
647
|
+
* Cause `"unverified-legacy"`, `verified: false` — a kill taken here
|
|
648
|
+
* carries the SAME residual risk a `"recycled"` refusal exists to
|
|
649
|
+
* prevent, just un-checked; it must never be silently
|
|
650
|
+
* indistinguishable from a PROVEN match.
|
|
651
|
+
* 2. Leader **readable**, start-time **DIFFERENT** from the persisted
|
|
652
|
+
* baseline -> the OS reused `pgid` for an unrelated process since
|
|
653
|
+
* this row was written -> REFUSE, cause `"recycled"` (the defensive
|
|
654
|
+
* save: without this check, `killGroup` below would have signalled
|
|
655
|
+
* an innocent third party).
|
|
656
|
+
* 3. Leader **readable**, start-time **MATCHES** -> the group is
|
|
657
|
+
* provably the one this row was written for -> PROCEED,
|
|
658
|
+
* `verified: true`.
|
|
659
|
+
* 4. Leader **absent** (unreadable right now) -> there is no start-time
|
|
660
|
+
* left to compare at all — the ORDINARY orphan this whole mechanism
|
|
661
|
+
* exists for (a shell that exits normally leaving a backgrounded
|
|
662
|
+
* descendant: leader gone, group alive, no containment ever
|
|
663
|
+
* triggered). Falls to the group-MEMBERSHIP token instead:
|
|
664
|
+
* - no token baseline was ever persisted either (same "nothing to
|
|
665
|
+
* compare" asymmetry as branch 1) -> PROCEED, cause
|
|
666
|
+
* `"unverified-legacy"`, `verified: false`.
|
|
667
|
+
* - a persisted token exists AND `groupCarriesSessionToken` finds it
|
|
668
|
+
* on a surviving member -> POSITIVE proof of membership,
|
|
669
|
+
* independent of the leader -> PROCEED, `verified: true`, cause
|
|
670
|
+
* `"token-verified"`.
|
|
671
|
+
* - a persisted token exists and NO surviving member carries it ->
|
|
672
|
+
* cannot positively prove this group's identity by any means ->
|
|
673
|
+
* REFUSE, cause `"membership-unprovable"` (conservative
|
|
674
|
+
* refuse-and-leak: this may well be our OWN orphan, already fully
|
|
675
|
+
* dead, but we cannot tell that apart from a live unrelated group
|
|
676
|
+
* we simply cannot identify). DISTINCT from `"recycled"` in the
|
|
677
|
+
* union, its own counter, its own log — "positively someone
|
|
678
|
+
* else's" and "cannot prove it is ours" are different epistemic
|
|
679
|
+
* states.
|
|
680
|
+
*/
|
|
681
|
+
#verifyGroupLeaderIdentity(pgid, persistedLeaderStartTime, persistedGroupToken, sessionId) {
|
|
682
|
+
const currentStartTime = this.#readLeaderStartTime(pgid);
|
|
683
|
+
if (currentStartTime !== undefined) {
|
|
684
|
+
if (persistedLeaderStartTime === undefined) {
|
|
685
|
+
this.#pgidGuardCounters.unverifiedLegacy += 1;
|
|
686
|
+
this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId} WITHOUT identity proof: no leader start-time baseline was ever persisted for this row (legacy) — cannot be checked for recycling at all. This kill is UNVERIFIED, not a proven match. cause=unverified-legacy sessionId=${sessionId} pgid=${pgid}`);
|
|
687
|
+
return { proceed: true, verified: false, cause: "unverified-legacy" };
|
|
688
|
+
}
|
|
689
|
+
if (currentStartTime !== persistedLeaderStartTime) {
|
|
690
|
+
this.#pgidGuardCounters.recycled += 1;
|
|
691
|
+
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}`);
|
|
692
|
+
return { proceed: false, cause: "recycled" };
|
|
693
|
+
}
|
|
694
|
+
return { proceed: true, verified: true };
|
|
695
|
+
}
|
|
696
|
+
// Leader absent: fall to the group-carried session token — the net for
|
|
697
|
+
// an ordinary orphan whose leader is simply gone.
|
|
698
|
+
if (persistedGroupToken === undefined) {
|
|
699
|
+
this.#pgidGuardCounters.unverifiedLegacy += 1;
|
|
700
|
+
this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId} WITHOUT identity proof: the leader is unreadable AND no group-token baseline was ever persisted for this row (legacy) — cannot be checked for membership at all. This kill is UNVERIFIED, not a proven match. cause=unverified-legacy sessionId=${sessionId} pgid=${pgid}`);
|
|
701
|
+
return { proceed: true, verified: false, cause: "unverified-legacy" };
|
|
702
|
+
}
|
|
703
|
+
if (this.#findGroupMemberToken(pgid, persistedGroupToken)) {
|
|
704
|
+
this.#pgidGuardCounters.tokenVerified += 1;
|
|
705
|
+
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}`);
|
|
706
|
+
return { proceed: true, verified: true, cause: "token-verified" };
|
|
707
|
+
}
|
|
708
|
+
this.#pgidGuardCounters.membershipUnprovable += 1;
|
|
709
|
+
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}`);
|
|
710
|
+
return { proceed: false, cause: "membership-unprovable" };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Core of both `forceStopAll` and `reapOrphan`: emit the group signal from
|
|
714
|
+
* the parent, then poll `isGroupAlive` (never a fixed sleep-and-hope) until
|
|
715
|
+
* it reports the group empty or `#forceKillTimeoutMs` elapses. On timeout,
|
|
716
|
+
* capture a best-effort survivor list so the caller's diagnostic names
|
|
717
|
+
* exactly what is still alive instead of just "it didn't work".
|
|
718
|
+
*
|
|
719
|
+
* `verify`, when supplied (only `reapOrphan` supplies it — see INV-4 doc on
|
|
720
|
+
* `#verifyGroupLeaderIdentity`), re-proves the group's identity BEFORE the
|
|
721
|
+
* signal is ever emitted. `forceStopAll` never supplies it: it kills a
|
|
722
|
+
* session THIS host is still holding a live in-memory handle to, spawned
|
|
723
|
+
* in this very process — there is no "was this pgid recycled since we last
|
|
724
|
+
* looked" window to close there.
|
|
725
|
+
*/
|
|
726
|
+
async #killGroupAndConfirmDead(pgid, signal, label, verify) {
|
|
727
|
+
if (!supportsProcessGroupSignals()) {
|
|
728
|
+
// No POSIX process groups on this platform; nothing more this host can
|
|
729
|
+
// prove. Documented gap, not a silent claim of success.
|
|
730
|
+
this.#log(`skipping group-kill proof for pgid=${pgid} (${label}): process groups are not supported on ${process.platform}`);
|
|
731
|
+
return { status: "dead", elapsedMs: 0 };
|
|
732
|
+
}
|
|
733
|
+
if (!this.#reaper.isGroupAlive(pgid)) {
|
|
734
|
+
// The group-leader-identity guard exists to protect a LIVE process
|
|
735
|
+
// about to be signalled from an innocent kill. A group the OS already
|
|
736
|
+
// positively reports empty (ESRCH — the same proof-of-death this
|
|
737
|
+
// method's own poll loop below relies on) has no such live process:
|
|
738
|
+
// kill(-pgid, sig) against zero members signals nobody, recycled pgid
|
|
739
|
+
// or not. Short-circuit BEFORE the guard (and before ever emitting a
|
|
740
|
+
// signal) rather than manufacture a refusal from a target that is not
|
|
741
|
+
// there to protect — this is a positive OS-confirmed fact, not a
|
|
742
|
+
// guess, so it does not weaken INV-1.
|
|
743
|
+
this.#log(`pgid=${pgid} already confirmed empty before any signal was emitted (${label})`);
|
|
744
|
+
return { status: "dead", elapsedMs: 0 };
|
|
745
|
+
}
|
|
746
|
+
let verified;
|
|
747
|
+
if (verify) {
|
|
748
|
+
const verdict = this.#verifyGroupLeaderIdentity(pgid, verify.persistedLeaderStartTime, verify.persistedGroupToken, verify.sessionId);
|
|
749
|
+
if (!verdict.proceed) {
|
|
750
|
+
return { status: "refused", cause: verdict.cause };
|
|
751
|
+
}
|
|
752
|
+
verified = verdict.verified;
|
|
753
|
+
}
|
|
754
|
+
const start = Date.now();
|
|
755
|
+
try {
|
|
756
|
+
this.#reaper.killGroup(pgid, signal);
|
|
757
|
+
this.#log(`emitted group ${signal} pgid=${pgid} (${label})`);
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
this.#log(`failed to emit group ${signal} pgid=${pgid} (${label}): ${String(error)}`);
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
for (;;) {
|
|
764
|
+
if (!this.#reaper.isGroupAlive(pgid)) {
|
|
765
|
+
const elapsedMs = Date.now() - start;
|
|
766
|
+
this.#log(`confirmed pgid=${pgid} reaped at ${elapsedMs}ms (${label})`);
|
|
767
|
+
return {
|
|
768
|
+
status: "dead",
|
|
769
|
+
elapsedMs,
|
|
770
|
+
...(verified !== undefined ? { verified } : {}),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
const elapsedMs = Date.now() - start;
|
|
774
|
+
if (elapsedMs >= this.#forceKillTimeoutMs) {
|
|
775
|
+
const survivors = this.#reaper.describeGroup(pgid);
|
|
776
|
+
this.#log(`pgid=${pgid} STILL ALIVE at ${elapsedMs}ms (${label}): ${survivors}`);
|
|
777
|
+
return { status: "timed-out", elapsedMs };
|
|
778
|
+
}
|
|
779
|
+
await delay(this.#forceKillPollIntervalMs);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
206
782
|
#requireSession(id) {
|
|
207
783
|
const record = this.#sessions.get(id);
|
|
208
784
|
if (!record)
|
|
@@ -269,7 +845,124 @@ export class NativeTerminalHost {
|
|
|
269
845
|
latestSeq: record.replay.latestSeq,
|
|
270
846
|
exit: record.exit,
|
|
271
847
|
stopSignal: record.stopSignal,
|
|
848
|
+
controlled: record.controllerId !== null,
|
|
272
849
|
});
|
|
273
850
|
}
|
|
274
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Reap orphan PTY groups left behind by a PROVEN-DEAD host — the ONE
|
|
854
|
+
* production trigger for `NativeTerminalHost#reapOrphan`. Called at
|
|
855
|
+
* supervisor takeover (see `NativeTerminalHostSupervisor`), the moment a
|
|
856
|
+
* fresh host is about to be spawned because the previous one could not be
|
|
857
|
+
* reached: exactly the moment a stale registry might exist, and exactly the
|
|
858
|
+
* moment this must NOT be mistaken for "the old host is dead".
|
|
859
|
+
*
|
|
860
|
+
* This mirrors `reapOrphan`'s own conservatism one level up: a lost
|
|
861
|
+
* connection to a host is the UNKNOWN, not proof of death. An
|
|
862
|
+
* unreachable-but-alive host (overloaded, a saturated socket, a paused
|
|
863
|
+
* process) is ALIVE and its sessions are live work — reaping there would be
|
|
864
|
+
* a mass-kill dressed up as cleanup. So this function enumerates EVERY
|
|
865
|
+
* durably-persisted native-terminal-pty row and, for EACH ONE
|
|
866
|
+
* INDEPENDENTLY, proves whether ITS OWNING HOST (not "the host we were just
|
|
867
|
+
* talking to") is dead before touching it:
|
|
868
|
+
*
|
|
869
|
+
* - registry unresolvable (`listNativeTerminalPgidEntries` reports
|
|
870
|
+
* `known:false`) -> reap NOTHING, loud refusal, `status:"refused"`.
|
|
871
|
+
* - an entry with no owner attribution recorded (a legacy row written
|
|
872
|
+
* before this fix, or a write-time start-time-read failure) -> cannot
|
|
873
|
+
* prove death -> skip.
|
|
874
|
+
* - `ownerProbe` reports the owner "alive" or "unresolvable" -> skip
|
|
875
|
+
* (unresolvable is treated exactly like alive: never guess).
|
|
876
|
+
* - `ownerProbe` reports "dead" (pid gone, or recycled — a live pid whose
|
|
877
|
+
* start-time no longer matches) -> reap that entry's orphan process group
|
|
878
|
+
* via `reap`, then prune the entry once the reap is CONFIRMED (`"reaped"`
|
|
879
|
+
* only — a `"reap-timed-out"` leaves the row for a future pass instead of
|
|
880
|
+
* losing the only durable record of an unconfirmed pgid).
|
|
881
|
+
*
|
|
882
|
+
* Best-effort per entry: one entry's failure does not stop the pass over the
|
|
883
|
+
* rest (each entry is an independent host/session; a single problem entry
|
|
884
|
+
* must never mask the others).
|
|
885
|
+
*/
|
|
886
|
+
export async function reconcileDeadHostOrphans(options = {}) {
|
|
887
|
+
const signal = options.signal ?? "SIGKILL";
|
|
888
|
+
const ownerProbe = options.ownerProbe ?? defaultOwnerHostProbe;
|
|
889
|
+
const log = options.log ??
|
|
890
|
+
((line) => {
|
|
891
|
+
process.stderr.write(`[h2a-pty-reconcile] ${line}\n`);
|
|
892
|
+
});
|
|
893
|
+
const reap = options.reap ??
|
|
894
|
+
(() => {
|
|
895
|
+
const reconcileHost = new NativeTerminalHost({
|
|
896
|
+
generation: `reconcile-${randomUUID()}`,
|
|
897
|
+
replayBytesPerSession: 1,
|
|
898
|
+
spawner: () => {
|
|
899
|
+
throw new Error("reconcile host must never spawn a pty");
|
|
900
|
+
},
|
|
901
|
+
...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
|
|
902
|
+
log,
|
|
903
|
+
});
|
|
904
|
+
return (sessionId, _pgid, sig) => reconcileHost.reapOrphan(sessionId, sig);
|
|
905
|
+
})();
|
|
906
|
+
const snapshot = listNativeTerminalPgidEntries(options.registryPath);
|
|
907
|
+
if (!snapshot.known) {
|
|
908
|
+
log(`REFUSING to reconcile native-terminal orphans: registry unreadable (${snapshot.reason}). NOTHING was reaped.`);
|
|
909
|
+
return { status: "refused", reason: snapshot.reason };
|
|
910
|
+
}
|
|
911
|
+
const outcomes = [];
|
|
912
|
+
for (const entry of snapshot.entries) {
|
|
913
|
+
if (!entry.owner) {
|
|
914
|
+
log(`skipping native-terminal session ${entry.sessionId}: no owning-host attribution recorded (legacy row) — cannot prove death, reaping nothing.`);
|
|
915
|
+
outcomes.push({ sessionId: entry.sessionId, status: "skipped-no-owner" });
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const owner = entry.owner;
|
|
919
|
+
const verdict = ownerProbe(owner);
|
|
920
|
+
if (verdict === "alive") {
|
|
921
|
+
log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is still alive — reaping nothing.`);
|
|
922
|
+
outcomes.push({ sessionId: entry.sessionId, status: "skipped-alive", ownerPid: owner.pid });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (verdict === "unresolvable") {
|
|
926
|
+
log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} liveness is unresolvable — treating conservatively as alive, reaping nothing.`);
|
|
927
|
+
outcomes.push({
|
|
928
|
+
sessionId: entry.sessionId,
|
|
929
|
+
status: "skipped-unresolvable",
|
|
930
|
+
ownerPid: owner.pid,
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
// verdict === "dead": PROVEN — the owner pid is gone, or was recycled.
|
|
935
|
+
log(`native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is PROVEN DEAD — reaping its orphan process group pgid=${entry.pgid}.`);
|
|
936
|
+
const outcome = await reap(entry.sessionId, entry.pgid, signal);
|
|
937
|
+
if (outcome.status === "reaped") {
|
|
938
|
+
pruneNativeTerminalPgidEntry(entry.sessionId, options.registryPath);
|
|
939
|
+
outcomes.push({
|
|
940
|
+
sessionId: entry.sessionId,
|
|
941
|
+
status: "reaped",
|
|
942
|
+
ownerPid: owner.pid,
|
|
943
|
+
pgid: entry.pgid,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
else if (outcome.status === "reap-timed-out") {
|
|
947
|
+
log(`native-terminal session ${entry.sessionId}: reap did not confirm dead within the timeout — leaving the record for a future pass.`);
|
|
948
|
+
outcomes.push({
|
|
949
|
+
sessionId: entry.sessionId,
|
|
950
|
+
status: "reap-timed-out",
|
|
951
|
+
ownerPid: owner.pid,
|
|
952
|
+
pgid: entry.pgid,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
log(`native-terminal session ${entry.sessionId}: reap refused (${outcome.reason}).`);
|
|
957
|
+
outcomes.push({
|
|
958
|
+
sessionId: entry.sessionId,
|
|
959
|
+
status: "reap-refused",
|
|
960
|
+
ownerPid: owner.pid,
|
|
961
|
+
pgid: entry.pgid,
|
|
962
|
+
reason: outcome.reason,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return { status: "completed", outcomes };
|
|
967
|
+
}
|
|
275
968
|
//# sourceMappingURL=host.js.map
|