@sentropic/h2a-runtime 0.98.4 → 0.98.6
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/identity-cull/cull.d.ts.map +1 -1
- package/dist/identity-cull/cull.js +35 -89
- package/dist/identity-cull/cull.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -8
- package/dist/index.js.map +1 -1
- package/dist/llm-mesh-accounts.d.ts +15 -2
- package/dist/llm-mesh-accounts.d.ts.map +1 -1
- package/dist/llm-mesh-accounts.js +13 -3
- package/dist/llm-mesh-accounts.js.map +1 -1
- package/dist/native-host.d.ts +3 -1
- package/dist/native-host.d.ts.map +1 -1
- package/dist/native-host.js +16 -5
- package/dist/native-host.js.map +1 -1
- package/dist/native-terminal/client.d.ts +2 -2
- package/dist/native-terminal/client.d.ts.map +1 -1
- package/dist/native-terminal/client.js +2 -2
- package/dist/native-terminal/client.js.map +1 -1
- package/dist/native-terminal/crash-safe-log.d.ts +17 -0
- package/dist/native-terminal/crash-safe-log.d.ts.map +1 -0
- package/dist/native-terminal/crash-safe-log.js +79 -0
- package/dist/native-terminal/crash-safe-log.js.map +1 -0
- package/dist/native-terminal/fault-counters.d.ts +38 -0
- package/dist/native-terminal/fault-counters.d.ts.map +1 -0
- package/dist/native-terminal/fault-counters.js +46 -0
- package/dist/native-terminal/fault-counters.js.map +1 -0
- package/dist/native-terminal/host.d.ts +21 -5
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +202 -109
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/journal.d.ts +5 -1
- package/dist/native-terminal/journal.d.ts.map +1 -1
- package/dist/native-terminal/journal.js +6 -2
- package/dist/native-terminal/journal.js.map +1 -1
- package/dist/native-terminal/op.d.ts +3 -1
- package/dist/native-terminal/op.d.ts.map +1 -1
- package/dist/native-terminal/op.js +62 -42
- 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 +41 -12
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/publication-refusal.d.ts +22 -0
- package/dist/native-terminal/publication-refusal.d.ts.map +1 -0
- package/dist/native-terminal/publication-refusal.js +26 -0
- package/dist/native-terminal/publication-refusal.js.map +1 -0
- package/dist/native-terminal/server.d.ts +40 -2
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +528 -180
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +1 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +3 -3
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +69 -1
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/proc-identity.d.ts +63 -1
- package/dist/proc-identity.d.ts.map +1 -1
- package/dist/proc-identity.js +98 -1
- package/dist/proc-identity.js.map +1 -1
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +18 -0
- package/dist/pty.js.map +1 -1
- package/dist/registry.d.ts +15 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +17 -4
- package/dist/registry.js.map +1 -1
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +14 -2
- package/dist/tmux.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named fault counters for supervision. Every fault-tolerant site in the
|
|
3
|
+
* native terminal host bumps its own named counter so an operator can see
|
|
4
|
+
* WHAT is being absorbed and how often — fault containment must never be
|
|
5
|
+
* silent. The contained sites are:
|
|
6
|
+
*
|
|
7
|
+
* - the host log sink (`crash-safe-log.ts`): stderr write failures;
|
|
8
|
+
* - the pty `onData` guard (`host.ts`);
|
|
9
|
+
* - the pty `onExit` guard (`host.ts`);
|
|
10
|
+
* - the pty DSR reply write (`host.ts`);
|
|
11
|
+
* - socket connection errors and client frame destroys (`server.ts`);
|
|
12
|
+
* - the net.Server listener handler (`server.ts`), per classification;
|
|
13
|
+
* - bounded respawn abandonment (`supervisor.ts`);
|
|
14
|
+
* - the process-level diagnostic writes (`process.ts`).
|
|
15
|
+
*
|
|
16
|
+
* Counters are per-process; supervision reads them through
|
|
17
|
+
* `hostFaultCounters()` (tests, an attached debugger, a future ping op).
|
|
18
|
+
*/
|
|
19
|
+
export const HOST_FAULT_COUNTERS = {
|
|
20
|
+
// The four contractual R7.5 supervision counters (h2a_host_*), one per
|
|
21
|
+
// NAMED fault site of the spec.
|
|
22
|
+
hostSocketConnectionErrors: "h2a_host_socket_conn_errors",
|
|
23
|
+
hostClientFrameDestroys: "h2a_host_client_frame_destroys",
|
|
24
|
+
hostPtyWriteBreaks: "h2a_host_pty_write_breaks",
|
|
25
|
+
hostPtyCallbackFailures: "h2a_host_pty_callback_failures",
|
|
26
|
+
// Per-site distinct counters kept for finer supervision.
|
|
27
|
+
hostLogStderrSyncWriteFailed: "host-log.stderr-sync-write-failed",
|
|
28
|
+
hostLogStderrStreamError: "host-log.stderr-stream-error",
|
|
29
|
+
ptyDataCallbackContained: "pty.data-callback-contained",
|
|
30
|
+
ptyExitCallbackContained: "pty.exit-callback-contained",
|
|
31
|
+
serverListenerErrorRecoverable: "server.listener-error-recoverable",
|
|
32
|
+
serverListenerErrorExhausted: "server.listener-error-exhausted",
|
|
33
|
+
serverListenerErrorUnusable: "server.listener-error-unusable",
|
|
34
|
+
serverListenerErrorUnknown: "server.listener-error-unknown",
|
|
35
|
+
supervisorRespawnAbandoned: "supervisor.respawn-abandoned",
|
|
36
|
+
processShutdownWriteFailed: "process.shutdown-write-failed",
|
|
37
|
+
processFatalWriteFailed: "process.fatal-write-failed",
|
|
38
|
+
};
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
export function recordHostFault(counter) {
|
|
41
|
+
counts.set(counter, (counts.get(counter) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
export function hostFaultCounters() {
|
|
44
|
+
return Object.fromEntries(counts);
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=fault-counters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fault-counters.js","sourceRoot":"","sources":["../../src/native-terminal/fault-counters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,uEAAuE;IACvE,gCAAgC;IAChC,0BAA0B,EAAE,6BAA6B;IACzD,uBAAuB,EAAE,gCAAgC;IACzD,kBAAkB,EAAE,2BAA2B;IAC/C,uBAAuB,EAAE,gCAAgC;IACzD,yDAAyD;IACzD,4BAA4B,EAAE,mCAAmC;IACjE,wBAAwB,EAAE,8BAA8B;IACxD,wBAAwB,EAAE,6BAA6B;IACvD,wBAAwB,EAAE,6BAA6B;IACvD,8BAA8B,EAAE,mCAAmC;IACnE,4BAA4B,EAAE,iCAAiC;IAC/D,2BAA2B,EAAE,gCAAgC;IAC7D,0BAA0B,EAAE,+BAA+B;IAC3D,0BAA0B,EAAE,8BAA8B;IAC1D,0BAA0B,EAAE,+BAA+B;IAC3D,uBAAuB,EAAE,4BAA4B;CAC7C,CAAC;AAEX,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEzC,MAAM,UAAU,eAAe,CAAC,OAAe;IAC7C,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AACtD,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC"}
|
|
@@ -82,8 +82,13 @@ export declare const H2A_SESSION_TOKEN_ENV_VAR = "H2A_SESSION_TOKEN";
|
|
|
82
82
|
* - This enumeration runs ONLY on the leader-absent path (never on every
|
|
83
83
|
* reap), keeping it rare in practice — the fast leader-start-time check
|
|
84
84
|
* above it covers the common case.
|
|
85
|
+
*
|
|
86
|
+
* `procRoot` is injected ONLY by tests: a /proc-shaped view that contains
|
|
87
|
+
* exactly the processes a test started (the enumeration is the confinement
|
|
88
|
+
* boundary — the stat/environ reads stay real). Production always reads the
|
|
89
|
+
* real `/proc`.
|
|
85
90
|
*/
|
|
86
|
-
export declare function groupCarriesSessionToken(pgid: number, expectedToken: string): boolean;
|
|
91
|
+
export declare function groupCarriesSessionToken(pgid: number, expectedToken: string, procRoot?: string): boolean;
|
|
87
92
|
/**
|
|
88
93
|
* True iff two CONSECUTIVE censuses of `pgid` both positively show the SAME,
|
|
89
94
|
* non-empty set of members and every one of them is a ZOMBIE down to its last
|
|
@@ -169,6 +174,16 @@ export type NativeTerminalExit = Readonly<{
|
|
|
169
174
|
export type NativeTerminalSessionStatus = "running" | "stopping" | "exited";
|
|
170
175
|
/** The provenance of an exclusive controller, used only for human-input safety. */
|
|
171
176
|
export type NativeTerminalControllerActivity = "human" | "automation";
|
|
177
|
+
export type NativeTerminalControllerIdentity = Readonly<{
|
|
178
|
+
pid: number | null;
|
|
179
|
+
tty: string;
|
|
180
|
+
since: string;
|
|
181
|
+
}>;
|
|
182
|
+
export type NativeTerminalControllerOptions = Readonly<{
|
|
183
|
+
takeover?: boolean;
|
|
184
|
+
pid?: number;
|
|
185
|
+
tty?: string;
|
|
186
|
+
}>;
|
|
172
187
|
export type NativeTerminalSessionState = Readonly<{
|
|
173
188
|
id: string;
|
|
174
189
|
generation: string;
|
|
@@ -180,11 +195,12 @@ export type NativeTerminalSessionState = Readonly<{
|
|
|
180
195
|
stopSignal: string | null;
|
|
181
196
|
/**
|
|
182
197
|
* Whether a controller currently holds this session's exclusive input lease.
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* stays private to the host.
|
|
198
|
+
* Restore consumers need only this boolean. Attach rescue also exposes the
|
|
199
|
+
* holder's diagnostic identity and epoch, never its input lease.
|
|
186
200
|
*/
|
|
187
201
|
controlled: boolean;
|
|
202
|
+
controllerEpoch?: number;
|
|
203
|
+
controller?: NativeTerminalControllerIdentity | null;
|
|
188
204
|
/**
|
|
189
205
|
* Most recent human controller/input activity, in epoch milliseconds. A
|
|
190
206
|
* missing value means an older host did not provide the safety signal and
|
|
@@ -391,7 +407,7 @@ export declare class NativeTerminalHost {
|
|
|
391
407
|
state(id: string): NativeTerminalSessionState;
|
|
392
408
|
readOutput(id: string, afterSeq: number): NativeTerminalReplay;
|
|
393
409
|
attachObserver(id: string): NativeTerminalObserverAttachment;
|
|
394
|
-
acquireController(id: string, controllerId: string, activity?: NativeTerminalControllerActivity): NativeTerminalControllerLease;
|
|
410
|
+
acquireController(id: string, controllerId: string, activity?: NativeTerminalControllerActivity, options?: NativeTerminalControllerOptions): NativeTerminalControllerLease;
|
|
395
411
|
/**
|
|
396
412
|
* Atomically acquire automation input only when no human activity is recent.
|
|
397
413
|
* This runs entirely in the host's serialized request handler: a human
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/native-terminal/host.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/native-terminal/host.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { writeHostLogLine } from "./crash-safe-log.js";
|
|
5
|
+
import { HOST_FAULT_COUNTERS, recordHostFault } from "./fault-counters.js";
|
|
4
6
|
// Durable session identity (incl. pgid) lives in the single registry store by
|
|
5
7
|
// design — one local store per the owner's durable-state direction (see the
|
|
6
8
|
// longer rationale on `persistNativeTerminalPgid`/`readNativeTerminalPgid` in
|
|
@@ -155,12 +157,12 @@ export const H2A_SESSION_TOKEN_ENV_VAR = "H2A_SESSION_TOKEN";
|
|
|
155
157
|
* process that exits mid-scan is silently skipped, never treated as a parse
|
|
156
158
|
* failure.
|
|
157
159
|
*/
|
|
158
|
-
function listProcessGroupMemberPids(pgid) {
|
|
160
|
+
function listProcessGroupMemberPids(pgid, procRoot = "/proc") {
|
|
159
161
|
if (process.platform !== "linux")
|
|
160
162
|
return [];
|
|
161
163
|
let names;
|
|
162
164
|
try {
|
|
163
|
-
names = readdirSync(
|
|
165
|
+
names = readdirSync(procRoot);
|
|
164
166
|
}
|
|
165
167
|
catch {
|
|
166
168
|
return [];
|
|
@@ -170,7 +172,7 @@ function listProcessGroupMemberPids(pgid) {
|
|
|
170
172
|
if (!/^\d+$/.test(name))
|
|
171
173
|
continue;
|
|
172
174
|
try {
|
|
173
|
-
const raw = readFileSync(
|
|
175
|
+
const raw = readFileSync(join(procRoot, name, "stat"), "utf8");
|
|
174
176
|
const commandEnd = raw.lastIndexOf(")");
|
|
175
177
|
const fields = raw.slice(commandEnd + 2).split(" ");
|
|
176
178
|
if (Number(fields[2]) === pgid)
|
|
@@ -189,11 +191,11 @@ function listProcessGroupMemberPids(pgid) {
|
|
|
189
191
|
* escaping to worry about). Returns undefined on ANY read/parse failure or
|
|
190
192
|
* a non-Linux platform — same "never guess" contract as `readProcessStartTime`.
|
|
191
193
|
*/
|
|
192
|
-
function readProcessSessionToken(pid) {
|
|
194
|
+
function readProcessSessionToken(pid, procRoot = "/proc") {
|
|
193
195
|
if (process.platform !== "linux")
|
|
194
196
|
return undefined;
|
|
195
197
|
try {
|
|
196
|
-
const raw = readFileSync(
|
|
198
|
+
const raw = readFileSync(join(procRoot, String(pid), "environ"), "utf8");
|
|
197
199
|
const prefix = `${H2A_SESSION_TOKEN_ENV_VAR}=`;
|
|
198
200
|
for (const entry of raw.split("\0")) {
|
|
199
201
|
if (entry.startsWith(prefix))
|
|
@@ -225,10 +227,15 @@ function readProcessSessionToken(pid) {
|
|
|
225
227
|
* - This enumeration runs ONLY on the leader-absent path (never on every
|
|
226
228
|
* reap), keeping it rare in practice — the fast leader-start-time check
|
|
227
229
|
* above it covers the common case.
|
|
230
|
+
*
|
|
231
|
+
* `procRoot` is injected ONLY by tests: a /proc-shaped view that contains
|
|
232
|
+
* exactly the processes a test started (the enumeration is the confinement
|
|
233
|
+
* boundary — the stat/environ reads stay real). Production always reads the
|
|
234
|
+
* real `/proc`.
|
|
228
235
|
*/
|
|
229
|
-
export function groupCarriesSessionToken(pgid, expectedToken) {
|
|
230
|
-
for (const pid of listProcessGroupMemberPids(pgid)) {
|
|
231
|
-
if (readProcessSessionToken(pid) === expectedToken)
|
|
236
|
+
export function groupCarriesSessionToken(pgid, expectedToken, procRoot = "/proc") {
|
|
237
|
+
for (const pid of listProcessGroupMemberPids(pgid, procRoot)) {
|
|
238
|
+
if (readProcessSessionToken(pid, procRoot) === expectedToken)
|
|
232
239
|
return true;
|
|
233
240
|
}
|
|
234
241
|
return false;
|
|
@@ -587,7 +594,10 @@ export class NativeTerminalHost {
|
|
|
587
594
|
this.#log =
|
|
588
595
|
options.log ??
|
|
589
596
|
((line) => {
|
|
590
|
-
process.stderr.write
|
|
597
|
+
// The default sink must be crash-safe: a plain `process.stderr.write`
|
|
598
|
+
// here reproduces the suspected mechanism of the 2026-10-10/11
|
|
599
|
+
// crash-loop (async EPIPE, unhandled `error`, uncaughtException).
|
|
600
|
+
writeHostLogLine(line);
|
|
591
601
|
});
|
|
592
602
|
this.#forceKillTimeoutMs =
|
|
593
603
|
options.forceKillTimeoutMs ?? NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS;
|
|
@@ -719,6 +729,7 @@ export class NativeTerminalHost {
|
|
|
719
729
|
stopSignal: null,
|
|
720
730
|
controllerId: null,
|
|
721
731
|
controllerActivity: null,
|
|
732
|
+
controllerIdentity: null,
|
|
722
733
|
controllerEpoch: 0,
|
|
723
734
|
lastHumanActivityAt: null,
|
|
724
735
|
};
|
|
@@ -728,48 +739,88 @@ export class NativeTerminalHost {
|
|
|
728
739
|
let dsrTail = "";
|
|
729
740
|
const resetRelay = new TerminalResetRelay((data) => { record.replay.append(data); }, () => record.replay.resetSequence());
|
|
730
741
|
record.dataSubscription = record.pty.onData((data) => {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
742
|
+
try {
|
|
743
|
+
if (record.exit !== null || data.length === 0)
|
|
744
|
+
return;
|
|
745
|
+
resetRelay.feed(data);
|
|
746
|
+
// Answer Device Status Report cursor queries (ESC[6n) like a real
|
|
747
|
+
// terminal/tmux: TUIs that require a cursor-position report (muse
|
|
748
|
+
// aborts with "cursor position could not be read" without it).
|
|
749
|
+
const window = dsrTail + data;
|
|
750
|
+
dsrTail = window.slice(-3);
|
|
751
|
+
const queries = window.split("\x1b[6n").length - 1;
|
|
752
|
+
for (let i = 0; i < queries; i++) {
|
|
753
|
+
try {
|
|
754
|
+
record.pty.write("\x1b[1;1R");
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// R7.5 named exemption: expected transport failure, potentially
|
|
758
|
+
// high volume. Count it instead of logging every failed reply.
|
|
759
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostPtyWriteBreaks);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
// node-pty delivers this callback on the event loop shared with every
|
|
766
|
+
// other session; a faulty one must cost at most this session's output
|
|
767
|
+
// relay, never the host. Dispose the subscription so the faulty
|
|
768
|
+
// callback stops firing on every chunk.
|
|
769
|
+
recordHostFault(HOST_FAULT_COUNTERS.ptyDataCallbackContained);
|
|
770
|
+
record.dataSubscription?.dispose();
|
|
771
|
+
this.#reportSessionCallbackFailure("data", record, error);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
record.exitSubscription = record.pty.onExit((event) => {
|
|
775
|
+
try {
|
|
776
|
+
if (record.exit !== null)
|
|
777
|
+
return;
|
|
778
|
+
resetRelay.flush();
|
|
779
|
+
// A durable row exists to make this session reapable after THIS host
|
|
780
|
+
// dies. Once the group is confirmed empty the row can no longer make
|
|
781
|
+
// anything reapable — it is a stale record that a later host death would
|
|
782
|
+
// re-examine against whatever holds that pgid NUMBER by then. Drop it
|
|
783
|
+
// on POSITIVE proof only (see `#pruneDurableRowIfGroupEmpty`), and OFF
|
|
784
|
+
// this callback: this is node-pty's own synchronous exit callback on the
|
|
785
|
+
// host's event loop (see `#schedulePruneOfExitedSession`).
|
|
786
|
+
this.#schedulePruneOfExitedSession(record, "its pty exited");
|
|
787
|
+
const exit = {
|
|
788
|
+
exitCode: event.exitCode,
|
|
789
|
+
};
|
|
790
|
+
if (event.signal !== undefined)
|
|
791
|
+
exit.signal = event.signal;
|
|
792
|
+
record.exit = Object.freeze(exit);
|
|
793
|
+
record.status = "exited";
|
|
794
|
+
this.#invalidateController(record);
|
|
795
|
+
record.dataSubscription?.dispose();
|
|
796
|
+
record.exitSubscription?.dispose();
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
// Same shared event loop as the data callback; a faulty exit callback
|
|
800
|
+
// must not take the host down. Settle the record from the parts of
|
|
801
|
+
// the event that remain readable so the session cannot linger as
|
|
802
|
+
// "running" with a dead pty, then dispose both subscriptions.
|
|
803
|
+
const exit = { exitCode: -1 };
|
|
741
804
|
try {
|
|
742
|
-
|
|
805
|
+
exit.exitCode = event.exitCode;
|
|
806
|
+
if (event.signal !== undefined)
|
|
807
|
+
exit.signal = event.signal;
|
|
743
808
|
}
|
|
744
809
|
catch {
|
|
745
|
-
//
|
|
746
|
-
|
|
810
|
+
// The event's own fields are unreadable; -1 records that the exit
|
|
811
|
+
// was observed but its code was not.
|
|
812
|
+
}
|
|
813
|
+
if (record.exit === null) {
|
|
814
|
+
record.exit = Object.freeze(exit);
|
|
815
|
+
record.status = "exited";
|
|
816
|
+
this.#invalidateController(record);
|
|
747
817
|
}
|
|
818
|
+
record.dataSubscription?.dispose();
|
|
819
|
+
record.exitSubscription?.dispose();
|
|
820
|
+
recordHostFault(HOST_FAULT_COUNTERS.ptyExitCallbackContained);
|
|
821
|
+
this.#reportSessionCallbackFailure("exit", record, error);
|
|
748
822
|
}
|
|
749
823
|
});
|
|
750
|
-
record.exitSubscription = record.pty.onExit((event) => {
|
|
751
|
-
if (record.exit !== null)
|
|
752
|
-
return;
|
|
753
|
-
resetRelay.flush();
|
|
754
|
-
// A durable row exists to make this session reapable after THIS host
|
|
755
|
-
// dies. Once the group is confirmed empty the row can no longer make
|
|
756
|
-
// anything reapable — it is a stale record that a later host death would
|
|
757
|
-
// re-examine against whatever holds that pgid NUMBER by then. Drop it
|
|
758
|
-
// on POSITIVE proof only (see `#pruneDurableRowIfGroupEmpty`), and OFF
|
|
759
|
-
// this callback: this is node-pty's own synchronous exit callback on the
|
|
760
|
-
// host's event loop (see `#schedulePruneOfExitedSession`).
|
|
761
|
-
this.#schedulePruneOfExitedSession(record, "its pty exited");
|
|
762
|
-
const exit = {
|
|
763
|
-
exitCode: event.exitCode,
|
|
764
|
-
};
|
|
765
|
-
if (event.signal !== undefined)
|
|
766
|
-
exit.signal = event.signal;
|
|
767
|
-
record.exit = Object.freeze(exit);
|
|
768
|
-
record.status = "exited";
|
|
769
|
-
this.#invalidateController(record);
|
|
770
|
-
record.dataSubscription?.dispose();
|
|
771
|
-
record.exitSubscription?.dispose();
|
|
772
|
-
});
|
|
773
824
|
return this.#snapshot(record);
|
|
774
825
|
}
|
|
775
826
|
list() {
|
|
@@ -797,21 +848,31 @@ export class NativeTerminalHost {
|
|
|
797
848
|
controllerEpoch: record.controllerEpoch,
|
|
798
849
|
});
|
|
799
850
|
}
|
|
800
|
-
acquireController(id, controllerId, activity = "human") {
|
|
851
|
+
acquireController(id, controllerId, activity = "human", options = {}) {
|
|
801
852
|
const record = this.#requireControllableSession(id);
|
|
802
853
|
if (controllerId.trim().length === 0 ||
|
|
803
854
|
controllerId.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
|
|
804
855
|
throw new RangeError(`terminal controller id must contain 1-${NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS} characters`);
|
|
805
856
|
}
|
|
806
|
-
if (record.controllerId !== null) {
|
|
807
|
-
throw new Error(`terminal session already has a controller: ${id}`);
|
|
808
|
-
}
|
|
809
857
|
if (activity !== "human" && activity !== "automation") {
|
|
810
858
|
throw new TypeError("terminal controller activity must be human or automation");
|
|
811
859
|
}
|
|
860
|
+
if (options.pid !== undefined && (!Number.isSafeInteger(options.pid) || options.pid <= 0)) {
|
|
861
|
+
throw new TypeError("terminal controller pid must be a positive safe integer");
|
|
862
|
+
}
|
|
863
|
+
if (options.tty !== undefined && (options.tty.length === 0 || options.tty.length > 256 || /[\p{Cc}\p{Cf}]/u.test(options.tty))) {
|
|
864
|
+
throw new TypeError("terminal controller tty must be a printable name of 1-256 characters");
|
|
865
|
+
}
|
|
866
|
+
if (record.controllerId !== null && options.takeover !== true) {
|
|
867
|
+
const holder = record.controllerIdentity;
|
|
868
|
+
throw new Error(`terminal session already has a controller: ${id}; pid=${holder?.pid ?? "unknown"} tty=${holder?.tty ?? "unknown"} since=${holder?.since ?? "unknown"}; use h2a attach ${id.replace(/^h2a-/, "")} --takeover to detach it`);
|
|
869
|
+
}
|
|
870
|
+
// Serialized with the lease grant: old input and disconnect cleanup are
|
|
871
|
+
// fenced by the new epoch, even if the previous attach is still exiting.
|
|
812
872
|
record.controllerEpoch += 1;
|
|
813
873
|
record.controllerId = controllerId;
|
|
814
874
|
record.controllerActivity = activity;
|
|
875
|
+
record.controllerIdentity = Object.freeze({ pid: options.pid ?? null, tty: options.tty ?? "unknown", since: new Date().toISOString() });
|
|
815
876
|
if (activity === "human")
|
|
816
877
|
record.lastHumanActivityAt = Date.now();
|
|
817
878
|
return Object.freeze({
|
|
@@ -1184,66 +1245,10 @@ export class NativeTerminalHost {
|
|
|
1184
1245
|
* states.
|
|
1185
1246
|
*/
|
|
1186
1247
|
#verifyGroupLeaderIdentity(pgid, persistedLeaderStartTime, persistedGroupToken, sessionId, frame = {}) {
|
|
1187
|
-
// FRAME CHECK, before any pid-based comparison. Every proof below compares a
|
|
1188
|
-
// number persisted earlier with a number read now, and that is meaningful
|
|
1189
|
-
// only while both were taken in the same pid namespace and after the same
|
|
1190
|
-
// boot (see proc-identity.ts). A row that records a frame which does not
|
|
1191
|
-
// match this reader's is unusable in BOTH directions: a start-time mismatch
|
|
1192
|
-
// would be read as "recycled" (pruning the row and releasing the socket on a
|
|
1193
|
-
// proof that does not hold), and a coincidental MATCH would authorize a
|
|
1194
|
-
// group SIGKILL at a group this row never described. Refuse, blocking, which
|
|
1195
|
-
// is the behaviour that predates the `recycled` outcome.
|
|
1196
|
-
//
|
|
1197
|
-
// ONE mismatch is different, and is separated out below: same pid namespace,
|
|
1198
|
-
// different BOOT, same MACHINE. That is not an absence of proof, it is the
|
|
1199
|
-
// strongest proof available — a reboot ends every process of the boot
|
|
1200
|
-
// before it — so it prunes instead of blocking. It is still a refusal:
|
|
1201
|
-
// nothing is signalled, because an unrelated live group may hold the pgid
|
|
1202
|
-
// number by now.
|
|
1203
1248
|
const anchoredFrame = frame.pidNamespace !== undefined && frame.bootId !== undefined;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
// sides: a pid resolved in another namespace is a different process, and
|
|
1208
|
-
// an unknown namespace on either side is no statement at all.
|
|
1209
|
-
const sameKnownPidNamespace = frame.pidNamespace !== undefined &&
|
|
1210
|
-
current.pidNamespace !== undefined &&
|
|
1211
|
-
frame.pidNamespace === current.pidNamespace;
|
|
1212
|
-
// ...and only on ONE machine, KNOWN on both sides. The init pid
|
|
1213
|
-
// namespace has the same inode on every kernel and boot ids are random
|
|
1214
|
-
// per boot, so "same namespace, different boot" is ALSO what a row written
|
|
1215
|
-
// by another machine sharing this registry looks like — a machine whose
|
|
1216
|
-
// group may be alive right now. Only the machine id tells the two apart.
|
|
1217
|
-
const sameKnownMachine = frame.machineId !== undefined &&
|
|
1218
|
-
current.machineId !== undefined &&
|
|
1219
|
-
frame.machineId === current.machineId;
|
|
1220
|
-
const differentKnownBoot = frame.bootId !== undefined &&
|
|
1221
|
-
current.bootId !== undefined &&
|
|
1222
|
-
frame.bootId !== current.bootId;
|
|
1223
|
-
if (sameKnownPidNamespace && sameKnownMachine && differentKnownBoot) {
|
|
1224
|
-
// PROOF, not an absence of one: a reboot ends every process of the
|
|
1225
|
-
// boot before it, so every process this row describes has ended. The
|
|
1226
|
-
// pgid NUMBER may be held by an unrelated live group now, which is
|
|
1227
|
-
// exactly why nothing is signalled here and why no diagnostic may
|
|
1228
|
-
// point anyone at it.
|
|
1229
|
-
this.#pgidGuardCounters.staleBoot += 1;
|
|
1230
|
-
this.#log(`NOT signalling process group pgid=${pgid} for session ${sessionId}: this row was written at boot ${String(frame.bootId)} and this reader is at boot ${String(current.bootId)}, in the same pid namespace ${String(current.pidNamespace)} on the same machine ${String(current.machineId)} — the reboot ENDED every process this row describes, so its group is PROVEN gone. The number pgid=${pgid} may belong to an unrelated live group now and must NOT be killed. cause=stale-boot sessionId=${sessionId} pgid=${pgid}`);
|
|
1231
|
-
return { proceed: false, cause: "stale-boot" };
|
|
1232
|
-
}
|
|
1233
|
-
if (frame.pidNamespace !== current.pidNamespace ||
|
|
1234
|
-
frame.bootId !== current.bootId) {
|
|
1235
|
-
// Says WHY nothing could be proven, because the two shapes call for
|
|
1236
|
-
// different checks by whoever reads this: a boot change that only lacks
|
|
1237
|
-
// the machine proof is either a reboot of this machine or another
|
|
1238
|
-
// machine; anything else is another (or an unknown) pid space.
|
|
1239
|
-
const why = sameKnownPidNamespace && differentKnownBoot
|
|
1240
|
-
? `a different boot in the same pid namespace inode is what a previous boot of THIS machine looks like, but also what ANOTHER machine sharing this registry looks like, and the machine ids that would tell them apart are ${frame.machineId === undefined || current.machineId === undefined ? "not known on both sides" : "DIFFERENT"}`
|
|
1241
|
-
: "no pid or start-time on it can be re-proven here";
|
|
1242
|
-
this.#pgidGuardCounters.foreignFrame += 1;
|
|
1243
|
-
this.#log(`REFUSING to act on process group pgid=${pgid} for session ${sessionId}: this row records pid namespace ${String(frame.pidNamespace)}, boot ${String(frame.bootId)} and machine ${String(frame.machineId)}, but this reader is in pid namespace ${String(current.pidNamespace)} at boot ${String(current.bootId)} on machine ${String(current.machineId)} — ${why}. Inspecting pgid=${pgid} HERE proves nothing about this row. PROCESSES MAY SURVIVE UNCOLLECTED. cause=foreign-frame sessionId=${sessionId} pgid=${pgid}`);
|
|
1244
|
-
return { proceed: false, cause: "foreign-frame" };
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1249
|
+
const frameRefusal = this.#verifyGroupFrame(pgid, sessionId, frame);
|
|
1250
|
+
if (frameRefusal)
|
|
1251
|
+
return { proceed: false, cause: frameRefusal };
|
|
1247
1252
|
const currentStartTime = this.#readLeaderStartTime(pgid);
|
|
1248
1253
|
if (currentStartTime !== undefined) {
|
|
1249
1254
|
if (persistedLeaderStartTime === undefined) {
|
|
@@ -1306,6 +1311,68 @@ export class NativeTerminalHost {
|
|
|
1306
1311
|
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}`);
|
|
1307
1312
|
return { proceed: false, cause: "membership-unprovable" };
|
|
1308
1313
|
}
|
|
1314
|
+
#verifyGroupFrame(pgid, sessionId, frame) {
|
|
1315
|
+
// FRAME CHECK, before any pid-based comparison. Every proof below compares a
|
|
1316
|
+
// number persisted earlier with a number read now, and that is meaningful
|
|
1317
|
+
// only while both were taken in the same pid namespace and after the same
|
|
1318
|
+
// boot (see proc-identity.ts). A row that records a frame which does not
|
|
1319
|
+
// match this reader's is unusable in BOTH directions: a start-time mismatch
|
|
1320
|
+
// would be read as "recycled" (pruning the row and releasing the socket on a
|
|
1321
|
+
// proof that does not hold), and a coincidental MATCH would authorize a
|
|
1322
|
+
// group SIGKILL at a group this row never described. Refuse, blocking, which
|
|
1323
|
+
// is the behaviour that predates the `recycled` outcome.
|
|
1324
|
+
//
|
|
1325
|
+
// ONE mismatch is different, and is separated out below: same pid namespace,
|
|
1326
|
+
// different BOOT, same MACHINE. That is not an absence of proof, it is the
|
|
1327
|
+
// strongest proof available — a reboot ends every process of the boot
|
|
1328
|
+
// before it — so it prunes instead of blocking. It is still a refusal:
|
|
1329
|
+
// nothing is signalled, because an unrelated live group may hold the pgid
|
|
1330
|
+
// number by now.
|
|
1331
|
+
if (frame.pidNamespace !== undefined || frame.bootId !== undefined) {
|
|
1332
|
+
const current = this.#readFrame();
|
|
1333
|
+
// The boot proof is admissible only inside ONE pid space, KNOWN on both
|
|
1334
|
+
// sides: a pid resolved in another namespace is a different process, and
|
|
1335
|
+
// an unknown namespace on either side is no statement at all.
|
|
1336
|
+
const sameKnownPidNamespace = frame.pidNamespace !== undefined &&
|
|
1337
|
+
current.pidNamespace !== undefined &&
|
|
1338
|
+
frame.pidNamespace === current.pidNamespace;
|
|
1339
|
+
// ...and only on ONE machine, KNOWN on both sides. The init pid
|
|
1340
|
+
// namespace has the same inode on every kernel and boot ids are random
|
|
1341
|
+
// per boot, so "same namespace, different boot" is ALSO what a row written
|
|
1342
|
+
// by another machine sharing this registry looks like — a machine whose
|
|
1343
|
+
// group may be alive right now. Only the machine id tells the two apart.
|
|
1344
|
+
const sameKnownMachine = frame.machineId !== undefined &&
|
|
1345
|
+
current.machineId !== undefined &&
|
|
1346
|
+
frame.machineId === current.machineId;
|
|
1347
|
+
const differentKnownBoot = frame.bootId !== undefined &&
|
|
1348
|
+
current.bootId !== undefined &&
|
|
1349
|
+
frame.bootId !== current.bootId;
|
|
1350
|
+
if (sameKnownPidNamespace && sameKnownMachine && differentKnownBoot) {
|
|
1351
|
+
// PROOF, not an absence of one: a reboot ends every process of the
|
|
1352
|
+
// boot before it, so every process this row describes has ended. The
|
|
1353
|
+
// pgid NUMBER may be held by an unrelated live group now, which is
|
|
1354
|
+
// exactly why nothing is signalled here and why no diagnostic may
|
|
1355
|
+
// point anyone at it.
|
|
1356
|
+
this.#pgidGuardCounters.staleBoot += 1;
|
|
1357
|
+
this.#log(`NOT signalling process group pgid=${pgid} for session ${sessionId}: this row was written at boot ${String(frame.bootId)} and this reader is at boot ${String(current.bootId)}, in the same pid namespace ${String(current.pidNamespace)} on the same machine ${String(current.machineId)} — the reboot ENDED every process this row describes, so its group is PROVEN gone. The number pgid=${pgid} may belong to an unrelated live group now and must NOT be killed. cause=stale-boot sessionId=${sessionId} pgid=${pgid}`);
|
|
1358
|
+
return "stale-boot";
|
|
1359
|
+
}
|
|
1360
|
+
if (frame.pidNamespace !== current.pidNamespace ||
|
|
1361
|
+
frame.bootId !== current.bootId) {
|
|
1362
|
+
// Says WHY nothing could be proven, because the two shapes call for
|
|
1363
|
+
// different checks by whoever reads this: a boot change that only lacks
|
|
1364
|
+
// the machine proof is either a reboot of this machine or another
|
|
1365
|
+
// machine; anything else is another (or an unknown) pid space.
|
|
1366
|
+
const why = sameKnownPidNamespace && differentKnownBoot
|
|
1367
|
+
? `a different boot in the same pid namespace inode is what a previous boot of THIS machine looks like, but also what ANOTHER machine sharing this registry looks like, and the machine ids that would tell them apart are ${frame.machineId === undefined || current.machineId === undefined ? "not known on both sides" : "DIFFERENT"}`
|
|
1368
|
+
: "no pid or start-time on it can be re-proven here";
|
|
1369
|
+
this.#pgidGuardCounters.foreignFrame += 1;
|
|
1370
|
+
this.#log(`REFUSING to act on process group pgid=${pgid} for session ${sessionId}: this row records pid namespace ${String(frame.pidNamespace)}, boot ${String(frame.bootId)} and machine ${String(frame.machineId)}, but this reader is in pid namespace ${String(current.pidNamespace)} at boot ${String(current.bootId)} on machine ${String(current.machineId)} — ${why}. Inspecting pgid=${pgid} HERE proves nothing about this row. PROCESSES MAY SURVIVE UNCOLLECTED. cause=foreign-frame sessionId=${sessionId} pgid=${pgid}`);
|
|
1371
|
+
return "foreign-frame";
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return undefined;
|
|
1375
|
+
}
|
|
1309
1376
|
/**
|
|
1310
1377
|
* Core of both `forceStopAll` and `reapOrphan`: emit the group signal from
|
|
1311
1378
|
* the parent, then poll `isGroupAlive` (never a fixed sleep-and-hope) until
|
|
@@ -1334,6 +1401,13 @@ export class NativeTerminalHost {
|
|
|
1334
1401
|
this.#log(`skipping group-kill proof for pgid=${pgid} (${label}): process groups are not supported on ${process.platform}`);
|
|
1335
1402
|
return { status: "dead", elapsedMs: 0 };
|
|
1336
1403
|
}
|
|
1404
|
+
// A group number has meaning only in the recorded frame. Even a
|
|
1405
|
+
// signal-zero probe in another namespace may inspect an unrelated group.
|
|
1406
|
+
if (verify) {
|
|
1407
|
+
const refusal = this.#verifyGroupFrame(pgid, verify.sessionId, verify.persistedFrame);
|
|
1408
|
+
if (refusal)
|
|
1409
|
+
return { status: "refused", cause: refusal };
|
|
1410
|
+
}
|
|
1337
1411
|
if (!this.#reaper.isGroupAlive(pgid)) {
|
|
1338
1412
|
// The group-leader-identity guard exists to protect a LIVE process
|
|
1339
1413
|
// about to be signalled from an innocent kill. A group the OS already
|
|
@@ -1518,8 +1592,25 @@ export class NativeTerminalHost {
|
|
|
1518
1592
|
return;
|
|
1519
1593
|
record.controllerId = null;
|
|
1520
1594
|
record.controllerActivity = null;
|
|
1595
|
+
record.controllerIdentity = null;
|
|
1521
1596
|
record.controllerEpoch += 1;
|
|
1522
1597
|
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Report a contained pty-callback failure WITHOUT being able to throw: the
|
|
1600
|
+
* caller is the catch block that keeps the host alive, and a failing log
|
|
1601
|
+
* sink must not re-arm the very crash the guard just contained. The default
|
|
1602
|
+
* sink is itself crash-safe (`crash-safe-log.ts`); an injected sink that
|
|
1603
|
+
* throws is swallowed here for the same reason.
|
|
1604
|
+
*/
|
|
1605
|
+
#reportSessionCallbackFailure(kind, record, error) {
|
|
1606
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostPtyCallbackFailures);
|
|
1607
|
+
try {
|
|
1608
|
+
this.#log(`contained a faulty pty ${kind} callback for terminal session ${record.id} (pgid=${record.pty.pgid}): ${String(error)}`);
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
// The log sink itself is failing; containment is the priority.
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1523
1614
|
#snapshot(record) {
|
|
1524
1615
|
return Object.freeze({
|
|
1525
1616
|
id: record.id,
|
|
@@ -1531,6 +1622,8 @@ export class NativeTerminalHost {
|
|
|
1531
1622
|
exit: record.exit,
|
|
1532
1623
|
stopSignal: record.stopSignal,
|
|
1533
1624
|
controlled: record.controllerId !== null,
|
|
1625
|
+
controllerEpoch: record.controllerEpoch,
|
|
1626
|
+
controller: record.controllerIdentity,
|
|
1534
1627
|
lastHumanActivityAt: record.lastHumanActivityAt,
|
|
1535
1628
|
});
|
|
1536
1629
|
}
|