@sentropic/h2a-runtime 0.98.5 → 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 +11 -5
- package/dist/index.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/host.d.ts +21 -5
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +104 -72
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/journal.d.ts.map +1 -1
- package/dist/native-terminal/journal.js +2 -1
- 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 +4 -2
- 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 +9 -2
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +335 -138
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +9 -0
- 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,5 +1,5 @@
|
|
|
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";
|
|
@@ -9,7 +9,8 @@ import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from
|
|
|
9
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";
|
|
10
10
|
import { NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES, assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
|
|
11
11
|
import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
|
|
12
|
-
import {
|
|
12
|
+
import { classifyDurableProcFrame, readProcessFrame, } from "../proc-identity.js";
|
|
13
|
+
import { NativeTerminalPublicationRefusal } from "./publication-refusal.js";
|
|
13
14
|
const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
|
|
14
15
|
const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
|
|
15
16
|
const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
|
|
@@ -169,7 +170,14 @@ function dispatch(host, context, request) {
|
|
|
169
170
|
return host.attachObserver(sessionId(params));
|
|
170
171
|
case "acquire-controller": {
|
|
171
172
|
const record = requiredRecord(params, "params");
|
|
172
|
-
|
|
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
|
+
});
|
|
173
181
|
context.leases.set(lease.id, lease);
|
|
174
182
|
return lease;
|
|
175
183
|
}
|
|
@@ -354,19 +362,6 @@ function consumeFrames(host, context, chunk) {
|
|
|
354
362
|
context.socket.destroy();
|
|
355
363
|
}
|
|
356
364
|
}
|
|
357
|
-
async function socketAcceptsConnections(socketPath, signal) {
|
|
358
|
-
return abortable(new Promise((resolve) => {
|
|
359
|
-
const socket = createConnection(socketPath);
|
|
360
|
-
const cancel = () => socket.destroy();
|
|
361
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
362
|
-
socket.once("close", () => signal?.removeEventListener("abort", cancel));
|
|
363
|
-
socket.once("connect", () => {
|
|
364
|
-
socket.destroy();
|
|
365
|
-
resolve(true);
|
|
366
|
-
});
|
|
367
|
-
socket.once("error", () => resolve(false));
|
|
368
|
-
}), signal);
|
|
369
|
-
}
|
|
370
365
|
async function socketPublicationLockSecret(socketPath, signal) {
|
|
371
366
|
if (process.platform !== "linux" || process.getuid === undefined) {
|
|
372
367
|
throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
|
|
@@ -405,11 +400,13 @@ async function socketPublicationLockSecret(socketPath, signal) {
|
|
|
405
400
|
|| identity.isSymbolicLink()
|
|
406
401
|
|| identity.uid !== process.getuid()
|
|
407
402
|
|| (identity.mode & 0o777) !== 0o600) {
|
|
408
|
-
|
|
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");
|
|
409
406
|
}
|
|
410
407
|
const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
|
|
411
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)) {
|
|
412
|
-
throw new
|
|
409
|
+
throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
|
|
413
410
|
}
|
|
414
411
|
return secret;
|
|
415
412
|
}
|
|
@@ -444,7 +441,7 @@ async function acquireSocketPublicationLock(socketPath, signal) {
|
|
|
444
441
|
}
|
|
445
442
|
async function withSocketPublicationLock(socketPath, operation, signal) {
|
|
446
443
|
const deadline = new AbortController();
|
|
447
|
-
const timer = setTimeout(() => deadline.abort(new
|
|
444
|
+
const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
448
445
|
const boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
|
|
449
446
|
try {
|
|
450
447
|
const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
|
|
@@ -496,14 +493,23 @@ function rollbackEndpoint(socketPath, owned) {
|
|
|
496
493
|
}
|
|
497
494
|
catch { /* Unknown sidecar ownership is never cleanup authority. */ }
|
|
498
495
|
}
|
|
499
|
-
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) {
|
|
500
506
|
signal?.throwIfAborted();
|
|
501
507
|
if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
|
|
502
508
|
throw new Error("simulated failure recording endpoint owner");
|
|
503
509
|
}
|
|
504
510
|
const { pid, startTime } = processOwner;
|
|
505
|
-
|
|
506
|
-
if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
|
|
511
|
+
frame = stampFrame ?? frame;
|
|
512
|
+
if (startTime === undefined || frame.bootId === undefined || frame.pidNamespace === undefined) {
|
|
507
513
|
throw new Error("cannot certify native host process identity");
|
|
508
514
|
}
|
|
509
515
|
const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -527,7 +533,10 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
|
|
|
527
533
|
signal?.throwIfAborted();
|
|
528
534
|
// Write through the already-open inode: cancellation can unlink it without
|
|
529
535
|
// a delayed write recreating the temporary pathname.
|
|
530
|
-
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);
|
|
531
540
|
signal?.throwIfAborted();
|
|
532
541
|
await abortable(rename(temporary, `${socketPath}.owner`), signal);
|
|
533
542
|
}
|
|
@@ -563,12 +572,12 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
|
|
|
563
572
|
* identity. A live or unreadable owner keeps the endpoint unknown.
|
|
564
573
|
* No socket is removed by an inventory; publication owns reclamation.
|
|
565
574
|
*/
|
|
566
|
-
export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
|
|
575
|
+
export async function proveNativeTerminalEndpointAbsent(socketPath, failure, readFrame = readProcessFrame, ownerProbe = defaultOwnerHostProbe) {
|
|
567
576
|
const code = failure?.code;
|
|
568
577
|
if (code !== "ENOENT" && code !== "ECONNREFUSED")
|
|
569
578
|
return false;
|
|
570
579
|
await ensurePrivateSocketDirectory(socketPath);
|
|
571
|
-
return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
|
|
580
|
+
return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal, readPublicationFrame(readFrame), ownerProbe));
|
|
572
581
|
}
|
|
573
582
|
function canonicalizePath(targetPath) {
|
|
574
583
|
const resolved = resolve(targetPath);
|
|
@@ -587,7 +596,7 @@ function canonicalizePath(targetPath) {
|
|
|
587
596
|
}
|
|
588
597
|
}
|
|
589
598
|
}
|
|
590
|
-
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) {
|
|
591
600
|
if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
|
|
592
601
|
return { state: "unknown", reason: "simulated /proc observation unavailable" };
|
|
593
602
|
}
|
|
@@ -611,6 +620,15 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
611
620
|
reason: `cannot canonicalize target socket path: ${socketPath}`,
|
|
612
621
|
};
|
|
613
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
|
+
}
|
|
614
632
|
for (const entry of entries) {
|
|
615
633
|
if (!/^\d+$/.test(entry))
|
|
616
634
|
continue;
|
|
@@ -654,14 +672,24 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
654
672
|
if (!isHost)
|
|
655
673
|
continue;
|
|
656
674
|
const candidateCanonical = canonicalizePath(candidateSocket);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
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. */ }
|
|
663
681
|
}
|
|
664
|
-
|
|
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)
|
|
665
693
|
continue;
|
|
666
694
|
const candDir = dirname(candidateSocket);
|
|
667
695
|
let isStagingCandidate = false;
|
|
@@ -689,20 +717,20 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
689
717
|
};
|
|
690
718
|
}
|
|
691
719
|
let hasSocketFd = false;
|
|
692
|
-
|
|
720
|
+
const descriptorLinks = new Map();
|
|
721
|
+
let unreadableDescriptor;
|
|
693
722
|
for (const fd of fds) {
|
|
694
|
-
let link;
|
|
695
723
|
try {
|
|
696
|
-
|
|
724
|
+
descriptorLinks.set(fd, readlinkSync(join(fdDir, fd)));
|
|
697
725
|
}
|
|
698
726
|
catch (err) {
|
|
699
727
|
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
700
728
|
continue;
|
|
701
|
-
|
|
702
|
-
state: "unknown",
|
|
703
|
-
reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
|
|
704
|
-
};
|
|
729
|
+
unreadableDescriptor = `cannot readlink /proc/${pid}/fd/${fd}: ${String(err)}`;
|
|
705
730
|
}
|
|
731
|
+
}
|
|
732
|
+
let unixSockets;
|
|
733
|
+
for (const link of descriptorLinks.values()) {
|
|
706
734
|
const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
|
|
707
735
|
if (!inode)
|
|
708
736
|
continue;
|
|
@@ -722,25 +750,70 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
722
750
|
// Stdio and journal IPC are Unix socket pairs too. Only the native
|
|
723
751
|
// listener's canonical or PID-attributed staged address proves service,
|
|
724
752
|
// including a listener whose filesystem pathname has been unlinked.
|
|
725
|
-
if (!boundPath ||
|
|
726
|
-
&& isStagedSocketName(basename(boundPath), candidateSocket, pid))))
|
|
753
|
+
if (!boundPath || !boundPath.startsWith("/"))
|
|
727
754
|
continue;
|
|
728
755
|
const canonicalBound = canonicalizePath(boundPath);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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) {
|
|
733
765
|
hasSocketFd = true;
|
|
734
766
|
break;
|
|
735
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)) {
|
|
801
|
+
hasSocketFd = true;
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
if (canonicalBound !== undefined && !candidateAliased)
|
|
805
|
+
continue;
|
|
806
|
+
return { state: "unknown", reason: `cannot attribute bound socket directory for host pid ${pid}` };
|
|
736
807
|
}
|
|
808
|
+
if (!hasSocketFd && unreadableDescriptor)
|
|
809
|
+
return { state: "unknown", reason: unreadableDescriptor };
|
|
737
810
|
if (!hasSocketFd)
|
|
738
811
|
continue;
|
|
739
812
|
const startTime = readProcessStartTime(pid);
|
|
740
813
|
if (startTime === undefined) {
|
|
741
814
|
return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
|
|
742
815
|
}
|
|
743
|
-
const probe =
|
|
816
|
+
const probe = ownerProbe({ pid, startTime });
|
|
744
817
|
if (probe === "alive") {
|
|
745
818
|
return { state: "live", pid, startTime };
|
|
746
819
|
}
|
|
@@ -750,7 +823,71 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
|
|
|
750
823
|
}
|
|
751
824
|
return { state: "absent" };
|
|
752
825
|
}
|
|
753
|
-
|
|
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) {
|
|
754
891
|
signal?.throwIfAborted();
|
|
755
892
|
let socket;
|
|
756
893
|
try {
|
|
@@ -760,95 +897,135 @@ async function proveEndpointAbsentUnderLock(socketPath, signal) {
|
|
|
760
897
|
if (error.code !== "ENOENT")
|
|
761
898
|
throw error;
|
|
762
899
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
connection.setTimeout(1_000, () => { connection.destroy(); reject(new Error("terminal host connection timed out")); });
|
|
773
|
-
connection.once("connect", () => { connection.destroy(); resolve(false); });
|
|
774
|
-
connection.once("error", error => error.code === "ECONNREFUSED" ? resolve(true) : reject(error));
|
|
775
|
-
}), signal);
|
|
776
|
-
if (!refusal || !sameNativeTerminalSocket(socket, await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal)))
|
|
777
|
-
return false;
|
|
778
|
-
}
|
|
779
|
-
// Independent proof of host vivacity: a live host that lost its pathname
|
|
780
|
-
// but still serves must never allow a second writer.
|
|
781
|
-
// /proc read errors => unknown, never absent.
|
|
782
|
-
const activeScan = findActiveHostServingSocket(socketPath);
|
|
783
|
-
if (activeScan.state === "live") {
|
|
784
|
-
return false;
|
|
785
|
-
}
|
|
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");
|
|
786
909
|
if (activeScan.state === "unknown") {
|
|
787
|
-
|
|
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
|
+
});
|
|
788
916
|
}
|
|
917
|
+
let sidecar;
|
|
918
|
+
const ownerPath = `${socketPath}.owner`;
|
|
789
919
|
try {
|
|
790
|
-
const ownerPath = `${socketPath}.owner`;
|
|
791
920
|
const info = await abortable(lstat(ownerPath), signal);
|
|
792
921
|
if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
|
|
793
|
-
throw new
|
|
922
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
|
|
794
923
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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");
|
|
800
947
|
}
|
|
801
|
-
if (socket && !sameNativeTerminalSocket(socket, owner))
|
|
802
|
-
return false;
|
|
803
|
-
if (owner.bootId !== readBootId() || owner.pidNamespace !== readPidNamespaceId())
|
|
804
|
-
return false;
|
|
805
|
-
if (defaultOwnerHostProbe(owner) !== "dead")
|
|
806
|
-
return false;
|
|
807
|
-
if (socket)
|
|
808
|
-
return true;
|
|
809
|
-
// A pre-upgrade publisher may have replaced and then lost the pathname
|
|
810
|
-
// without updating this sidecar. Its durable PTY owners must also be dead.
|
|
811
948
|
}
|
|
812
949
|
catch (error) {
|
|
813
950
|
if (error.code !== "ENOENT")
|
|
814
951
|
throw error;
|
|
815
952
|
}
|
|
816
|
-
// Pre-upgrade hosts already persist this identity for each PTY. A stale
|
|
817
|
-
// socket without any attribution is unprovable; never infer death from
|
|
818
|
-
// a bare PID, a socket's age, or an empty/error-collapsed registry.
|
|
819
953
|
const snapshot = listNativeTerminalPgidEntries();
|
|
820
|
-
if (!snapshot.known) {
|
|
821
|
-
// This decoder deliberately calls an ENOENT registry "unknown" for
|
|
822
|
-
// orphan reaping. For endpoint discovery, no file means no attribution;
|
|
823
|
-
// it proves absence only when the socket is also absent under the lock.
|
|
824
|
-
if (snapshot.reason === "registry is absent")
|
|
825
|
-
return socket === undefined;
|
|
826
|
-
throw new Error(`native endpoint owner registry is unknown: ${snapshot.reason}`);
|
|
827
|
-
}
|
|
828
|
-
const canonicalTarget = canonicalizePath(socketPath);
|
|
829
|
-
if (!canonicalTarget) {
|
|
830
|
-
throw new Error(`cannot canonicalize socket path: ${socketPath}`);
|
|
831
|
-
}
|
|
832
|
-
const owners = snapshot.entries
|
|
833
|
-
.filter(entry => entry.owner?.socketPath && canonicalizePath(entry.owner.socketPath) === canonicalTarget)
|
|
834
|
-
.map(entry => entry.owner);
|
|
835
954
|
const registry = loadRegistry();
|
|
955
|
+
if (!snapshot.known && snapshot.reason !== "registry is absent")
|
|
956
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
|
|
836
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"))
|
|
837
974
|
return false;
|
|
838
|
-
|
|
839
|
-
|
|
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))
|
|
840
993
|
return false;
|
|
841
|
-
|
|
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)
|
|
1009
|
+
return false;
|
|
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)
|
|
842
1019
|
return false;
|
|
843
|
-
if (socket &&
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
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 });
|
|
848
1025
|
}
|
|
849
|
-
return
|
|
1026
|
+
return true;
|
|
850
1027
|
}
|
|
851
|
-
async function removeStaleSocket(socketPath, signal) {
|
|
1028
|
+
async function removeStaleSocket(socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
|
|
852
1029
|
for (;;) {
|
|
853
1030
|
signal?.throwIfAborted();
|
|
854
1031
|
let stale;
|
|
@@ -860,11 +1037,8 @@ async function removeStaleSocket(socketPath, signal) {
|
|
|
860
1037
|
return;
|
|
861
1038
|
throw error;
|
|
862
1039
|
}
|
|
863
|
-
if (await
|
|
864
|
-
throw new
|
|
865
|
-
}
|
|
866
|
-
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
867
|
-
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");
|
|
868
1042
|
}
|
|
869
1043
|
try {
|
|
870
1044
|
const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
@@ -958,7 +1132,7 @@ function stagingSocketPath(socketPath) {
|
|
|
958
1132
|
assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
|
|
959
1133
|
return stagedPath;
|
|
960
1134
|
}
|
|
961
|
-
async function publishSocket(stagedPath, socketPath, signal) {
|
|
1135
|
+
async function publishSocket(stagedPath, socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
|
|
962
1136
|
signal.throwIfAborted();
|
|
963
1137
|
const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
|
|
964
1138
|
try {
|
|
@@ -967,8 +1141,8 @@ async function publishSocket(stagedPath, socketPath, signal) {
|
|
|
967
1141
|
catch (error) {
|
|
968
1142
|
if (error.code !== "ENOENT")
|
|
969
1143
|
throw error;
|
|
970
|
-
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
971
|
-
throw new
|
|
1144
|
+
if (!await proveEndpointAbsentUnderLock(socketPath, signal, frame, ownerProbe)) {
|
|
1145
|
+
throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_UNPROVEN");
|
|
972
1146
|
}
|
|
973
1147
|
}
|
|
974
1148
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
@@ -985,7 +1159,7 @@ async function publishSocket(stagedPath, socketPath, signal) {
|
|
|
985
1159
|
catch (error) {
|
|
986
1160
|
if (error.code !== "EEXIST")
|
|
987
1161
|
throw error;
|
|
988
|
-
await removeStaleSocket(socketPath, signal);
|
|
1162
|
+
await removeStaleSocket(socketPath, signal, frame, ownerProbe);
|
|
989
1163
|
}
|
|
990
1164
|
}
|
|
991
1165
|
throw new Error("terminal host socket publication retry limit exceeded");
|
|
@@ -1005,7 +1179,7 @@ const LISTENER_EXHAUSTION_WINDOW_MS = 60_000;
|
|
|
1005
1179
|
const UNUSABLE_LISTENER_ERRNOS = new Set(["ERR_SERVER_CLOSED", "EADDRINUSE", "EADDRNOTAVAIL", "EBADF", "EINVAL", "ENOTSOCK", "EACCES"]);
|
|
1006
1180
|
export async function startNativeTerminalHostServer(options) {
|
|
1007
1181
|
const deadline = new AbortController();
|
|
1008
|
-
const timer = setTimeout(() => deadline.abort(new
|
|
1182
|
+
const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_SOCKET_PUBLICATION_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
1009
1183
|
const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
|
|
1010
1184
|
try {
|
|
1011
1185
|
return await initializeNativeTerminalHostServer({ ...options, signal });
|
|
@@ -1067,13 +1241,27 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
1067
1241
|
sockets.delete(socket);
|
|
1068
1242
|
});
|
|
1069
1243
|
});
|
|
1070
|
-
|
|
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
|
+
};
|
|
1071
1255
|
let ownedSocket;
|
|
1072
1256
|
// Assigned once the published close is built; the listener-failure handler
|
|
1073
1257
|
// (installed before that object exists) tears the server down through it.
|
|
1074
1258
|
let closePublished;
|
|
1075
1259
|
let initializationFailure;
|
|
1076
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);
|
|
1077
1265
|
await abortable(new Promise((resolve, reject) => {
|
|
1078
1266
|
server.once("error", reject);
|
|
1079
1267
|
server.listen(stagedPath, () => {
|
|
@@ -1142,12 +1330,16 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
1142
1330
|
};
|
|
1143
1331
|
// Capture the listener's inode before any publication await can suspend.
|
|
1144
1332
|
ownedSocket = lstatSync(stagedPath);
|
|
1333
|
+
if (!sameNativeTerminalSocket(fstatSync(directoryFd), statSync(dirname(stagedPath)))) {
|
|
1334
|
+
throw new Error("terminal host socket directory changed during bind");
|
|
1335
|
+
}
|
|
1145
1336
|
await abortable(chmod(stagedPath, 0o600), options.signal);
|
|
1146
1337
|
await withSocketPublicationLock(options.socketPath, async (signal) => {
|
|
1147
1338
|
try {
|
|
1148
1339
|
signal.throwIfAborted();
|
|
1149
|
-
const
|
|
1150
|
-
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);
|
|
1151
1343
|
return published;
|
|
1152
1344
|
}
|
|
1153
1345
|
catch (pubError) {
|
|
@@ -1163,14 +1355,19 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
1163
1355
|
}
|
|
1164
1356
|
}
|
|
1165
1357
|
catch (error) {
|
|
1166
|
-
|
|
1167
|
-
socket
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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();
|
|
1172
1370
|
}
|
|
1173
|
-
await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
1174
1371
|
throw error;
|
|
1175
1372
|
}
|
|
1176
1373
|
readyForRequests = true;
|
|
@@ -1208,7 +1405,7 @@ async function initializeNativeTerminalHostServer(options) {
|
|
|
1208
1405
|
});
|
|
1209
1406
|
if (stopError !== undefined)
|
|
1210
1407
|
throw stopError;
|
|
1211
|
-
})();
|
|
1408
|
+
})().finally(closeDirectory);
|
|
1212
1409
|
return closing;
|
|
1213
1410
|
};
|
|
1214
1411
|
closePublished = doClose;
|