@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
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { fstatSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { chmod, link, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { createConnection, createServer } from "node:net";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { writeHostLogLine } from "./crash-safe-log.js";
|
|
7
|
+
import { HOST_FAULT_COUNTERS, recordHostFault } from "./fault-counters.js";
|
|
6
8
|
import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from "./host.js";
|
|
7
9
|
import { NATIVE_TERMINAL_MAX_FRAME_BYTES, NATIVE_TERMINAL_MAX_CONNECTIONS, NATIVE_TERMINAL_MAX_ERROR_MESSAGE_CHARS, NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS, NATIVE_TERMINAL_MAX_PENDING_RESPONSE_BYTES_PER_CONNECTION, NATIVE_TERMINAL_MAX_PENDING_RESPONSE_BYTES_TOTAL, NATIVE_TERMINAL_MAX_PENDING_RESPONSES_PER_CONNECTION, NATIVE_TERMINAL_PROTOCOL_VERSION, isNativeTerminalStopSignal, isRecord, parseNativeTerminalRequest, } from "./protocol.js";
|
|
8
|
-
import { assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
|
|
10
|
+
import { NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES, assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
|
|
9
11
|
import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
|
|
10
|
-
import {
|
|
12
|
+
import { classifyDurableProcFrame, readProcessFrame, } from "../proc-identity.js";
|
|
13
|
+
import { NativeTerminalPublicationRefusal } from "./publication-refusal.js";
|
|
11
14
|
const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
|
|
12
15
|
const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
|
|
13
16
|
const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
|
|
@@ -167,7 +170,14 @@ function dispatch(host, context, request) {
|
|
|
167
170
|
return host.attachObserver(sessionId(params));
|
|
168
171
|
case "acquire-controller": {
|
|
169
172
|
const record = requiredRecord(params, "params");
|
|
170
|
-
|
|
173
|
+
if (record.takeover !== undefined && typeof record.takeover !== "boolean") {
|
|
174
|
+
throw new TypeError("terminal controller takeover must be a boolean");
|
|
175
|
+
}
|
|
176
|
+
const lease = host.acquireController(requiredIdentifier(record.id, "session id"), requiredIdentifier(record.controllerId, "controller id"), controllerActivity(record.activity), {
|
|
177
|
+
...(record.takeover !== undefined ? { takeover: record.takeover } : {}),
|
|
178
|
+
...(record.pid !== undefined ? { pid: requiredInteger(record.pid, "controller pid") } : {}),
|
|
179
|
+
...(record.tty !== undefined ? { tty: requiredString(record.tty, "controller tty") } : {}),
|
|
180
|
+
});
|
|
171
181
|
context.leases.set(lease.id, lease);
|
|
172
182
|
return lease;
|
|
173
183
|
}
|
|
@@ -308,6 +318,7 @@ function consumeFrames(host, context, chunk) {
|
|
|
308
318
|
continue;
|
|
309
319
|
if (Buffer.byteLength(line) > NATIVE_TERMINAL_MAX_FRAME_BYTES) {
|
|
310
320
|
writeError(context, "invalid", "invalid-request", new Error("terminal request exceeds the frame limit"));
|
|
321
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
|
|
311
322
|
context.socket.destroy();
|
|
312
323
|
return;
|
|
313
324
|
}
|
|
@@ -347,22 +358,10 @@ function consumeFrames(host, context, chunk) {
|
|
|
347
358
|
}
|
|
348
359
|
if (Buffer.byteLength(context.buffer) > NATIVE_TERMINAL_MAX_FRAME_BYTES) {
|
|
349
360
|
writeError(context, "invalid", "invalid-request", new Error("terminal request exceeds the frame limit"));
|
|
361
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
|
|
350
362
|
context.socket.destroy();
|
|
351
363
|
}
|
|
352
364
|
}
|
|
353
|
-
async function socketAcceptsConnections(socketPath, signal) {
|
|
354
|
-
return abortable(new Promise((resolve) => {
|
|
355
|
-
const socket = createConnection(socketPath);
|
|
356
|
-
const cancel = () => socket.destroy();
|
|
357
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
358
|
-
socket.once("close", () => signal?.removeEventListener("abort", cancel));
|
|
359
|
-
socket.once("connect", () => {
|
|
360
|
-
socket.destroy();
|
|
361
|
-
resolve(true);
|
|
362
|
-
});
|
|
363
|
-
socket.once("error", () => resolve(false));
|
|
364
|
-
}), signal);
|
|
365
|
-
}
|
|
366
365
|
async function socketPublicationLockSecret(socketPath, signal) {
|
|
367
366
|
if (process.platform !== "linux" || process.getuid === undefined) {
|
|
368
367
|
throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
|
|
@@ -401,11 +400,13 @@ async function socketPublicationLockSecret(socketPath, signal) {
|
|
|
401
400
|
|| identity.isSymbolicLink()
|
|
402
401
|
|| identity.uid !== process.getuid()
|
|
403
402
|
|| (identity.mode & 0o777) !== 0o600) {
|
|
404
|
-
|
|
403
|
+
// T4: a lock-id that is not a private owned file is a coded refusal,
|
|
404
|
+
// never an undifferentiated startup crash.
|
|
405
|
+
throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
|
|
405
406
|
}
|
|
406
407
|
const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
|
|
407
408
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(secret)) {
|
|
408
|
-
throw new
|
|
409
|
+
throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
|
|
409
410
|
}
|
|
410
411
|
return secret;
|
|
411
412
|
}
|
|
@@ -440,7 +441,7 @@ async function acquireSocketPublicationLock(socketPath, signal) {
|
|
|
440
441
|
}
|
|
441
442
|
async function withSocketPublicationLock(socketPath, operation, signal) {
|
|
442
443
|
const deadline = new AbortController();
|
|
443
|
-
const timer = setTimeout(() => deadline.abort(new
|
|
444
|
+
const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
444
445
|
const boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
|
|
445
446
|
try {
|
|
446
447
|
const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
|
|
@@ -492,14 +493,23 @@ function rollbackEndpoint(socketPath, owned) {
|
|
|
492
493
|
}
|
|
493
494
|
catch { /* Unknown sidecar ownership is never cleanup authority. */ }
|
|
494
495
|
}
|
|
495
|
-
async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal
|
|
496
|
+
async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal, frame,
|
|
497
|
+
/**
|
|
498
|
+
* Stamp a frame OTHER than the reader's current one: used only by the
|
|
499
|
+
* proof-preservation pass, which re-records an identity that a durable row
|
|
500
|
+
* already PROVED dead at a PREVIOUS boot of this machine. Every stamped
|
|
501
|
+
* value (pid, start-time, boot, namespace, machine) then comes from that
|
|
502
|
+
* one row — a coherent epoch — never a mix of a dead boot's numbers with
|
|
503
|
+
* the current boot's frame.
|
|
504
|
+
*/
|
|
505
|
+
stampFrame) {
|
|
496
506
|
signal?.throwIfAborted();
|
|
497
507
|
if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
|
|
498
508
|
throw new Error("simulated failure recording endpoint owner");
|
|
499
509
|
}
|
|
500
510
|
const { pid, startTime } = processOwner;
|
|
501
|
-
|
|
502
|
-
if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
|
|
511
|
+
frame = stampFrame ?? frame;
|
|
512
|
+
if (startTime === undefined || frame.bootId === undefined || frame.pidNamespace === undefined) {
|
|
503
513
|
throw new Error("cannot certify native host process identity");
|
|
504
514
|
}
|
|
505
515
|
const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -523,7 +533,10 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
|
|
|
523
533
|
signal?.throwIfAborted();
|
|
524
534
|
// Write through the already-open inode: cancellation can unlink it without
|
|
525
535
|
// a delayed write recreating the temporary pathname.
|
|
526
|
-
await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId
|
|
536
|
+
await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId: frame.bootId,
|
|
537
|
+
pidNamespace: frame.pidNamespace, ...(frame.machineId !== undefined ? { machineId: frame.machineId } : {}),
|
|
538
|
+
...(frame.nsNet !== undefined ? { nsNet: frame.nsNet } : {}),
|
|
539
|
+
...(frame.bootTimeMs !== undefined ? { bootTimeMs: frame.bootTimeMs } : {}) }), signal === undefined ? {} : { signal }), signal);
|
|
527
540
|
signal?.throwIfAborted();
|
|
528
541
|
await abortable(rename(temporary, `${socketPath}.owner`), signal);
|
|
529
542
|
}
|
|
@@ -559,12 +572,12 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
|
|
|
559
572
|
* identity. A live or unreadable owner keeps the endpoint unknown.
|
|
560
573
|
* No socket is removed by an inventory; publication owns reclamation.
|
|
561
574
|
*/
|
|
562
|
-
export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
|
|
575
|
+
export async function proveNativeTerminalEndpointAbsent(socketPath, failure, readFrame = readProcessFrame, ownerProbe = defaultOwnerHostProbe) {
|
|
563
576
|
const code = failure?.code;
|
|
564
577
|
if (code !== "ENOENT" && code !== "ECONNREFUSED")
|
|
565
578
|
return false;
|
|
566
579
|
await ensurePrivateSocketDirectory(socketPath);
|
|
567
|
-
return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
|
|
580
|
+
return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal, readPublicationFrame(readFrame), ownerProbe));
|
|
568
581
|
}
|
|
569
582
|
function canonicalizePath(targetPath) {
|
|
570
583
|
const resolved = resolve(targetPath);
|
|
@@ -583,7 +596,7 @@ function canonicalizePath(targetPath) {
|
|
|
583
596
|
}
|
|
584
597
|
}
|
|
585
598
|
}
|
|
586
|
-
function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc") {
|
|
599
|
+
function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc", ownerProbe = defaultOwnerHostProbe) {
|
|
587
600
|
if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
|
|
588
601
|
return { state: "unknown", reason: "simulated /proc observation unavailable" };
|
|
589
602
|
}
|
|
@@ -607,6 +620,15 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
607
620
|
reason: `cannot canonicalize target socket path: ${socketPath}`,
|
|
608
621
|
};
|
|
609
622
|
}
|
|
623
|
+
const targetDirectory = statSync(dirname(targetCanonical));
|
|
624
|
+
let targetSocket;
|
|
625
|
+
try {
|
|
626
|
+
targetSocket = statSync(targetCanonical);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
if (error.code !== "ENOENT")
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
610
632
|
for (const entry of entries) {
|
|
611
633
|
if (!/^\d+$/.test(entry))
|
|
612
634
|
continue;
|
|
@@ -650,23 +672,35 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
650
672
|
if (!isHost)
|
|
651
673
|
continue;
|
|
652
674
|
const candidateCanonical = canonicalizePath(candidateSocket);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
675
|
+
let sameCandidateSocket = false;
|
|
676
|
+
if (targetSocket) {
|
|
677
|
+
try {
|
|
678
|
+
sameCandidateSocket = sameNativeTerminalSocket(statSync(candidateSocket), targetSocket);
|
|
679
|
+
}
|
|
680
|
+
catch { /* An absent published name supplies no direct inode evidence. */ }
|
|
659
681
|
}
|
|
660
|
-
|
|
682
|
+
// Staged stems collide between the primary and lf1 endpoints. A native
|
|
683
|
+
// publication keeps its full basename; only direct socket identity can
|
|
684
|
+
// override a different endpoint name or a different resolved path.
|
|
685
|
+
if (!sameCandidateSocket && basename(resolve(candidateSocket)) !== basename(targetCanonical))
|
|
686
|
+
continue;
|
|
687
|
+
// An ancestor alias can have been retargeted since bind. Its current
|
|
688
|
+
// resolution alone cannot exclude the old target; pinned bind evidence can.
|
|
689
|
+
const candidateAliased = candidateCanonical !== undefined && candidateCanonical !== resolve(candidateSocket);
|
|
690
|
+
const couldServeTarget = candidateCanonical === undefined
|
|
691
|
+
|| candidateCanonical === targetCanonical || sameCandidateSocket || candidateAliased;
|
|
692
|
+
if (!couldServeTarget)
|
|
661
693
|
continue;
|
|
662
694
|
const candDir = dirname(candidateSocket);
|
|
663
|
-
const candStem = basename(candidateSocket).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
664
695
|
let isStagingCandidate = false;
|
|
665
696
|
try {
|
|
666
697
|
const dirEntries = readdirSync(candDir);
|
|
667
|
-
isStagingCandidate = dirEntries.some(name => name
|
|
698
|
+
isStagingCandidate = dirEntries.some(name => isStagedSocketName(name, candidateSocket, pid));
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Staging-directory inspection is optional; the descriptor/inode
|
|
702
|
+
// attribution below still decides live, absent, or unknown.
|
|
668
703
|
}
|
|
669
|
-
catch { }
|
|
670
704
|
if (isStagingCandidate)
|
|
671
705
|
continue;
|
|
672
706
|
const fdDir = join(pidDir, "fd");
|
|
@@ -683,20 +717,20 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
683
717
|
};
|
|
684
718
|
}
|
|
685
719
|
let hasSocketFd = false;
|
|
686
|
-
|
|
720
|
+
const descriptorLinks = new Map();
|
|
721
|
+
let unreadableDescriptor;
|
|
687
722
|
for (const fd of fds) {
|
|
688
|
-
let link;
|
|
689
723
|
try {
|
|
690
|
-
|
|
724
|
+
descriptorLinks.set(fd, readlinkSync(join(fdDir, fd)));
|
|
691
725
|
}
|
|
692
726
|
catch (err) {
|
|
693
727
|
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
694
728
|
continue;
|
|
695
|
-
|
|
696
|
-
state: "unknown",
|
|
697
|
-
reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
|
|
698
|
-
};
|
|
729
|
+
unreadableDescriptor = `cannot readlink /proc/${pid}/fd/${fd}: ${String(err)}`;
|
|
699
730
|
}
|
|
731
|
+
}
|
|
732
|
+
let unixSockets;
|
|
733
|
+
for (const link of descriptorLinks.values()) {
|
|
700
734
|
const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
|
|
701
735
|
if (!inode)
|
|
702
736
|
continue;
|
|
@@ -716,25 +750,70 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
716
750
|
// Stdio and journal IPC are Unix socket pairs too. Only the native
|
|
717
751
|
// listener's canonical or PID-attributed staged address proves service,
|
|
718
752
|
// including a listener whose filesystem pathname has been unlinked.
|
|
719
|
-
if (!boundPath ||
|
|
720
|
-
&& basename(boundPath).startsWith(`.${candStem}.${pid}.`) && boundPath.endsWith(".sock"))))
|
|
753
|
+
if (!boundPath || !boundPath.startsWith("/"))
|
|
721
754
|
continue;
|
|
722
755
|
const canonicalBound = canonicalizePath(boundPath);
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
756
|
+
let sameSocket = false;
|
|
757
|
+
if (targetSocket) {
|
|
758
|
+
try {
|
|
759
|
+
sameSocket = sameNativeTerminalSocket(statSync(boundPath), targetSocket);
|
|
760
|
+
}
|
|
761
|
+
catch { /* An unlinked staging name has no filesystem inode to compare. */ }
|
|
762
|
+
}
|
|
763
|
+
const stagedForTarget = isStagedSocketName(basename(boundPath), socketPath, pid);
|
|
764
|
+
if (sameSocket) {
|
|
765
|
+
hasSocketFd = true;
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
// Prefer the pinned directory even when its old pathname resolves again:
|
|
769
|
+
// a replacement directory at that name is a different endpoint. The bind
|
|
770
|
+
// directory is canonical, so its FD stays attributable after alias loss.
|
|
771
|
+
// Never compare the sockfs inode with a VFS inode.
|
|
772
|
+
// The Unix-table row above belongs to a socket this PID actually holds.
|
|
773
|
+
const nativeListener = boundPath === candidateSocket
|
|
774
|
+
|| isStagedSocketName(basename(boundPath), candidateSocket, pid);
|
|
775
|
+
if (!nativeListener)
|
|
776
|
+
continue;
|
|
777
|
+
let boundDirectory;
|
|
778
|
+
for (const [fd, path] of descriptorLinks) {
|
|
779
|
+
if (path !== dirname(boundPath) && path !== `${dirname(boundPath)} (deleted)`)
|
|
780
|
+
continue;
|
|
781
|
+
try {
|
|
782
|
+
const info = statSync(join(fdDir, fd));
|
|
783
|
+
if (info.isDirectory() && (path === dirname(boundPath) || info.nlink === 0)) {
|
|
784
|
+
boundDirectory = info;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
catch { /* Missing directory evidence remains unknown below. */ }
|
|
789
|
+
}
|
|
790
|
+
if (boundDirectory && !sameNativeTerminalSocket(boundDirectory, targetDirectory))
|
|
791
|
+
continue;
|
|
792
|
+
if (boundDirectory && (stagedForTarget || basename(boundPath) === basename(targetCanonical))) {
|
|
793
|
+
hasSocketFd = true;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
// Older hosts have no directory FD. Resolvable bind evidence can still
|
|
797
|
+
// attribute them; only an opaque listener of this same endpoint remains
|
|
798
|
+
// unknown. Distinct endpoint names were excluded before FD inspection.
|
|
799
|
+
if (canonicalBound === targetCanonical || (canonicalBound !== undefined
|
|
800
|
+
&& dirname(canonicalBound) === dirname(targetCanonical) && stagedForTarget)) {
|
|
727
801
|
hasSocketFd = true;
|
|
728
802
|
break;
|
|
729
803
|
}
|
|
804
|
+
if (canonicalBound !== undefined && !candidateAliased)
|
|
805
|
+
continue;
|
|
806
|
+
return { state: "unknown", reason: `cannot attribute bound socket directory for host pid ${pid}` };
|
|
730
807
|
}
|
|
808
|
+
if (!hasSocketFd && unreadableDescriptor)
|
|
809
|
+
return { state: "unknown", reason: unreadableDescriptor };
|
|
731
810
|
if (!hasSocketFd)
|
|
732
811
|
continue;
|
|
733
812
|
const startTime = readProcessStartTime(pid);
|
|
734
813
|
if (startTime === undefined) {
|
|
735
814
|
return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
|
|
736
815
|
}
|
|
737
|
-
const probe =
|
|
816
|
+
const probe = ownerProbe({ pid, startTime });
|
|
738
817
|
if (probe === "alive") {
|
|
739
818
|
return { state: "live", pid, startTime };
|
|
740
819
|
}
|
|
@@ -744,7 +823,71 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
744
823
|
}
|
|
745
824
|
return { state: "absent" };
|
|
746
825
|
}
|
|
747
|
-
|
|
826
|
+
function readPublicationFrame(readFrame) {
|
|
827
|
+
const frame = readFrame();
|
|
828
|
+
if (frame.machineId === undefined || !/^[0-9a-f]{32}$/.test(frame.machineId) || /^0+$/.test(frame.machineId)) {
|
|
829
|
+
throw new NativeTerminalPublicationRefusal("H2A_MACHINE_ID_UNREADABLE");
|
|
830
|
+
}
|
|
831
|
+
if (classifyDurableProcFrame(frame, frame) !== "current") {
|
|
832
|
+
throw new NativeTerminalPublicationRefusal("H2A_PROC_FRAME_UNKNOWN");
|
|
833
|
+
}
|
|
834
|
+
return frame;
|
|
835
|
+
}
|
|
836
|
+
/** A refused connect says only that nobody accepts now. All five refusals
|
|
837
|
+
* must complete within the global budget; every attempt has its own timer.
|
|
838
|
+
* Abort destroys the socket and cancels the interval before the lock releases. */
|
|
839
|
+
async function repeatedlyRefusesConnection(socketPath, socketPresent, signal) {
|
|
840
|
+
const deadline = new AbortController();
|
|
841
|
+
const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_PROBE_TIMEOUT")), 2_000);
|
|
842
|
+
const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
843
|
+
try {
|
|
844
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
845
|
+
bounded.throwIfAborted();
|
|
846
|
+
const refusal = await new Promise((resolve, reject) => {
|
|
847
|
+
const connection = createConnection(socketPath);
|
|
848
|
+
const finish = (error, refused = false) => {
|
|
849
|
+
clearTimeout(unitTimer);
|
|
850
|
+
bounded.removeEventListener("abort", cancel);
|
|
851
|
+
connection.destroy();
|
|
852
|
+
if (error !== undefined)
|
|
853
|
+
reject(error);
|
|
854
|
+
else
|
|
855
|
+
resolve(refused);
|
|
856
|
+
};
|
|
857
|
+
const cancel = () => finish(bounded.reason);
|
|
858
|
+
const unitTimer = setTimeout(() => finish(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_PROBE_TIMEOUT")), 500);
|
|
859
|
+
bounded.addEventListener("abort", cancel, { once: true });
|
|
860
|
+
connection.once("connect", () => finish());
|
|
861
|
+
connection.once("error", error => {
|
|
862
|
+
const code = error.code;
|
|
863
|
+
if (code === "ECONNREFUSED" || (!socketPresent && code === "ENOENT"))
|
|
864
|
+
finish(undefined, true);
|
|
865
|
+
else
|
|
866
|
+
finish(error);
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
if (!refusal)
|
|
870
|
+
return false;
|
|
871
|
+
if (attempt < 4) {
|
|
872
|
+
await new Promise((resolve, reject) => {
|
|
873
|
+
const cancel = () => { clearTimeout(interval); reject(bounded.reason); };
|
|
874
|
+
const interval = setTimeout(() => { bounded.removeEventListener("abort", cancel); resolve(); }, 250);
|
|
875
|
+
bounded.addEventListener("abort", cancel, { once: true });
|
|
876
|
+
if (bounded.aborted)
|
|
877
|
+
cancel();
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
bounded.throwIfAborted();
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
clearTimeout(timer);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/** Caller holds withSocketPublicationLock through classification, probes,
|
|
889
|
+
* inode recheck, reclamation, link and owner stamping (R2). */
|
|
890
|
+
async function proveEndpointAbsentUnderLock(socketPath, signal, currentFrame, ownerProbe = defaultOwnerHostProbe) {
|
|
748
891
|
signal?.throwIfAborted();
|
|
749
892
|
let socket;
|
|
750
893
|
try {
|
|
@@ -754,95 +897,135 @@ async function proveEndpointAbsentUnderLock(socketPath, signal) {
|
|
|
754
897
|
if (error.code !== "ENOENT")
|
|
755
898
|
throw error;
|
|
756
899
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
connection.setTimeout(1_000, () => { connection.destroy(); reject(new Error("terminal host connection timed out")); });
|
|
767
|
-
connection.once("connect", () => { connection.destroy(); resolve(false); });
|
|
768
|
-
connection.once("error", error => error.code === "ECONNREFUSED" ? resolve(true) : reject(error));
|
|
769
|
-
}), signal);
|
|
770
|
-
if (!refusal || !sameNativeTerminalSocket(socket, await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal)))
|
|
771
|
-
return false;
|
|
772
|
-
}
|
|
773
|
-
// Independent proof of host vivacity: a live host that lost its pathname
|
|
774
|
-
// but still serves must never allow a second writer.
|
|
775
|
-
// /proc read errors => unknown, never absent.
|
|
776
|
-
const activeScan = findActiveHostServingSocket(socketPath);
|
|
777
|
-
if (activeScan.state === "live") {
|
|
778
|
-
return false;
|
|
779
|
-
}
|
|
900
|
+
// Normative order: active scan, all three sources, classification, probes.
|
|
901
|
+
// A live host serving this endpoint — including an OLDER published host
|
|
902
|
+
// during an upgrade (scenario 9) — is the strongest refusal there is: the
|
|
903
|
+
// candidate must never unlink or republish over it.
|
|
904
|
+
// Seam symmetry (F4): the scan liveness probe is the INJECTED ownerProbe so
|
|
905
|
+
// the "zero probes for stale-boot sources" assertions observe this path too.
|
|
906
|
+
const activeScan = findActiveHostServingSocket(socketPath, undefined, ownerProbe);
|
|
907
|
+
if (activeScan.state === "live")
|
|
908
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_LIVE_OWNER");
|
|
780
909
|
if (activeScan.state === "unknown") {
|
|
781
|
-
|
|
910
|
+
// The frozen literal carries the sense (T8); the scan's own reason (e.g.
|
|
911
|
+
// which /proc view was unreadable) rides as `cause` so operators keep the
|
|
912
|
+
// diagnostic the old generic throw used to inline.
|
|
913
|
+
throw Object.assign(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_SCAN_UNKNOWN"), {
|
|
914
|
+
cause: new Error(activeScan.reason),
|
|
915
|
+
});
|
|
782
916
|
}
|
|
917
|
+
let sidecar;
|
|
918
|
+
const ownerPath = `${socketPath}.owner`;
|
|
783
919
|
try {
|
|
784
|
-
const ownerPath = `${socketPath}.owner`;
|
|
785
920
|
const info = await abortable(lstat(ownerPath), signal);
|
|
786
921
|
if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
|
|
787
|
-
throw new
|
|
922
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
|
|
788
923
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
924
|
+
try {
|
|
925
|
+
const parsed = JSON.parse(await abortable(readFile(ownerPath, "utf8"), signal));
|
|
926
|
+
// Valid JSON that is not an object (null, a bare string, a number) is
|
|
927
|
+
// the same coded refusal (T4): the shape is validated BEFORE any
|
|
928
|
+
// property access, never an undifferentiated TypeError crash (exit 1).
|
|
929
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
930
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
|
|
931
|
+
}
|
|
932
|
+
sidecar = parsed;
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (signal?.aborted || error instanceof NativeTerminalPublicationRefusal)
|
|
936
|
+
throw error;
|
|
937
|
+
// Unparseable sidecar content is a coded refusal (T4), never an
|
|
938
|
+
// undifferentiated crash: the file is local fail-closed evidence.
|
|
939
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
|
|
940
|
+
}
|
|
941
|
+
if (!Number.isSafeInteger(sidecar.pid) || sidecar.pid <= 0 || !Number.isSafeInteger(sidecar.startTime) ||
|
|
942
|
+
sidecar.startTime < 0 || !Number.isSafeInteger(sidecar.dev) || !Number.isSafeInteger(sidecar.ino) ||
|
|
943
|
+
typeof sidecar.bootId !== "string" || typeof sidecar.pidNamespace !== "string" ||
|
|
944
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sidecar.bootId) || !/^\d+$/.test(sidecar.pidNamespace) ||
|
|
945
|
+
(sidecar.machineId !== undefined && typeof sidecar.machineId !== "string")) {
|
|
946
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
|
|
794
947
|
}
|
|
795
|
-
if (socket && !sameNativeTerminalSocket(socket, owner))
|
|
796
|
-
return false;
|
|
797
|
-
if (owner.bootId !== readBootId() || owner.pidNamespace !== readPidNamespaceId())
|
|
798
|
-
return false;
|
|
799
|
-
if (defaultOwnerHostProbe(owner) !== "dead")
|
|
800
|
-
return false;
|
|
801
|
-
if (socket)
|
|
802
|
-
return true;
|
|
803
|
-
// A pre-upgrade publisher may have replaced and then lost the pathname
|
|
804
|
-
// without updating this sidecar. Its durable PTY owners must also be dead.
|
|
805
948
|
}
|
|
806
949
|
catch (error) {
|
|
807
950
|
if (error.code !== "ENOENT")
|
|
808
951
|
throw error;
|
|
809
952
|
}
|
|
810
|
-
// Pre-upgrade hosts already persist this identity for each PTY. A stale
|
|
811
|
-
// socket without any attribution is unprovable; never infer death from
|
|
812
|
-
// a bare PID, a socket's age, or an empty/error-collapsed registry.
|
|
813
953
|
const snapshot = listNativeTerminalPgidEntries();
|
|
814
|
-
if (!snapshot.known) {
|
|
815
|
-
// This decoder deliberately calls an ENOENT registry "unknown" for
|
|
816
|
-
// orphan reaping. For endpoint discovery, no file means no attribution;
|
|
817
|
-
// it proves absence only when the socket is also absent under the lock.
|
|
818
|
-
if (snapshot.reason === "registry is absent")
|
|
819
|
-
return socket === undefined;
|
|
820
|
-
throw new Error(`native endpoint owner registry is unknown: ${snapshot.reason}`);
|
|
821
|
-
}
|
|
822
|
-
const canonicalTarget = canonicalizePath(socketPath);
|
|
823
|
-
if (!canonicalTarget) {
|
|
824
|
-
throw new Error(`cannot canonicalize socket path: ${socketPath}`);
|
|
825
|
-
}
|
|
826
|
-
const owners = snapshot.entries
|
|
827
|
-
.filter(entry => entry.owner?.socketPath && canonicalizePath(entry.owner.socketPath) === canonicalTarget)
|
|
828
|
-
.map(entry => entry.owner);
|
|
829
954
|
const registry = loadRegistry();
|
|
955
|
+
if (!snapshot.known && snapshot.reason !== "registry is absent")
|
|
956
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
|
|
830
957
|
if (registry.state === "unknown" || registry.unreadable.length > 0)
|
|
958
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
|
|
959
|
+
const canonicalTarget = canonicalizePath(socketPath);
|
|
960
|
+
if (!canonicalTarget)
|
|
961
|
+
throw new Error(`cannot canonicalize socket path: ${socketPath}`);
|
|
962
|
+
const matches = (path) => path !== undefined && canonicalizePath(path) === canonicalTarget;
|
|
963
|
+
const attributedRows = registry.entries.filter(row => matches(row.ownerHostSocketPath));
|
|
964
|
+
const attributedPgid = snapshot.known ? snapshot.entries.filter(entry => matches(entry.owner?.socketPath)) : [];
|
|
965
|
+
// Legacy sidecars are local, unattributed evidence (R3); registry and pgid
|
|
966
|
+
// records without a machine anchor remain blocking foreign frames (R1).
|
|
967
|
+
const sidecarVerdict = sidecar?.machineId === undefined ? undefined : classifyDurableProcFrame(sidecar, currentFrame);
|
|
968
|
+
const classifiedPgid = attributedPgid.map(entry => ({ entry, verdict: classifyDurableProcFrame(entry.frame, currentFrame) }));
|
|
969
|
+
const classifiedRows = attributedRows.map(row => ({ row, verdict: classifyDurableProcFrame({
|
|
970
|
+
pidNamespace: row.pgidPidNamespace, bootId: row.pgidBootId, machineId: row.pgidMachineId,
|
|
971
|
+
}, currentFrame) }));
|
|
972
|
+
if ((socket && sidecar && !sameNativeTerminalSocket(socket, sidecar)) || sidecarVerdict === "foreign-frame" ||
|
|
973
|
+
classifiedPgid.some(item => item.verdict === "foreign-frame") || classifiedRows.some(item => item.verdict === "foreign-frame"))
|
|
831
974
|
return false;
|
|
832
|
-
|
|
833
|
-
|
|
975
|
+
// Owners are derived only AFTER every source has been classified.
|
|
976
|
+
const currentOwners = [];
|
|
977
|
+
if (sidecarVerdict === "current" && sidecar)
|
|
978
|
+
currentOwners.push(sidecar);
|
|
979
|
+
for (const { entry, verdict } of classifiedPgid)
|
|
980
|
+
if (verdict === "current" && entry.owner)
|
|
981
|
+
currentOwners.push(entry.owner);
|
|
982
|
+
for (const { row, verdict } of classifiedRows)
|
|
983
|
+
if (verdict === "current") {
|
|
984
|
+
if (row.ownerHostPid === undefined)
|
|
985
|
+
return false;
|
|
986
|
+
currentOwners.push({ pid: row.ownerHostPid, ...(row.ownerHostStartTime !== undefined ? { startTime: row.ownerHostStartTime } : {}) });
|
|
987
|
+
}
|
|
988
|
+
const uniqueOwners = [...new Map(currentOwners.map(owner => [`${owner.pid}:${owner.startTime}`, owner])).values()];
|
|
989
|
+
const ownersDead = uniqueOwners.every(owner => owner.startTime !== undefined && ownerProbe(owner) === "dead");
|
|
990
|
+
const sidecarProvesDeath = sidecarVerdict === "stale-boot" || (sidecarVerdict === "current" && ownersDead);
|
|
991
|
+
// Even a run of refusals cannot prove a new, unattributed holder dead.
|
|
992
|
+
if (!await repeatedlyRefusesConnection(socketPath, socket !== undefined, signal))
|
|
993
|
+
return false;
|
|
994
|
+
let preBootInode = false;
|
|
995
|
+
try {
|
|
996
|
+
const final = await abortable(lstat(socketPath), signal);
|
|
997
|
+
if (!socket || !sameNativeTerminalSocket(socket, final))
|
|
998
|
+
return false;
|
|
999
|
+
preBootInode = currentFrame.bootTimeMs !== undefined && Number.isFinite(currentFrame.bootTimeMs) && currentFrame.bootTimeMs > 0 &&
|
|
1000
|
+
final.birthtimeMs > 0 && final.birthtimeMs < currentFrame.bootTimeMs;
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
if (error.code !== "ENOENT")
|
|
1004
|
+
throw error;
|
|
1005
|
+
if (socket)
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
if (!ownersDead)
|
|
834
1009
|
return false;
|
|
835
|
-
|
|
1010
|
+
const staleProof = classifiedRows.find(item => item.verdict === "stale-boot" && item.row.ownerHostPid !== undefined && item.row.ownerHostStartTime !== undefined)?.row;
|
|
1011
|
+
const currentDead = uniqueOwners[0];
|
|
1012
|
+
// Stale rows prove the death of their AUTHORS, never the absence of a NEW
|
|
1013
|
+
// holder of the pathname (R4-probe): they cannot bypass the age/liveness
|
|
1014
|
+
// proof. Without a sidecar death proof or a CURRENT-boot owner positively
|
|
1015
|
+
// probed dead, an existing socket yields publication only to the pre-boot
|
|
1016
|
+
// inode proof (R3 combo 4): an inode created during this boot may have a
|
|
1017
|
+
// live, unattributed holder that refused every connect probe.
|
|
1018
|
+
if (socket && !sidecarProvesDeath && currentDead === undefined && !preBootInode)
|
|
836
1019
|
return false;
|
|
837
|
-
if (socket &&
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
await recordEndpointOwner(socketPath, socket, { pid:
|
|
1020
|
+
if (socket && currentDead !== undefined) {
|
|
1021
|
+
await recordEndpointOwner(socketPath, socket, { pid: currentDead.pid, startTime: currentDead.startTime }, signal, currentFrame);
|
|
1022
|
+
}
|
|
1023
|
+
else if (socket && !sidecarProvesDeath && staleProof !== undefined) {
|
|
1024
|
+
await recordEndpointOwner(socketPath, socket, { pid: staleProof.ownerHostPid, startTime: staleProof.ownerHostStartTime }, signal, currentFrame, { bootId: staleProof.pgidBootId, pidNamespace: staleProof.pgidPidNamespace, machineId: staleProof.pgidMachineId });
|
|
842
1025
|
}
|
|
843
|
-
return
|
|
1026
|
+
return true;
|
|
844
1027
|
}
|
|
845
|
-
async function removeStaleSocket(socketPath, signal) {
|
|
1028
|
+
async function removeStaleSocket(socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
|
|
846
1029
|
for (;;) {
|
|
847
1030
|
signal?.throwIfAborted();
|
|
848
1031
|
let stale;
|
|
@@ -854,11 +1037,8 @@ async function removeStaleSocket(socketPath, signal) {
|
|
|
854
1037
|
return;
|
|
855
1038
|
throw error;
|
|
856
1039
|
}
|
|
857
|
-
if (await
|
|
858
|
-
throw new
|
|
859
|
-
}
|
|
860
|
-
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
861
|
-
throw new Error(`terminal host owner death is unproven; refusing stale socket reclamation: ${socketPath}`);
|
|
1040
|
+
if (!await proveEndpointAbsentUnderLock(socketPath, signal, frame, ownerProbe)) {
|
|
1041
|
+
throw new NativeTerminalPublicationRefusal("H2A_STALE_SOCKET_UNPROVEN");
|
|
862
1042
|
}
|
|
863
1043
|
try {
|
|
864
1044
|
const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
@@ -888,16 +1068,71 @@ async function ensurePrivateSocketDirectory(socketPath) {
|
|
|
888
1068
|
await assertPrivateNativeTerminalSocketDirectory(socketPath);
|
|
889
1069
|
}
|
|
890
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Staged-socket naming grammar, shared by the generator (`stagingSocketPath`)
|
|
1073
|
+
* and the recognizers in `findActiveHostServingSocket`. The staged name MUST
|
|
1074
|
+
* remain recognizable: a live host that lost its published pathname is only
|
|
1075
|
+
* attributable through its staged name, and refusing to attribute it would
|
|
1076
|
+
* admit a second writer. Never edit the generator without the recognizers.
|
|
1077
|
+
*
|
|
1078
|
+
* Two grammars, with an explicit compatibility order (R5 v5):
|
|
1079
|
+
*
|
|
1080
|
+
* 1. LEGACY (≤ 0.98.4): `.<8 stem bytes>.<pid>.<8 hex>.sock` — the ONLY
|
|
1081
|
+
* grammar the PUBLISHED 0.98.4 reader recognizes when it attributes, via
|
|
1082
|
+
* /proc, a live host whose published pathname is gone. Chosen WHENEVER the
|
|
1083
|
+
* kernel sun_path budget allows it, so a mixed 0.98.4/0.98.5 fleet never
|
|
1084
|
+
* loses the anti-second-writer attribution in either direction.
|
|
1085
|
+
* 2. SHORT: `.<3 stem bytes>.<pid>.<8 hex>` — the fallback when the legacy
|
|
1086
|
+
* name would overflow the sun_path budget. RESTRICTION (explicit, R5 v5):
|
|
1087
|
+
* in such deep paths a 0.98.4 reader can no longer attribute a
|
|
1088
|
+
* pathname-less 0.98.5 host through /proc — but a 0.98.4 host could not
|
|
1089
|
+
* even bind there (its own staged name would overflow first), so the
|
|
1090
|
+
* exposure is confined to deep development/deployment directories, and
|
|
1091
|
+
* 0.98.5 readers still refuse the double publication on both grammars.
|
|
1092
|
+
*/
|
|
1093
|
+
const STAGED_SOCKET_UUID_HEX_LENGTH = 8;
|
|
1094
|
+
function stagedSocketStem(publishedPath, stemLength) {
|
|
1095
|
+
return basename(publishedPath).slice(0, stemLength).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Grammar-exact recognizer: `.<stem>.<pid>.<exactly 8 lowercase hex>` for the
|
|
1099
|
+
* short grammar, plus `.sock` for the legacy grammar. A bare `startsWith`
|
|
1100
|
+
* would accept forged or truncated names as attribution evidence.
|
|
1101
|
+
*/
|
|
1102
|
+
export function isStagedSocketName(name, publishedPath, pid) {
|
|
1103
|
+
for (const [stemLength, legacy] of [[8, true], [3, false]]) {
|
|
1104
|
+
const prefix = `.${stagedSocketStem(publishedPath, stemLength)}.${pid}.`;
|
|
1105
|
+
if (!name.startsWith(prefix))
|
|
1106
|
+
continue;
|
|
1107
|
+
const tail = name.slice(prefix.length);
|
|
1108
|
+
const expectedTailLength = STAGED_SOCKET_UUID_HEX_LENGTH + (legacy ? ".sock".length : 0);
|
|
1109
|
+
if (tail.length !== expectedTailLength)
|
|
1110
|
+
continue;
|
|
1111
|
+
if (!/^[0-9a-f]+$/.test(tail.slice(0, STAGED_SOCKET_UUID_HEX_LENGTH)))
|
|
1112
|
+
continue;
|
|
1113
|
+
if (legacy && !tail.endsWith(".sock"))
|
|
1114
|
+
continue;
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
891
1119
|
function stagingSocketPath(socketPath) {
|
|
892
1120
|
const directory = dirname(socketPath);
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
//
|
|
896
|
-
//
|
|
1121
|
+
const uuid = randomUUID().slice(0, STAGED_SOCKET_UUID_HEX_LENGTH);
|
|
1122
|
+
// Legacy first: it is the grammar the published 0.98.4 reader can still
|
|
1123
|
+
// attribute (see the restriction note above). Only a sun_path overflow may
|
|
1124
|
+
// demote the staged name to the short grammar.
|
|
1125
|
+
const legacy = join(directory, `.${stagedSocketStem(socketPath, 8)}.${process.pid}.${uuid}.sock`);
|
|
1126
|
+
if (Buffer.byteLength(legacy, "utf8") <= NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES) {
|
|
1127
|
+
return legacy;
|
|
1128
|
+
}
|
|
1129
|
+
const stagedPath = join(directory, `.${stagedSocketStem(socketPath, 3)}.${process.pid}.${uuid}`);
|
|
1130
|
+
// Even the short name must fit: a publishable path can still overflow the
|
|
1131
|
+
// kernel sun_path budget at the bind step.
|
|
897
1132
|
assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
|
|
898
1133
|
return stagedPath;
|
|
899
1134
|
}
|
|
900
|
-
async function publishSocket(stagedPath, socketPath, signal) {
|
|
1135
|
+
async function publishSocket(stagedPath, socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
|
|
901
1136
|
signal.throwIfAborted();
|
|
902
1137
|
const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
|
|
903
1138
|
try {
|
|
@@ -906,8 +1141,8 @@ async function publishSocket(stagedPath, socketPath, signal) {
|
|
|
906
1141
|
catch (error) {
|
|
907
1142
|
if (error.code !== "ENOENT")
|
|
908
1143
|
throw error;
|
|
909
|
-
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
910
|
-
throw new
|
|
1144
|
+
if (!await proveEndpointAbsentUnderLock(socketPath, signal, frame, ownerProbe)) {
|
|
1145
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_UNPROVEN");
|
|
911
1146
|
}
|
|
912
1147
|
}
|
|
913
1148
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
@@ -924,14 +1159,27 @@ async function publishSocket(stagedPath, socketPath, signal) {
|
|
|
924
1159
|
catch (error) {
|
|
925
1160
|
if (error.code !== "EEXIST")
|
|
926
1161
|
throw error;
|
|
927
|
-
await removeStaleSocket(socketPath, signal);
|
|
1162
|
+
await removeStaleSocket(socketPath, signal, frame, ownerProbe);
|
|
928
1163
|
}
|
|
929
1164
|
}
|
|
930
1165
|
throw new Error("terminal host socket publication retry limit exceeded");
|
|
931
1166
|
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Accept/connection-side transport errors: the listener itself keeps serving.
|
|
1169
|
+
*/
|
|
1170
|
+
const RECOVERABLE_LISTENER_ERRNOS = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
|
|
1171
|
+
/** Accept-path fd exhaustion: transient while it clears, unusable when it
|
|
1172
|
+
* persists (v5 R7 predicate: >= 3 consecutive accept errors within 60 s). */
|
|
1173
|
+
const EXHAUSTED_LISTENER_ERRNOS = new Set(["EMFILE", "ENFILE"]);
|
|
1174
|
+
const LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD = 3;
|
|
1175
|
+
const LISTENER_EXHAUSTION_WINDOW_MS = 60_000;
|
|
1176
|
+
/** The listening socket can no longer serve: late EADDRINUSE, an invalid fd,
|
|
1177
|
+
* an unusable address. Known-dead — exit cleanly and let the supervisor own
|
|
1178
|
+
* the respawn. */
|
|
1179
|
+
const UNUSABLE_LISTENER_ERRNOS = new Set(["ERR_SERVER_CLOSED", "EADDRINUSE", "EADDRNOTAVAIL", "EBADF", "EINVAL", "ENOTSOCK", "EACCES"]);
|
|
932
1180
|
export async function startNativeTerminalHostServer(options) {
|
|
933
1181
|
const deadline = new AbortController();
|
|
934
|
-
const timer = setTimeout(() => deadline.abort(new
|
|
1182
|
+
const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_SOCKET_PUBLICATION_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
935
1183
|
const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
|
|
936
1184
|
try {
|
|
937
1185
|
return await initializeNativeTerminalHostServer({ ...options, signal });
|
|
@@ -948,7 +1196,10 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
948
1196
|
const sockets = new Set();
|
|
949
1197
|
const responseBudget = { pendingBytes: 0 };
|
|
950
1198
|
let readyForRequests = false;
|
|
1199
|
+
let listenerExhaustionEvents = [];
|
|
951
1200
|
const server = createServer((socket) => {
|
|
1201
|
+
// A successful accept breaks the consecutive failure series (R7 v5).
|
|
1202
|
+
listenerExhaustionEvents = [];
|
|
952
1203
|
if (sockets.size >= NATIVE_TERMINAL_MAX_CONNECTIONS) {
|
|
953
1204
|
socket.destroy();
|
|
954
1205
|
return;
|
|
@@ -969,13 +1220,18 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
969
1220
|
socket.setEncoding("utf8");
|
|
970
1221
|
socket.setNoDelay(true);
|
|
971
1222
|
socket.on("error", () => {
|
|
972
|
-
//
|
|
1223
|
+
// R7.5 named exemption: expected, potentially high-volume transport
|
|
1224
|
+
// errors. Count per connection instead of producing a log per event.
|
|
1225
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostSocketConnectionErrors);
|
|
973
1226
|
});
|
|
974
1227
|
socket.on("data", (chunk) => {
|
|
975
1228
|
try {
|
|
976
1229
|
consumeFrames(options.host, context, chunk);
|
|
977
1230
|
}
|
|
978
1231
|
catch {
|
|
1232
|
+
// R7.5 named exemption: a bad client costs only this connection;
|
|
1233
|
+
// count frame destroys instead of logging every malformed stream.
|
|
1234
|
+
recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
|
|
979
1235
|
socket.destroy();
|
|
980
1236
|
}
|
|
981
1237
|
});
|
|
@@ -985,9 +1241,27 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
985
1241
|
sockets.delete(socket);
|
|
986
1242
|
});
|
|
987
1243
|
});
|
|
988
|
-
|
|
1244
|
+
// bind(2) retains this literal path in /proc/net/unix. Resolve its directory
|
|
1245
|
+
// before binding so it agrees with the retained directory FD even if an
|
|
1246
|
+
// ancestor symlink is later removed. Keep the published path and grammar.
|
|
1247
|
+
const stagedPath = stagingSocketPath(join(realpathSync(dirname(options.socketPath)), basename(options.socketPath)));
|
|
1248
|
+
let directoryFd;
|
|
1249
|
+
const closeDirectory = () => {
|
|
1250
|
+
if (directoryFd !== undefined) {
|
|
1251
|
+
closeSync(directoryFd);
|
|
1252
|
+
directoryFd = undefined;
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
989
1255
|
let ownedSocket;
|
|
1256
|
+
// Assigned once the published close is built; the listener-failure handler
|
|
1257
|
+
// (installed before that object exists) tears the server down through it.
|
|
1258
|
+
let closePublished;
|
|
1259
|
+
let initializationFailure;
|
|
990
1260
|
try {
|
|
1261
|
+
// Keep the real directory inode observable through /proc/<pid>/fd even
|
|
1262
|
+
// after its pathname is deleted. The bind name and staging grammar stay
|
|
1263
|
+
// compatible with older readers; no private-state census is introduced.
|
|
1264
|
+
directoryFd = openSync(dirname(stagedPath), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
991
1265
|
await abortable(new Promise((resolve, reject) => {
|
|
992
1266
|
server.once("error", reject);
|
|
993
1267
|
server.listen(stagedPath, () => {
|
|
@@ -995,14 +1269,77 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
995
1269
|
resolve();
|
|
996
1270
|
});
|
|
997
1271
|
}), options.signal);
|
|
1272
|
+
// The listener that turned listen()-path errors into a rejected startup
|
|
1273
|
+
// is gone by design; without a replacement, any error surfacing on the
|
|
1274
|
+
// net.Server AFTER listen would be an unhandled `error` event — an
|
|
1275
|
+
// uncaughtException that kills the host (the 2026-10-10/11 crash-loop's
|
|
1276
|
+
// class of failure). This durable handler CLASSIFIES instead of
|
|
1277
|
+
// absorbing everything (R7):
|
|
1278
|
+
// - recoverable transport errors (accept/connection side): log + survive;
|
|
1279
|
+
// - a known-unusable listener: log + stop serving + clean exit path;
|
|
1280
|
+
// - anything unclassifiable: log + stop serving + let the wired owner
|
|
1281
|
+
// exit non-zero (exit 3) — the supervisor's bounded respawn owns it.
|
|
1282
|
+
// Per-connection transport errors stay on each socket's own handler.
|
|
1283
|
+
server.on("error", (error) => {
|
|
1284
|
+
const errno = error.code;
|
|
1285
|
+
if (errno !== undefined && RECOVERABLE_LISTENER_ERRNOS.has(errno)) {
|
|
1286
|
+
listenerExhaustionEvents = [];
|
|
1287
|
+
recordHostFault(HOST_FAULT_COUNTERS.serverListenerErrorRecoverable);
|
|
1288
|
+
writeHostLogLine(`terminal host listener recovered from a transport error (${errno}, contained): ${String(error)}`);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
// fd exhaustion on the accept path is survivable while it clears; the
|
|
1292
|
+
// v5 R7 persistence predicate promotes it to a known-unusable listener.
|
|
1293
|
+
if (errno !== undefined && EXHAUSTED_LISTENER_ERRNOS.has(errno)) {
|
|
1294
|
+
const now = Date.now();
|
|
1295
|
+
listenerExhaustionEvents.push(now);
|
|
1296
|
+
listenerExhaustionEvents = listenerExhaustionEvents.filter((at) => now - at < LISTENER_EXHAUSTION_WINDOW_MS);
|
|
1297
|
+
if (listenerExhaustionEvents.length >= LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD) {
|
|
1298
|
+
writeHostLogLine(`terminal host listener accept path is persistently exhausted (${listenerExhaustionEvents.length} ${errno} within ${LISTENER_EXHAUSTION_WINDOW_MS}ms); stopping`);
|
|
1299
|
+
listenerExhaustionEvents = [];
|
|
1300
|
+
escalateListenerFailure({ kind: "unusable", errno, error });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
recordHostFault(HOST_FAULT_COUNTERS.serverListenerErrorExhausted);
|
|
1304
|
+
writeHostLogLine(`terminal host listener accept path exhausted (${errno}, contained while it clears; ${listenerExhaustionEvents.length}/${LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD} in the window): ${String(error)}`);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const kind = errno !== undefined && UNUSABLE_LISTENER_ERRNOS.has(errno) ? "unusable" : "unknown";
|
|
1308
|
+
escalateListenerFailure({ kind, errno, error });
|
|
1309
|
+
});
|
|
1310
|
+
const escalateListenerFailure = (failure) => {
|
|
1311
|
+
recordHostFault(failure.kind === "unusable"
|
|
1312
|
+
? HOST_FAULT_COUNTERS.serverListenerErrorUnusable
|
|
1313
|
+
: HOST_FAULT_COUNTERS.serverListenerErrorUnknown);
|
|
1314
|
+
const label = failure.kind === "unknown" ? "unknown listener error" : "unusable listener error";
|
|
1315
|
+
writeHostLogLine(`${label} (${failure.errno ?? "no errno"}): ${String(failure.error)}; stopping`);
|
|
1316
|
+
if (options.onListenerFailure !== undefined) {
|
|
1317
|
+
options.onListenerFailure(failure);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (closePublished === undefined) {
|
|
1321
|
+
initializationFailure = failure;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
// Library default: no owner owns the process exit — stop serving
|
|
1325
|
+
// (sessions, sockets, publication rollback) without crashing the caller.
|
|
1326
|
+
closePublished?.({ stopSessions: true }).catch(() => {
|
|
1327
|
+
// The failure is already logged and counted; teardown errors must not
|
|
1328
|
+
// turn a contained listener failure into an unhandled rejection.
|
|
1329
|
+
});
|
|
1330
|
+
};
|
|
998
1331
|
// Capture the listener's inode before any publication await can suspend.
|
|
999
1332
|
ownedSocket = lstatSync(stagedPath);
|
|
1333
|
+
if (!sameNativeTerminalSocket(fstatSync(directoryFd), statSync(dirname(stagedPath)))) {
|
|
1334
|
+
throw new Error("terminal host socket directory changed during bind");
|
|
1335
|
+
}
|
|
1000
1336
|
await abortable(chmod(stagedPath, 0o600), options.signal);
|
|
1001
1337
|
await withSocketPublicationLock(options.socketPath, async (signal) => {
|
|
1002
1338
|
try {
|
|
1003
1339
|
signal.throwIfAborted();
|
|
1004
|
-
const
|
|
1005
|
-
await
|
|
1340
|
+
const frame = readPublicationFrame(options.readFrame ?? readProcessFrame);
|
|
1341
|
+
const published = await publishSocket(stagedPath, options.socketPath, signal, frame, options.ownerProbe ?? defaultOwnerHostProbe);
|
|
1342
|
+
await recordEndpointOwner(options.socketPath, published, undefined, signal, frame);
|
|
1006
1343
|
return published;
|
|
1007
1344
|
}
|
|
1008
1345
|
catch (pubError) {
|
|
@@ -1013,58 +1350,69 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
1013
1350
|
throw pubError;
|
|
1014
1351
|
}
|
|
1015
1352
|
}, options.signal);
|
|
1353
|
+
if (initializationFailure !== undefined) {
|
|
1354
|
+
throw new Error(initializationFailure.kind === "unknown" ? "unknown listener error" : "unusable listener error");
|
|
1355
|
+
}
|
|
1016
1356
|
}
|
|
1017
1357
|
catch (error) {
|
|
1018
|
-
|
|
1019
|
-
socket
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1358
|
+
try {
|
|
1359
|
+
for (const socket of sockets)
|
|
1360
|
+
socket.destroy();
|
|
1361
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
1362
|
+
if (ownedSocket !== undefined) {
|
|
1363
|
+
unlinkOwnedPath(stagedPath, ownedSocket);
|
|
1364
|
+
rollbackEndpoint(options.socketPath, ownedSocket);
|
|
1365
|
+
}
|
|
1366
|
+
await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
1367
|
+
}
|
|
1368
|
+
finally {
|
|
1369
|
+
closeDirectory();
|
|
1024
1370
|
}
|
|
1025
|
-
await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
1026
1371
|
throw error;
|
|
1027
1372
|
}
|
|
1028
1373
|
readyForRequests = true;
|
|
1029
1374
|
for (const socket of sockets)
|
|
1030
1375
|
socket.resume();
|
|
1031
1376
|
let closing;
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (!isNativeTerminalStopSignal(signal)) {
|
|
1041
|
-
throw new TypeError("invalid terminal host shutdown signal");
|
|
1042
|
-
}
|
|
1043
|
-
options.host.stopAll(signal);
|
|
1044
|
-
}
|
|
1045
|
-
catch (error) {
|
|
1046
|
-
stopError = error;
|
|
1377
|
+
const doClose = (closeOptions = {}) => {
|
|
1378
|
+
closing ??= (async () => {
|
|
1379
|
+
let stopError;
|
|
1380
|
+
if (closeOptions.stopSessions) {
|
|
1381
|
+
try {
|
|
1382
|
+
const signal = closeOptions.signal ?? "SIGTERM";
|
|
1383
|
+
if (!isNativeTerminalStopSignal(signal)) {
|
|
1384
|
+
throw new TypeError("invalid terminal host shutdown signal");
|
|
1047
1385
|
}
|
|
1386
|
+
options.host.stopAll(signal);
|
|
1048
1387
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
stopError = error;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
for (const socket of sockets)
|
|
1393
|
+
socket.destroy();
|
|
1394
|
+
await abortable(new Promise((resolve, reject) => {
|
|
1395
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
1396
|
+
}), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
1397
|
+
await withSocketPublicationLock(options.socketPath, async () => {
|
|
1398
|
+
try {
|
|
1399
|
+
rollbackEndpoint(options.socketPath, ownedSocket);
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
if (error.code !== "ENOENT")
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
if (stopError !== undefined)
|
|
1407
|
+
throw stopError;
|
|
1408
|
+
})().finally(closeDirectory);
|
|
1409
|
+
return closing;
|
|
1410
|
+
};
|
|
1411
|
+
closePublished = doClose;
|
|
1412
|
+
return Object.freeze({
|
|
1413
|
+
socketPath: options.socketPath,
|
|
1414
|
+
server,
|
|
1415
|
+
close: doClose,
|
|
1068
1416
|
});
|
|
1069
1417
|
}
|
|
1070
1418
|
//# sourceMappingURL=server.js.map
|