@sentropic/h2a-runtime 0.98.1 → 0.98.3
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/agent-launch-args.d.ts +1 -1
- package/dist/agent-launch-args.d.ts.map +1 -1
- package/dist/agent-launch-args.js +16 -1
- package/dist/agent-launch-args.js.map +1 -1
- package/dist/auth-bundle.d.ts.map +1 -1
- package/dist/auth-bundle.js +1 -0
- package/dist/auth-bundle.js.map +1 -1
- package/dist/cli-help-groups.d.ts.map +1 -1
- package/dist/cli-help-groups.js +1 -0
- package/dist/cli-help-groups.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +14 -2
- package/dist/delegate.js.map +1 -1
- package/dist/gateway-host/config-file.d.ts +19 -0
- package/dist/gateway-host/config-file.d.ts.map +1 -0
- package/dist/gateway-host/config-file.js +54 -0
- package/dist/gateway-host/config-file.js.map +1 -0
- package/dist/gateway-host/daemon.d.ts +61 -0
- package/dist/gateway-host/daemon.d.ts.map +1 -0
- package/dist/gateway-host/daemon.js +274 -0
- package/dist/gateway-host/daemon.js.map +1 -0
- package/dist/gateway-host/host.d.ts +34 -0
- package/dist/gateway-host/host.d.ts.map +1 -0
- package/dist/gateway-host/host.js +228 -0
- package/dist/gateway-host/host.js.map +1 -0
- package/dist/{llm-gateway-runtime/session-ledger.d.ts → gateway-host/ledger.d.ts} +4 -4
- package/dist/gateway-host/ledger.d.ts.map +1 -0
- package/dist/{llm-gateway-runtime/session-ledger.js → gateway-host/ledger.js} +1 -1
- package/dist/gateway-host/ledger.js.map +1 -0
- package/dist/{llm-gateway-runtime/sticky.d.ts → gateway-host/sessions.d.ts} +3 -1
- package/dist/gateway-host/sessions.d.ts.map +1 -0
- package/dist/{llm-gateway-runtime/sticky.js → gateway-host/sessions.js} +6 -1
- package/dist/gateway-host/sessions.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -18
- package/dist/index.js.map +1 -1
- package/dist/llm-mesh-accounts.d.ts +55 -0
- package/dist/llm-mesh-accounts.d.ts.map +1 -0
- package/dist/llm-mesh-accounts.js +188 -0
- package/dist/llm-mesh-accounts.js.map +1 -0
- package/dist/native-host.d.ts +1 -1
- package/dist/native-host.d.ts.map +1 -1
- package/dist/native-host.js +84 -21
- package/dist/native-host.js.map +1 -1
- package/dist/native-terminal/attach-recovery.d.ts +9 -0
- package/dist/native-terminal/attach-recovery.d.ts.map +1 -0
- package/dist/native-terminal/attach-recovery.js +54 -0
- package/dist/native-terminal/attach-recovery.js.map +1 -0
- package/dist/native-terminal/fleet.d.ts +2 -1
- package/dist/native-terminal/fleet.d.ts.map +1 -1
- package/dist/native-terminal/fleet.js +10 -1
- package/dist/native-terminal/fleet.js.map +1 -1
- package/dist/native-terminal/host.d.ts +1 -0
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +5 -2
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/journal.d.ts +48 -0
- package/dist/native-terminal/journal.d.ts.map +1 -0
- package/dist/native-terminal/journal.js +305 -0
- package/dist/native-terminal/journal.js.map +1 -0
- package/dist/native-terminal/op.d.ts +3 -0
- package/dist/native-terminal/op.d.ts.map +1 -1
- package/dist/native-terminal/op.js +148 -33
- 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 +134 -46
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/replay-buffer.d.ts +3 -0
- package/dist/native-terminal/replay-buffer.d.ts.map +1 -1
- package/dist/native-terminal/replay-buffer.js +11 -0
- package/dist/native-terminal/replay-buffer.js.map +1 -1
- package/dist/native-terminal/reset-relay.d.ts +11 -0
- package/dist/native-terminal/reset-relay.d.ts.map +1 -0
- package/dist/native-terminal/reset-relay.js +38 -0
- package/dist/native-terminal/reset-relay.js.map +1 -0
- package/dist/native-terminal/server.d.ts +8 -0
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +535 -58
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/terminal-modes.d.ts +15 -0
- package/dist/native-terminal/terminal-modes.d.ts.map +1 -0
- package/dist/native-terminal/terminal-modes.js +180 -0
- package/dist/native-terminal/terminal-modes.js.map +1 -0
- package/dist/profile-menu.d.ts.map +1 -1
- package/dist/profile-menu.js +2 -0
- package/dist/profile-menu.js.map +1 -1
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.d.ts.map +1 -1
- package/dist/profiles.js +6 -0
- package/dist/profiles.js.map +1 -1
- package/dist/protocol-local.d.ts +1 -1
- package/dist/protocol-local.d.ts.map +1 -1
- package/dist/protocol-local.js +1 -0
- package/dist/protocol-local.js.map +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +1 -1
- package/dist/restore.d.ts.map +1 -1
- package/dist/{llm-routing-config.d.ts → routing-preferences.d.ts} +2 -2
- package/dist/routing-preferences.d.ts.map +1 -0
- package/dist/{llm-routing-config.js → routing-preferences.js} +16 -13
- package/dist/routing-preferences.js.map +1 -0
- package/dist/sentropic/auth-token.d.ts +1 -1
- package/dist/sentropic/auth-token.js +1 -1
- package/dist/status-projection.d.ts +1 -1
- package/dist/status-projection.d.ts.map +1 -1
- package/dist/throttle-signatures.d.ts.map +1 -1
- package/dist/throttle-signatures.js +3 -0
- package/dist/throttle-signatures.js.map +1 -1
- package/dist/tmux.d.ts +17 -1
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +10 -4
- package/dist/tmux.js.map +1 -1
- package/package.json +8 -4
- package/dist/llm-gateway-runtime/index.d.ts +0 -10
- package/dist/llm-gateway-runtime/index.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/index.js +0 -147
- package/dist/llm-gateway-runtime/index.js.map +0 -1
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/session-ledger.js.map +0 -1
- package/dist/llm-gateway-runtime/sticky.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/sticky.js.map +0 -1
- package/dist/llm-mesh.d.ts +0 -154
- package/dist/llm-mesh.d.ts.map +0 -1
- package/dist/llm-mesh.js +0 -490
- package/dist/llm-mesh.js.map +0 -1
- package/dist/llm-routing-config.d.ts.map +0 -1
- package/dist/llm-routing-config.js.map +0 -1
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { fstatSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
4
|
import { createConnection, createServer } from "node:net";
|
|
4
|
-
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
|
-
import { NativeTerminalHost, } from "./host.js";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from "./host.js";
|
|
6
7
|
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";
|
|
7
8
|
import { assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
|
|
9
|
+
import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
|
|
10
|
+
import { readBootId, readPidNamespaceId } from "../proc-identity.js";
|
|
8
11
|
const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
|
|
9
12
|
const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
|
|
10
13
|
const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
|
|
14
|
+
const SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS = 100;
|
|
15
|
+
async function abortable(operation, signal) {
|
|
16
|
+
if (signal === undefined)
|
|
17
|
+
return operation;
|
|
18
|
+
let aborted;
|
|
19
|
+
try {
|
|
20
|
+
const result = await Promise.race([
|
|
21
|
+
operation,
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
aborted = () => reject(signal.reason);
|
|
24
|
+
if (signal.aborted)
|
|
25
|
+
aborted();
|
|
26
|
+
else
|
|
27
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
if (aborted !== undefined)
|
|
35
|
+
signal.removeEventListener("abort", aborted);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
11
38
|
function requiredRecord(value, label) {
|
|
12
39
|
if (!isRecord(value))
|
|
13
40
|
throw new TypeError(`${label} must be an object`);
|
|
@@ -323,17 +350,20 @@ function consumeFrames(host, context, chunk) {
|
|
|
323
350
|
context.socket.destroy();
|
|
324
351
|
}
|
|
325
352
|
}
|
|
326
|
-
async function socketAcceptsConnections(socketPath) {
|
|
327
|
-
return new Promise((resolve) => {
|
|
353
|
+
async function socketAcceptsConnections(socketPath, signal) {
|
|
354
|
+
return abortable(new Promise((resolve) => {
|
|
328
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));
|
|
329
359
|
socket.once("connect", () => {
|
|
330
360
|
socket.destroy();
|
|
331
361
|
resolve(true);
|
|
332
362
|
});
|
|
333
363
|
socket.once("error", () => resolve(false));
|
|
334
|
-
});
|
|
364
|
+
}), signal);
|
|
335
365
|
}
|
|
336
|
-
async function socketPublicationLockSecret(socketPath) {
|
|
366
|
+
async function socketPublicationLockSecret(socketPath, signal) {
|
|
337
367
|
if (process.platform !== "linux" || process.getuid === undefined) {
|
|
338
368
|
throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
|
|
339
369
|
}
|
|
@@ -341,13 +371,14 @@ async function socketPublicationLockSecret(socketPath) {
|
|
|
341
371
|
const identityPath = join(directory, SOCKET_PUBLICATION_LOCK_ID_FILE);
|
|
342
372
|
const temporaryPath = join(directory, `.${SOCKET_PUBLICATION_LOCK_ID_FILE}.${process.pid}.${randomUUID()}.tmp`);
|
|
343
373
|
try {
|
|
344
|
-
await writeFile(temporaryPath, `${randomUUID()}\n`, {
|
|
374
|
+
await abortable(writeFile(temporaryPath, `${randomUUID()}\n`, {
|
|
345
375
|
flag: "wx",
|
|
346
376
|
mode: 0o600,
|
|
347
|
-
|
|
348
|
-
|
|
377
|
+
signal,
|
|
378
|
+
}), signal);
|
|
379
|
+
await abortable(chmod(temporaryPath, 0o600), signal);
|
|
349
380
|
try {
|
|
350
|
-
await link(temporaryPath, identityPath);
|
|
381
|
+
await abortable(link(temporaryPath, identityPath), signal);
|
|
351
382
|
}
|
|
352
383
|
catch (error) {
|
|
353
384
|
if (error.code !== "EEXIST")
|
|
@@ -356,82 +387,484 @@ async function socketPublicationLockSecret(socketPath) {
|
|
|
356
387
|
}
|
|
357
388
|
finally {
|
|
358
389
|
try {
|
|
359
|
-
await unlink(temporaryPath);
|
|
390
|
+
await abortable(unlink(temporaryPath), signal);
|
|
360
391
|
}
|
|
361
392
|
catch (error) {
|
|
362
|
-
if (
|
|
393
|
+
if (signal?.aborted)
|
|
394
|
+
unlinkIfPresent(temporaryPath);
|
|
395
|
+
else if (error.code !== "ENOENT")
|
|
363
396
|
throw error;
|
|
364
397
|
}
|
|
365
398
|
}
|
|
366
|
-
const identity = await lstat(identityPath);
|
|
399
|
+
const identity = await abortable(lstat(identityPath), signal);
|
|
367
400
|
if (!identity.isFile()
|
|
368
401
|
|| identity.isSymbolicLink()
|
|
369
402
|
|| identity.uid !== process.getuid()
|
|
370
403
|
|| (identity.mode & 0o777) !== 0o600) {
|
|
371
404
|
throw new Error(`terminal socket publication lock identity is not a private owned file: ${identityPath}`);
|
|
372
405
|
}
|
|
373
|
-
const secret = (await readFile(identityPath, "utf8")).trim();
|
|
406
|
+
const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
|
|
374
407
|
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)) {
|
|
375
408
|
throw new Error(`terminal socket publication lock identity is malformed: ${identityPath}`);
|
|
376
409
|
}
|
|
377
410
|
return secret;
|
|
378
411
|
}
|
|
379
|
-
async function acquireSocketPublicationLock(socketPath) {
|
|
380
|
-
const secret = await socketPublicationLockSecret(socketPath);
|
|
412
|
+
async function acquireSocketPublicationLock(socketPath, signal) {
|
|
413
|
+
const secret = await socketPublicationLockSecret(socketPath, signal);
|
|
381
414
|
const address = `\0h2a-terminal-lock-${createHash("sha256")
|
|
382
415
|
.update(`${process.getuid?.() ?? "unknown"}:${socketPath}:${secret}`)
|
|
383
416
|
.digest("hex")}`;
|
|
384
417
|
const deadline = Date.now() + SOCKET_PUBLICATION_LOCK_TIMEOUT_MS;
|
|
385
418
|
for (;;) {
|
|
386
|
-
|
|
419
|
+
signal?.throwIfAborted();
|
|
420
|
+
const lock = createServer(socket => socket.destroy());
|
|
387
421
|
try {
|
|
388
|
-
await new Promise((resolve, reject) => {
|
|
422
|
+
await abortable(new Promise((resolve, reject) => {
|
|
389
423
|
lock.once("error", reject);
|
|
390
424
|
lock.listen(address, () => {
|
|
391
425
|
lock.removeListener("error", reject);
|
|
392
426
|
resolve();
|
|
393
427
|
});
|
|
394
|
-
});
|
|
428
|
+
}), signal);
|
|
395
429
|
return lock;
|
|
396
430
|
}
|
|
397
431
|
catch (error) {
|
|
432
|
+
lock.close();
|
|
398
433
|
if (error.code !== "EADDRINUSE"
|
|
399
434
|
|| Date.now() >= deadline) {
|
|
400
435
|
throw error;
|
|
401
436
|
}
|
|
402
|
-
await new Promise((resolve) => setTimeout(resolve, SOCKET_PUBLICATION_LOCK_RETRY_MS));
|
|
437
|
+
await abortable(new Promise((resolve) => setTimeout(resolve, SOCKET_PUBLICATION_LOCK_RETRY_MS)), signal);
|
|
403
438
|
}
|
|
404
439
|
}
|
|
405
440
|
}
|
|
406
|
-
async function withSocketPublicationLock(socketPath, operation) {
|
|
407
|
-
const
|
|
441
|
+
async function withSocketPublicationLock(socketPath, operation, signal) {
|
|
442
|
+
const deadline = new AbortController();
|
|
443
|
+
const timer = setTimeout(() => deadline.abort(new Error("terminal socket publication lock timed out")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
444
|
+
const boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
|
|
408
445
|
try {
|
|
409
|
-
|
|
446
|
+
const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
|
|
447
|
+
try {
|
|
448
|
+
boundedSignal.throwIfAborted();
|
|
449
|
+
return await operation(boundedSignal);
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
await abortable(new Promise((resolve, reject) => {
|
|
453
|
+
lock.close((error) => error && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve());
|
|
454
|
+
}), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
455
|
+
}
|
|
410
456
|
}
|
|
411
457
|
finally {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function unlinkIfPresent(path) {
|
|
462
|
+
try {
|
|
463
|
+
unlinkSync(path);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (error.code !== "ENOENT")
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function unlinkOwnedPath(path, owned) {
|
|
471
|
+
try {
|
|
472
|
+
const current = lstatSync(path);
|
|
473
|
+
if (sameNativeTerminalSocket(current, owned))
|
|
474
|
+
unlinkSync(path);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (error.code !== "ENOENT")
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function rollbackEndpoint(socketPath, owned) {
|
|
482
|
+
unlinkOwnedPath(socketPath, owned);
|
|
483
|
+
const ownerPath = `${socketPath}.owner`;
|
|
484
|
+
try {
|
|
485
|
+
const identity = lstatSync(ownerPath);
|
|
486
|
+
if (!identity.isFile() || identity.uid !== process.getuid?.() || (identity.mode & 0o777) !== 0o600)
|
|
487
|
+
return;
|
|
488
|
+
const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
489
|
+
if (owner.pid === process.pid && owner.startTime === readProcessStartTime(process.pid)
|
|
490
|
+
&& sameNativeTerminalSocket(owner, owned))
|
|
491
|
+
unlinkOwnedPath(ownerPath, identity);
|
|
492
|
+
}
|
|
493
|
+
catch { /* Unknown sidecar ownership is never cleanup authority. */ }
|
|
494
|
+
}
|
|
495
|
+
async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal) {
|
|
496
|
+
signal?.throwIfAborted();
|
|
497
|
+
if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
|
|
498
|
+
throw new Error("simulated failure recording endpoint owner");
|
|
499
|
+
}
|
|
500
|
+
const { pid, startTime } = processOwner;
|
|
501
|
+
const bootId = readBootId(), pidNamespace = readPidNamespaceId();
|
|
502
|
+
if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
|
|
503
|
+
throw new Error("cannot certify native host process identity");
|
|
504
|
+
}
|
|
505
|
+
const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
|
|
506
|
+
let file;
|
|
507
|
+
try {
|
|
508
|
+
const openedFile = await abortable(open(temporary, "wx", 0o600).then((opened) => {
|
|
509
|
+
if (signal?.aborted) {
|
|
510
|
+
// A cancelled open may finish after rollback. Its unique pathname and
|
|
511
|
+
// descriptor still belong to this publication, never to a successor.
|
|
512
|
+
try {
|
|
513
|
+
unlinkOwnedPath(temporary, fstatSync(opened.fd));
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
void opened.close().catch(() => { });
|
|
517
|
+
}
|
|
518
|
+
signal.throwIfAborted();
|
|
519
|
+
}
|
|
520
|
+
file = opened;
|
|
521
|
+
return opened;
|
|
522
|
+
}), signal);
|
|
523
|
+
signal?.throwIfAborted();
|
|
524
|
+
// Write through the already-open inode: cancellation can unlink it without
|
|
525
|
+
// a delayed write recreating the temporary pathname.
|
|
526
|
+
await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId, pidNamespace }), signal === undefined ? {} : { signal }), signal);
|
|
527
|
+
signal?.throwIfAborted();
|
|
528
|
+
await abortable(rename(temporary, `${socketPath}.owner`), signal);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
// Invalidate the rename source before releasing the publication lock. A
|
|
532
|
+
// delayed rename either already committed (and is rolled back by its
|
|
533
|
+
// publisher) or can no longer install an owner after cancellation.
|
|
534
|
+
try {
|
|
535
|
+
await abortable(unlink(temporary).catch(error => { if (error.code !== "ENOENT")
|
|
536
|
+
throw error; }), signal);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (!signal?.aborted)
|
|
540
|
+
throw error;
|
|
541
|
+
// No asynchronous rollback can be left targeting a shared pathname.
|
|
542
|
+
// Invalidate the unique source now, before the exclusion lock is closed.
|
|
543
|
+
if (file !== undefined)
|
|
544
|
+
unlinkOwnedPath(temporary, fstatSync(file.fd));
|
|
545
|
+
else
|
|
546
|
+
unlinkIfPresent(temporary);
|
|
547
|
+
signal.throwIfAborted();
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
// An aborted write may still be unwinding. Its descriptor owns only the
|
|
551
|
+
// unlinked inode and must not hold up listener/lock cleanup.
|
|
552
|
+
void file?.close().catch(() => { });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* ENOENT/ECONNREFUSED are candidates, never proof by themselves. Serialize
|
|
558
|
+
* with publication and re-read both the socket and its durable process
|
|
559
|
+
* identity. A live or unreadable owner keeps the endpoint unknown.
|
|
560
|
+
* No socket is removed by an inventory; publication owns reclamation.
|
|
561
|
+
*/
|
|
562
|
+
export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
|
|
563
|
+
const code = failure?.code;
|
|
564
|
+
if (code !== "ENOENT" && code !== "ECONNREFUSED")
|
|
565
|
+
return false;
|
|
566
|
+
await ensurePrivateSocketDirectory(socketPath);
|
|
567
|
+
return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
|
|
568
|
+
}
|
|
569
|
+
function canonicalizePath(targetPath) {
|
|
570
|
+
const resolved = resolve(targetPath);
|
|
571
|
+
try {
|
|
572
|
+
return realpathSync(resolved);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
const dir = dirname(resolved);
|
|
576
|
+
const base = basename(resolved);
|
|
577
|
+
try {
|
|
578
|
+
const realDir = realpathSync(dir);
|
|
579
|
+
return join(realDir, base);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc") {
|
|
587
|
+
if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
|
|
588
|
+
return { state: "unknown", reason: "simulated /proc observation unavailable" };
|
|
589
|
+
}
|
|
590
|
+
if (process.platform !== "linux") {
|
|
591
|
+
return { state: "unknown", reason: "platform does not support /proc host observation" };
|
|
592
|
+
}
|
|
593
|
+
let entries;
|
|
594
|
+
try {
|
|
595
|
+
entries = readdirSync(procRoot);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
return {
|
|
599
|
+
state: "unknown",
|
|
600
|
+
reason: `cannot read ${procRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const targetCanonical = canonicalizePath(socketPath);
|
|
604
|
+
if (!targetCanonical) {
|
|
605
|
+
return {
|
|
606
|
+
state: "unknown",
|
|
607
|
+
reason: `cannot canonicalize target socket path: ${socketPath}`,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
for (const entry of entries) {
|
|
611
|
+
if (!/^\d+$/.test(entry))
|
|
612
|
+
continue;
|
|
613
|
+
const pid = Number(entry);
|
|
614
|
+
if (pid === process.pid)
|
|
615
|
+
continue;
|
|
616
|
+
const pidDir = join(procRoot, entry);
|
|
617
|
+
let procStat;
|
|
618
|
+
try {
|
|
619
|
+
procStat = statSync(pidDir);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
623
|
+
continue;
|
|
624
|
+
return {
|
|
625
|
+
state: "unknown",
|
|
626
|
+
reason: `cannot stat /proc/${pid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (process.getuid && procStat.uid !== process.getuid()) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
let cmdline;
|
|
633
|
+
try {
|
|
634
|
+
cmdline = readFileSync(join(pidDir, "cmdline"), "utf8");
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
638
|
+
continue;
|
|
639
|
+
return {
|
|
640
|
+
state: "unknown",
|
|
641
|
+
reason: `cannot read /proc/${pid}/cmdline: ${err instanceof Error ? err.message : String(err)}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const args = cmdline.split("\0");
|
|
645
|
+
const idx = args.indexOf("--socket");
|
|
646
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
647
|
+
continue;
|
|
648
|
+
const candidateSocket = args[idx + 1];
|
|
649
|
+
const isHost = args.some(arg => arg.includes("process.js") || (arg.includes("native-terminal") && !arg.includes("op.js")));
|
|
650
|
+
if (!isHost)
|
|
651
|
+
continue;
|
|
652
|
+
const candidateCanonical = canonicalizePath(candidateSocket);
|
|
653
|
+
if (!candidateCanonical) {
|
|
654
|
+
// The alias used at host startup was deleted; canonicalization cannot prove absence
|
|
655
|
+
return {
|
|
656
|
+
state: "unknown",
|
|
657
|
+
reason: `cannot canonicalize host socket path ${candidateSocket} for host pid ${pid}`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
if (candidateCanonical !== targetCanonical)
|
|
661
|
+
continue;
|
|
662
|
+
const candDir = dirname(candidateSocket);
|
|
663
|
+
const candStem = basename(candidateSocket).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
664
|
+
let isStagingCandidate = false;
|
|
665
|
+
try {
|
|
666
|
+
const dirEntries = readdirSync(candDir);
|
|
667
|
+
isStagingCandidate = dirEntries.some(name => name.startsWith(`.${candStem}.${pid}.`));
|
|
668
|
+
}
|
|
669
|
+
catch { }
|
|
670
|
+
if (isStagingCandidate)
|
|
671
|
+
continue;
|
|
672
|
+
const fdDir = join(pidDir, "fd");
|
|
673
|
+
let fds;
|
|
674
|
+
try {
|
|
675
|
+
fds = readdirSync(fdDir);
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
679
|
+
continue;
|
|
680
|
+
return {
|
|
681
|
+
state: "unknown",
|
|
682
|
+
reason: `cannot inspect /proc/${pid}/fd: ${err instanceof Error ? err.message : String(err)}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
let hasSocketFd = false;
|
|
686
|
+
let unixSockets;
|
|
687
|
+
for (const fd of fds) {
|
|
688
|
+
let link;
|
|
689
|
+
try {
|
|
690
|
+
link = readlinkSync(join(fdDir, fd));
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
if (err.code === "ENOENT" || err.code === "ESRCH")
|
|
694
|
+
continue;
|
|
695
|
+
return {
|
|
696
|
+
state: "unknown",
|
|
697
|
+
reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
|
|
701
|
+
if (!inode)
|
|
702
|
+
continue;
|
|
703
|
+
if (!unixSockets) {
|
|
704
|
+
try {
|
|
705
|
+
unixSockets = new Map(readFileSync(join(pidDir, "net", "unix"), "utf8")
|
|
706
|
+
.split("\n").flatMap(line => {
|
|
707
|
+
const row = /^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\d+)(?:\s+(.*))?$/.exec(line);
|
|
708
|
+
return row?.[2] ? [[row[1], row[2]]] : [];
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
return { state: "unknown", reason: `cannot inspect /proc/${pid}/net/unix: ${String(error)}` };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const boundPath = unixSockets.get(inode);
|
|
716
|
+
// Stdio and journal IPC are Unix socket pairs too. Only the native
|
|
717
|
+
// listener's canonical or PID-attributed staged address proves service,
|
|
718
|
+
// including a listener whose filesystem pathname has been unlinked.
|
|
719
|
+
if (!boundPath || (boundPath !== candidateSocket && !(dirname(boundPath) === candDir
|
|
720
|
+
&& basename(boundPath).startsWith(`.${candStem}.${pid}.`) && boundPath.endsWith(".sock"))))
|
|
721
|
+
continue;
|
|
722
|
+
const canonicalBound = canonicalizePath(boundPath);
|
|
723
|
+
if (!canonicalBound)
|
|
724
|
+
return { state: "unknown", reason: `cannot canonicalize bound socket for host pid ${pid}` };
|
|
725
|
+
if (canonicalBound === targetCanonical || (dirname(canonicalBound) === dirname(targetCanonical)
|
|
726
|
+
&& basename(canonicalBound).startsWith(`.${candStem}.${pid}.`))) {
|
|
727
|
+
hasSocketFd = true;
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (!hasSocketFd)
|
|
732
|
+
continue;
|
|
733
|
+
const startTime = readProcessStartTime(pid);
|
|
734
|
+
if (startTime === undefined) {
|
|
735
|
+
return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
|
|
736
|
+
}
|
|
737
|
+
const probe = defaultOwnerHostProbe({ pid, startTime });
|
|
738
|
+
if (probe === "alive") {
|
|
739
|
+
return { state: "live", pid, startTime };
|
|
740
|
+
}
|
|
741
|
+
if (probe === "unresolvable") {
|
|
742
|
+
return { state: "unknown", reason: `host pid ${pid} liveness probe is unresolvable` };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return { state: "absent" };
|
|
746
|
+
}
|
|
747
|
+
async function proveEndpointAbsentUnderLock(socketPath, signal) {
|
|
748
|
+
signal?.throwIfAborted();
|
|
749
|
+
let socket;
|
|
750
|
+
try {
|
|
751
|
+
socket = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (error.code !== "ENOENT")
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
if (socket) {
|
|
758
|
+
// A listener may have appeared since the failed inventory connection.
|
|
759
|
+
// Only an explicit refusal qualifies; permissions and all other errors
|
|
760
|
+
// remain unknown, including a changed socket incarnation.
|
|
761
|
+
const refusal = await abortable(new Promise((resolve, reject) => {
|
|
762
|
+
const connection = createConnection(socketPath);
|
|
763
|
+
const cancel = () => connection.destroy();
|
|
764
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
765
|
+
connection.once("close", () => signal?.removeEventListener("abort", cancel));
|
|
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
|
+
}
|
|
780
|
+
if (activeScan.state === "unknown") {
|
|
781
|
+
throw new Error(`terminal host state is unprovable (${activeScan.reason}): ${socketPath}`);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const ownerPath = `${socketPath}.owner`;
|
|
785
|
+
const info = await abortable(lstat(ownerPath), signal);
|
|
786
|
+
if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
|
|
787
|
+
throw new Error(`native endpoint owner identity is not a private owned file: ${ownerPath}`);
|
|
788
|
+
}
|
|
789
|
+
const owner = JSON.parse(await abortable(readFile(ownerPath, "utf8"), signal));
|
|
790
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || !Number.isSafeInteger(owner.startTime) ||
|
|
791
|
+
owner.startTime < 0 || !Number.isSafeInteger(owner.dev) || !Number.isSafeInteger(owner.ino) ||
|
|
792
|
+
typeof owner.bootId !== "string" || typeof owner.pidNamespace !== "string") {
|
|
793
|
+
throw new Error(`native endpoint owner identity is malformed: ${ownerPath}`);
|
|
794
|
+
}
|
|
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
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
if (error.code !== "ENOENT")
|
|
808
|
+
throw error;
|
|
415
809
|
}
|
|
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
|
+
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
|
+
const registry = loadRegistry();
|
|
830
|
+
if (registry.state === "unknown" || registry.unreadable.length > 0)
|
|
831
|
+
return false;
|
|
832
|
+
const rows = registry.entries.filter(entry => entry.ownerHostSocketPath && canonicalizePath(entry.ownerHostSocketPath) === canonicalTarget);
|
|
833
|
+
if (rows.some(row => row.pgidBootId !== readBootId() || row.pgidPidNamespace !== readPidNamespaceId()))
|
|
834
|
+
return false;
|
|
835
|
+
if (owners.some(owner => owner.startTime === undefined || defaultOwnerHostProbe(owner) !== "dead"))
|
|
836
|
+
return false;
|
|
837
|
+
if (socket && owners.length > 0) {
|
|
838
|
+
// Preserve the death proof before the supervisor reaps/prunes the last
|
|
839
|
+
// pre-upgrade PTY row. Reclamation must still have attribution then.
|
|
840
|
+
const owner = owners[0];
|
|
841
|
+
await recordEndpointOwner(socketPath, socket, { pid: owner.pid, startTime: owner.startTime }, signal);
|
|
842
|
+
}
|
|
843
|
+
return socket === undefined || owners.length > 0;
|
|
416
844
|
}
|
|
417
|
-
async function removeStaleSocket(socketPath) {
|
|
845
|
+
async function removeStaleSocket(socketPath, signal) {
|
|
418
846
|
for (;;) {
|
|
847
|
+
signal?.throwIfAborted();
|
|
419
848
|
let stale;
|
|
420
849
|
try {
|
|
421
|
-
stale = await inspectPrivateNativeTerminalSocket(socketPath);
|
|
850
|
+
stale = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
422
851
|
}
|
|
423
852
|
catch (error) {
|
|
424
853
|
if (error.code === "ENOENT")
|
|
425
854
|
return;
|
|
426
855
|
throw error;
|
|
427
856
|
}
|
|
428
|
-
if (await socketAcceptsConnections(socketPath)) {
|
|
857
|
+
if (await socketAcceptsConnections(socketPath, signal)) {
|
|
429
858
|
throw new Error(`terminal host socket is already active: ${socketPath}`);
|
|
430
859
|
}
|
|
860
|
+
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
861
|
+
throw new Error(`terminal host owner death is unproven; refusing stale socket reclamation: ${socketPath}`);
|
|
862
|
+
}
|
|
431
863
|
try {
|
|
432
|
-
const current = await inspectPrivateNativeTerminalSocket(socketPath);
|
|
864
|
+
const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
433
865
|
if (sameNativeTerminalSocket(stale, current)) {
|
|
434
|
-
|
|
866
|
+
// Do not leave an uncancellable deletion queued against a successor.
|
|
867
|
+
unlinkOwnedPath(socketPath, stale);
|
|
435
868
|
return;
|
|
436
869
|
}
|
|
437
870
|
}
|
|
@@ -457,44 +890,73 @@ async function ensurePrivateSocketDirectory(socketPath) {
|
|
|
457
890
|
}
|
|
458
891
|
function stagingSocketPath(socketPath) {
|
|
459
892
|
const directory = dirname(socketPath);
|
|
460
|
-
const stem = basename(socketPath).slice(0,
|
|
893
|
+
const stem = basename(socketPath).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
461
894
|
const stagedPath = join(directory, `.${stem}.${process.pid}.${randomUUID().slice(0, 8)}.sock`);
|
|
462
895
|
// The staged name is longer than the published one; a publishable path can
|
|
463
896
|
// still overflow the kernel sun_path budget at the bind step.
|
|
464
897
|
assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
|
|
465
898
|
return stagedPath;
|
|
466
899
|
}
|
|
467
|
-
async function publishSocket(stagedPath, socketPath) {
|
|
468
|
-
|
|
900
|
+
async function publishSocket(stagedPath, socketPath, signal) {
|
|
901
|
+
signal.throwIfAborted();
|
|
902
|
+
const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
|
|
903
|
+
try {
|
|
904
|
+
await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (error.code !== "ENOENT")
|
|
908
|
+
throw error;
|
|
909
|
+
if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
|
|
910
|
+
throw new Error(`terminal host owner death is unproven; refusing socket publication: ${socketPath}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
469
913
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
914
|
+
signal.throwIfAborted();
|
|
470
915
|
try {
|
|
471
|
-
await link(stagedPath, socketPath);
|
|
472
|
-
const published = await inspectPrivateNativeTerminalSocket(socketPath);
|
|
916
|
+
await abortable(link(stagedPath, socketPath), signal);
|
|
917
|
+
const published = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
|
|
473
918
|
if (!sameNativeTerminalSocket(staged, published)) {
|
|
474
919
|
throw new Error("terminal host published socket identity does not match its staged socket");
|
|
475
920
|
}
|
|
476
|
-
await unlink(stagedPath);
|
|
921
|
+
await abortable(unlink(stagedPath), signal);
|
|
477
922
|
return published;
|
|
478
923
|
}
|
|
479
924
|
catch (error) {
|
|
480
925
|
if (error.code !== "EEXIST")
|
|
481
926
|
throw error;
|
|
482
|
-
await removeStaleSocket(socketPath);
|
|
927
|
+
await removeStaleSocket(socketPath, signal);
|
|
483
928
|
}
|
|
484
929
|
}
|
|
485
930
|
throw new Error("terminal host socket publication retry limit exceeded");
|
|
486
931
|
}
|
|
487
932
|
export async function startNativeTerminalHostServer(options) {
|
|
933
|
+
const deadline = new AbortController();
|
|
934
|
+
const timer = setTimeout(() => deadline.abort(new Error("terminal host socket publication timed out")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
|
|
935
|
+
const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
|
|
936
|
+
try {
|
|
937
|
+
return await initializeNativeTerminalHostServer({ ...options, signal });
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
clearTimeout(timer);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
async function initializeNativeTerminalHostServer(options) {
|
|
944
|
+
options.signal?.throwIfAborted();
|
|
488
945
|
if (!isAbsolute(options.socketPath))
|
|
489
946
|
throw new Error("terminal host socket path must be absolute");
|
|
490
|
-
await ensurePrivateSocketDirectory(options.socketPath);
|
|
947
|
+
await abortable(ensurePrivateSocketDirectory(options.socketPath), options.signal);
|
|
491
948
|
const sockets = new Set();
|
|
492
949
|
const responseBudget = { pendingBytes: 0 };
|
|
950
|
+
let readyForRequests = false;
|
|
493
951
|
const server = createServer((socket) => {
|
|
494
952
|
if (sockets.size >= NATIVE_TERMINAL_MAX_CONNECTIONS) {
|
|
495
953
|
socket.destroy();
|
|
496
954
|
return;
|
|
497
955
|
}
|
|
956
|
+
// A visible socket is not yet a completed publication: its durable owner
|
|
957
|
+
// record and publication lock must finish before health/PTY admission.
|
|
958
|
+
if (!readyForRequests)
|
|
959
|
+
socket.pause();
|
|
498
960
|
const context = {
|
|
499
961
|
socket,
|
|
500
962
|
leases: new Map(),
|
|
@@ -526,28 +988,46 @@ export async function startNativeTerminalHostServer(options) {
|
|
|
526
988
|
const stagedPath = stagingSocketPath(options.socketPath);
|
|
527
989
|
let ownedSocket;
|
|
528
990
|
try {
|
|
529
|
-
await new Promise((resolve, reject) => {
|
|
991
|
+
await abortable(new Promise((resolve, reject) => {
|
|
530
992
|
server.once("error", reject);
|
|
531
993
|
server.listen(stagedPath, () => {
|
|
532
994
|
server.removeListener("error", reject);
|
|
533
995
|
resolve();
|
|
534
996
|
});
|
|
535
|
-
});
|
|
536
|
-
await
|
|
537
|
-
ownedSocket =
|
|
997
|
+
}), options.signal);
|
|
998
|
+
// Capture the listener's inode before any publication await can suspend.
|
|
999
|
+
ownedSocket = lstatSync(stagedPath);
|
|
1000
|
+
await abortable(chmod(stagedPath, 0o600), options.signal);
|
|
1001
|
+
await withSocketPublicationLock(options.socketPath, async (signal) => {
|
|
1002
|
+
try {
|
|
1003
|
+
signal.throwIfAborted();
|
|
1004
|
+
const published = await publishSocket(stagedPath, options.socketPath, signal);
|
|
1005
|
+
await recordEndpointOwner(options.socketPath, published, undefined, signal);
|
|
1006
|
+
return published;
|
|
1007
|
+
}
|
|
1008
|
+
catch (pubError) {
|
|
1009
|
+
// A link may have committed without resolving its promise. Invalidate
|
|
1010
|
+
// its source first, then remove only this listener's published inode.
|
|
1011
|
+
unlinkOwnedPath(stagedPath, ownedSocket);
|
|
1012
|
+
rollbackEndpoint(options.socketPath, ownedSocket);
|
|
1013
|
+
throw pubError;
|
|
1014
|
+
}
|
|
1015
|
+
}, options.signal);
|
|
538
1016
|
}
|
|
539
1017
|
catch (error) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
throw cleanupError;
|
|
547
|
-
}
|
|
1018
|
+
for (const socket of sockets)
|
|
1019
|
+
socket.destroy();
|
|
1020
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
1021
|
+
if (ownedSocket !== undefined) {
|
|
1022
|
+
unlinkOwnedPath(stagedPath, ownedSocket);
|
|
1023
|
+
rollbackEndpoint(options.socketPath, ownedSocket);
|
|
548
1024
|
}
|
|
1025
|
+
await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
549
1026
|
throw error;
|
|
550
1027
|
}
|
|
1028
|
+
readyForRequests = true;
|
|
1029
|
+
for (const socket of sockets)
|
|
1030
|
+
socket.resume();
|
|
551
1031
|
let closing;
|
|
552
1032
|
return Object.freeze({
|
|
553
1033
|
socketPath: options.socketPath,
|
|
@@ -568,15 +1048,12 @@ export async function startNativeTerminalHostServer(options) {
|
|
|
568
1048
|
}
|
|
569
1049
|
for (const socket of sockets)
|
|
570
1050
|
socket.destroy();
|
|
571
|
-
await new Promise((resolve, reject) => {
|
|
1051
|
+
await abortable(new Promise((resolve, reject) => {
|
|
572
1052
|
server.close((error) => error ? reject(error) : resolve());
|
|
573
|
-
});
|
|
1053
|
+
}), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
|
|
574
1054
|
await withSocketPublicationLock(options.socketPath, async () => {
|
|
575
1055
|
try {
|
|
576
|
-
|
|
577
|
-
if (sameNativeTerminalSocket(current, ownedSocket)) {
|
|
578
|
-
await unlink(options.socketPath);
|
|
579
|
-
}
|
|
1056
|
+
rollbackEndpoint(options.socketPath, ownedSocket);
|
|
580
1057
|
}
|
|
581
1058
|
catch (error) {
|
|
582
1059
|
if (error.code !== "ENOENT")
|