@runuai/host 0.9.98 → 0.9.100
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/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +7 -0
- package/lib/engine-accounts.ts +120 -11
- package/lib/engine-connect.ts +24 -2
- package/lib/engine-login.ts +46 -8
- package/lib/orchestrator.ts +40 -19
- package/lib/runtime-state.ts +2 -0
- package/package.json +1 -1
- package/scripts/install/darwin.ts +90 -8
- package/src/activation-respawn.ts +155 -0
- package/src/cli.ts +33 -0
- package/src/index.ts +3 -1
- package/src/machine-tunnel-route.ts +26 -6
- package/src/main.ts +65 -7
- package/src/protocol.ts +46 -5
- package/src/tunnel-dial.ts +102 -0
package/src/main.ts
CHANGED
|
@@ -116,6 +116,8 @@ import {
|
|
|
116
116
|
invalidateMachineTunnelRoute,
|
|
117
117
|
resolveMachineTunnelRoute,
|
|
118
118
|
} from "./machine-tunnel-route";
|
|
119
|
+
import { armConnectTimeout } from "./tunnel-dial";
|
|
120
|
+
import { scheduleActivationRespawn } from "./activation-respawn";
|
|
119
121
|
import {
|
|
120
122
|
configureBundledContainerRuntime,
|
|
121
123
|
selectedRuntimeMatchesPersistedTaskEnvironments,
|
|
@@ -148,7 +150,7 @@ import {
|
|
|
148
150
|
patchHostConfig,
|
|
149
151
|
type HostConfigResult,
|
|
150
152
|
} from "../lib/host-config";
|
|
151
|
-
import {
|
|
153
|
+
import { listEngineAccountsAllScopes } from "../lib/engine-accounts";
|
|
152
154
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
153
155
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
154
156
|
import {
|
|
@@ -180,7 +182,9 @@ import {
|
|
|
180
182
|
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
181
183
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
182
184
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
185
|
+
HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
|
|
183
186
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
187
|
+
isEngineAccountScope,
|
|
184
188
|
HOST_LOGS_PROTOCOL_FEATURE,
|
|
185
189
|
HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
|
|
186
190
|
MAX_ENGINE_ACCOUNT_LABEL_CHARS,
|
|
@@ -599,6 +603,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
599
603
|
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
600
604
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
601
605
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
606
|
+
HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
|
|
602
607
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
603
608
|
// dispatch in mock mode would let the composer create a Secretary that
|
|
604
609
|
// has no way to wake crew.
|
|
@@ -694,13 +699,15 @@ function engineAccountSummaries(): EngineAccountSummary[] {
|
|
|
694
699
|
const summaries: EngineAccountSummary[] = [];
|
|
695
700
|
for (const kind of ["claude", "codex", "opencode"] as const) {
|
|
696
701
|
try {
|
|
697
|
-
|
|
702
|
+
// ADR-131 D3: every scope — the cloud filters per caller.
|
|
703
|
+
for (const account of listEngineAccountsAllScopes(kind)) {
|
|
698
704
|
summaries.push({
|
|
699
705
|
id: account.id,
|
|
700
706
|
kind,
|
|
701
707
|
label: account.label.slice(0, MAX_ENGINE_ACCOUNT_LABEL_CHARS),
|
|
702
708
|
authKind: account.authKind,
|
|
703
709
|
isDefault: account.isDefault,
|
|
710
|
+
scope: account.scope,
|
|
704
711
|
});
|
|
705
712
|
}
|
|
706
713
|
} catch (error) {
|
|
@@ -1225,6 +1232,7 @@ async function connect(): Promise<void> {
|
|
|
1225
1232
|
frame.engine,
|
|
1226
1233
|
emitEngineLoginEvent,
|
|
1227
1234
|
frame.label,
|
|
1235
|
+
frame.scope,
|
|
1228
1236
|
);
|
|
1229
1237
|
break;
|
|
1230
1238
|
case "engine.login.input":
|
|
@@ -1245,6 +1253,7 @@ async function connect(): Promise<void> {
|
|
|
1245
1253
|
frame.secret,
|
|
1246
1254
|
frame.label,
|
|
1247
1255
|
(event) => sendRemoteOperationFrame(event),
|
|
1256
|
+
frame.scope,
|
|
1248
1257
|
);
|
|
1249
1258
|
break;
|
|
1250
1259
|
case "engine.connect.stop":
|
|
@@ -1863,6 +1872,18 @@ function handleHttpTunnelOpen(
|
|
|
1863
1872
|
},
|
|
1864
1873
|
);
|
|
1865
1874
|
|
|
1875
|
+
// ADR-121: a memoised machine address may black-hole (instance stopped
|
|
1876
|
+
// while memoised), and a black-holed dial neither errors nor answers for
|
|
1877
|
+
// the kernel's SYN budget. Bound the connect phase only — never `timeout`
|
|
1878
|
+
// on the request, which is idle semantics and would cut quiet streams. A
|
|
1879
|
+
// socket the agent reuses arrives connected and is left alone.
|
|
1880
|
+
if (target.connectTimeoutMs !== undefined && !target.socket) {
|
|
1881
|
+
const connectTimeoutMs = target.connectTimeoutMs;
|
|
1882
|
+
upstream.once("socket", (socket: Socket) => {
|
|
1883
|
+
armConnectTimeout(socket, target, connectTimeoutMs);
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1866
1887
|
// Registering replays anything that arrived while the target resolved — for a
|
|
1867
1888
|
// bodyless GET that is the `requestEnd` this request cannot reply without.
|
|
1868
1889
|
tunnels.register(frame.tunnelId, upstream);
|
|
@@ -1872,7 +1893,8 @@ function handleHttpTunnelOpen(
|
|
|
1872
1893
|
// ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
|
|
1873
1894
|
// the next request recreates it (no-op for published / container-IP targets).
|
|
1874
1895
|
invalidatePreviewSidecar(target.port);
|
|
1875
|
-
// ADR-121: a memoised machine address that no longer answers is stale
|
|
1896
|
+
// ADR-121: a memoised machine address that no longer answers is stale —
|
|
1897
|
+
// a refused dial and a connect timeout (tunnel-dial.ts) both land here.
|
|
1876
1898
|
invalidateMachineTunnelRoute(frame.taskId);
|
|
1877
1899
|
if (!acked) {
|
|
1878
1900
|
send(wsSocket, {
|
|
@@ -1969,7 +1991,16 @@ function handleRawTunnelOpen(
|
|
|
1969
1991
|
});
|
|
1970
1992
|
});
|
|
1971
1993
|
|
|
1972
|
-
if (!target.socket)
|
|
1994
|
+
if (!target.socket) {
|
|
1995
|
+
upstream.connect(target.port, target.host);
|
|
1996
|
+
// ADR-121: a memoised machine address may black-hole; bound the connect
|
|
1997
|
+
// phase (tunnel-dial.ts) so the 'error' handler above runs and drops the
|
|
1998
|
+
// memo instead of the pane hanging on the kernel's SYN budget. Armed
|
|
1999
|
+
// after connect() so the socket is `connecting`; disarmed on 'connect'.
|
|
2000
|
+
if (target.connectTimeoutMs !== undefined) {
|
|
2001
|
+
armConnectTimeout(upstream, target, target.connectTimeoutMs);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
1973
2004
|
// AFTER `connect`, not before: replaying a queued write onto a socket that
|
|
1974
2005
|
// has not started connecting errors, while node buffers writes made once it
|
|
1975
2006
|
// is connecting. An upgrade's own request goes out from the `connect`
|
|
@@ -2003,6 +2034,12 @@ interface UpstreamAddr {
|
|
|
2003
2034
|
* (ADR-106 apple routing). An established TCP peer cannot be retargeted by
|
|
2004
2035
|
* later IP reuse, so consumers must use this socket instead of redialing. */
|
|
2005
2036
|
socket?: Socket;
|
|
2037
|
+
/** Connect-phase bound for a dial to this address, set only by the ADR-121
|
|
2038
|
+
* machine branch: its address is memoised and may black-hole after an
|
|
2039
|
+
* external stop, and a black-holed dial neither errors nor answers for the
|
|
2040
|
+
* kernel's SYN budget. Loopback, sidecar and pre-established (`socket`)
|
|
2041
|
+
* targets carry none — their dials fail fast or are already connected. */
|
|
2042
|
+
connectTimeoutMs?: number;
|
|
2006
2043
|
}
|
|
2007
2044
|
|
|
2008
2045
|
async function resolveTunnelTarget(
|
|
@@ -2404,7 +2441,7 @@ async function dispatchCommand(
|
|
|
2404
2441
|
function expectEngineAccountRemoveInput(
|
|
2405
2442
|
args: unknown[],
|
|
2406
2443
|
index: number,
|
|
2407
|
-
): { id: string } {
|
|
2444
|
+
): { id: string; scope?: string } {
|
|
2408
2445
|
const value = args[index];
|
|
2409
2446
|
if (
|
|
2410
2447
|
!value ||
|
|
@@ -2415,7 +2452,14 @@ function expectEngineAccountRemoveInput(
|
|
|
2415
2452
|
) {
|
|
2416
2453
|
throw new Error(`invalid command args: expected account id at ${index}`);
|
|
2417
2454
|
}
|
|
2418
|
-
|
|
2455
|
+
const scope = (value as { scope?: unknown }).scope;
|
|
2456
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
2457
|
+
throw new Error(`invalid command args: bad account scope at ${index}`);
|
|
2458
|
+
}
|
|
2459
|
+
return {
|
|
2460
|
+
id: (value as { id: string }).id,
|
|
2461
|
+
...(scope !== undefined ? { scope } : {}),
|
|
2462
|
+
};
|
|
2419
2463
|
}
|
|
2420
2464
|
|
|
2421
2465
|
function expectNumberArg(args: unknown[], index: number): number {
|
|
@@ -3369,13 +3413,27 @@ function maybeExit(): void {
|
|
|
3369
3413
|
}
|
|
3370
3414
|
if (ws.readyState === WebSocket.CLOSING) return;
|
|
3371
3415
|
}
|
|
3416
|
+
if (shutdownExitCode === MANAGED_UPDATE_RESTART_EXIT_CODE) {
|
|
3417
|
+
// The drain is complete: this is the last thing the old generation does.
|
|
3418
|
+
// On a launchd host the respawn is scheduled from here rather than left
|
|
3419
|
+
// to KeepAlive — see activation-respawn.ts for the two 2026-09-06 stalls.
|
|
3420
|
+
const plan = scheduleActivationRespawn();
|
|
3421
|
+
console.log(
|
|
3422
|
+
plan.scheduled
|
|
3423
|
+
? `[host-agent] exiting ${shutdownExitCode} for the supervisor to relaunch runtime/current; launchctl kickstart ${plan.target} is scheduled in ${plan.delaySeconds}s in case KeepAlive does not respawn the job`
|
|
3424
|
+
: `[host-agent] exiting ${shutdownExitCode} for the supervisor to relaunch runtime/current (no launchctl kickstart scheduled: ${plan.reason})`,
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3372
3427
|
process.exit(shutdownExitCode);
|
|
3373
3428
|
}
|
|
3374
3429
|
|
|
3375
3430
|
/**
|
|
3376
3431
|
* Finish any in-flight bridge command, then let the service supervisor exec
|
|
3377
3432
|
* the newly activated `runtime/current`. The non-zero status is intentional:
|
|
3378
|
-
* Linux uses Restart=on-failure
|
|
3433
|
+
* Linux uses Restart=on-failure and the desktop supervisor restarts on any
|
|
3434
|
+
* exit. launchd's KeepAlive is supposed to respawn it too but has been seen
|
|
3435
|
+
* not to (2026-09-06, twice), so maybeExit() also schedules an explicit
|
|
3436
|
+
* `launchctl kickstart` on macOS before the process exits.
|
|
3379
3437
|
*/
|
|
3380
3438
|
export function requestManagedHostRestart(): void {
|
|
3381
3439
|
shutdownExitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
|
package/src/protocol.ts
CHANGED
|
@@ -102,6 +102,25 @@ export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
|
|
|
102
102
|
/** ADR-117: one-shot engine credentials over the bridge + the full engine
|
|
103
103
|
* catalog in capabilities, so every engine renders on the cloud pane. */
|
|
104
104
|
export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
|
|
105
|
+
/** ADR-131 Decision 3: engine accounts carry a scope — `host` (the owner's)
|
|
106
|
+
* or `org:<orgId>` (a workspace's account on a shared host). A host that
|
|
107
|
+
* advertises this accepts `scope` on engine.login.start / engine.connect.start
|
|
108
|
+
* / engineAccountRemove, `engineAccountScope` on channelEnsure and task-up,
|
|
109
|
+
* and stamps `scope` on every EngineAccountSummary it advertises. The cloud
|
|
110
|
+
* sends none of those to a host without the feature (the v1 exact-shape
|
|
111
|
+
* guards would reject the frames). */
|
|
112
|
+
export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
|
|
113
|
+
"host-engine-account-scopes-v1";
|
|
114
|
+
/** The owner-level account scope; every pre-scope account lives here. */
|
|
115
|
+
export const HOST_ACCOUNT_SCOPE = "host";
|
|
116
|
+
const ORG_ACCOUNT_SCOPE_RE = /^org:[A-Za-z0-9_-]{1,128}$/;
|
|
117
|
+
/** `host` or `org:<orgId>` — bounded, single-token, safe in every surface. */
|
|
118
|
+
export function isEngineAccountScope(value: unknown): value is string {
|
|
119
|
+
return (
|
|
120
|
+
value === HOST_ACCOUNT_SCOPE ||
|
|
121
|
+
(typeof value === "string" && ORG_ACCOUNT_SCOPE_RE.test(value))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
105
124
|
export const MAX_ENGINE_CONNECT_SECRET_CHARS = 8_192;
|
|
106
125
|
export const MAX_ENGINE_CONNECT_LINE_CHARS = 512;
|
|
107
126
|
export const MAX_ENGINE_CATALOG_ENTRIES = 8;
|
|
@@ -307,6 +326,10 @@ export interface EngineLoginStartFrame {
|
|
|
307
326
|
* slot. Only sent to hosts advertising `host-engine-accounts-v1`; the v1
|
|
308
327
|
* exact-shape guard on older hosts rejects frames carrying it. */
|
|
309
328
|
label?: string;
|
|
329
|
+
/** ADR-131 D3: capture into this account scope (`org:<orgId>` on a shared
|
|
330
|
+
* host). Only sent to hosts advertising `host-engine-account-scopes-v1`;
|
|
331
|
+
* absent = the host scope. An org scope requires a label. */
|
|
332
|
+
scope?: string;
|
|
310
333
|
}
|
|
311
334
|
|
|
312
335
|
/** The only generic one-time response channel. Long-lived tokens must never
|
|
@@ -379,6 +402,9 @@ export interface EngineAccountSummary {
|
|
|
379
402
|
label: string;
|
|
380
403
|
authKind: "env" | "config-dir";
|
|
381
404
|
isDefault: boolean;
|
|
405
|
+
/** ADR-131 D3: `host` or `org:<orgId>`. Absent from hosts that predate
|
|
406
|
+
* scopes — read as `host`. */
|
|
407
|
+
scope?: string;
|
|
382
408
|
}
|
|
383
409
|
|
|
384
410
|
/** ADR-117 engine kinds — every engine the host's descriptor table knows. */
|
|
@@ -416,6 +442,8 @@ export interface EngineConnectStartFrame {
|
|
|
416
442
|
engine: EngineConnectKind;
|
|
417
443
|
secret?: string;
|
|
418
444
|
label?: string;
|
|
445
|
+
/** ADR-131 D3: see EngineLoginStartFrame.scope. */
|
|
446
|
+
scope?: string;
|
|
419
447
|
}
|
|
420
448
|
|
|
421
449
|
export interface EngineConnectStopFrame {
|
|
@@ -454,13 +482,14 @@ export function isEngineConnectStartFrame(
|
|
|
454
482
|
const frame = exactWireObject(
|
|
455
483
|
value,
|
|
456
484
|
["kind", "opId", "engine"],
|
|
457
|
-
["secret", "label"],
|
|
485
|
+
["secret", "label", "scope"],
|
|
458
486
|
);
|
|
459
487
|
if (
|
|
460
488
|
!frame ||
|
|
461
489
|
frame.kind !== "engine.connect.start" ||
|
|
462
490
|
!isHostOpId(frame.opId) ||
|
|
463
|
-
!isEngineConnectKind(frame.engine)
|
|
491
|
+
!isEngineConnectKind(frame.engine) ||
|
|
492
|
+
(frame.scope !== undefined && !isEngineAccountScope(frame.scope))
|
|
464
493
|
) {
|
|
465
494
|
return false;
|
|
466
495
|
}
|
|
@@ -705,13 +734,18 @@ export function isHostLogsEndFrame(value: unknown): value is HostLogsEndFrame {
|
|
|
705
734
|
export function isEngineLoginStartFrame(
|
|
706
735
|
value: unknown,
|
|
707
736
|
): value is EngineLoginStartFrame {
|
|
708
|
-
const frame = exactWireObject(
|
|
737
|
+
const frame = exactWireObject(
|
|
738
|
+
value,
|
|
739
|
+
["kind", "opId", "engine"],
|
|
740
|
+
["label", "scope"],
|
|
741
|
+
);
|
|
709
742
|
return Boolean(
|
|
710
743
|
frame &&
|
|
711
744
|
frame.kind === "engine.login.start" &&
|
|
712
745
|
isHostOpId(frame.opId) &&
|
|
713
746
|
isEngineLoginKind(frame.engine) &&
|
|
714
|
-
(frame.label === undefined || isEngineAccountLabel(frame.label))
|
|
747
|
+
(frame.label === undefined || isEngineAccountLabel(frame.label)) &&
|
|
748
|
+
(frame.scope === undefined || isEngineAccountScope(frame.scope)),
|
|
715
749
|
);
|
|
716
750
|
}
|
|
717
751
|
|
|
@@ -1087,6 +1121,9 @@ export interface TaskCommandTask {
|
|
|
1087
1121
|
previewEnv?: Record<string, string>;
|
|
1088
1122
|
/** ADR-062: owning org — locates the org shared-files root on the host. */
|
|
1089
1123
|
ownerOrgId?: string;
|
|
1124
|
+
/** ADR-131 D3: the engine-account scope the task's agents resolve
|
|
1125
|
+
* credentials from (`org:<orgId>` on a shared host). Absent = `host`. */
|
|
1126
|
+
engineAccountScope?: string;
|
|
1090
1127
|
/** ADR-062: shared-files mount mode ("off" | "ro" | "rw"; default "ro"). */
|
|
1091
1128
|
sharedFiles?: string;
|
|
1092
1129
|
}
|
|
@@ -1188,6 +1225,10 @@ export interface ChannelEnsureInput {
|
|
|
1188
1225
|
/** ADR-053: any joined project opted into browser testing — the host wires
|
|
1189
1226
|
* the Playwright MCP browser at session start. */
|
|
1190
1227
|
browserTesting?: boolean;
|
|
1228
|
+
/** ADR-131 D3: see TaskCommandTask.engineAccountScope — the channel's
|
|
1229
|
+
* agents pick accounts from this scope. Absent = the task's persisted
|
|
1230
|
+
* scope, else `host`. */
|
|
1231
|
+
engineAccountScope?: string;
|
|
1191
1232
|
/** ADR-057: the owner's usable MCP connections (policy "on", connected,
|
|
1192
1233
|
* remote). The host writes gateway-URL MCP configs at session start. */
|
|
1193
1234
|
mcpConnections?: Array<{ id: string; slug: string }>;
|
|
@@ -1358,7 +1399,7 @@ export interface HostCommands {
|
|
|
1358
1399
|
* exactly like the local API; re-advertises capabilities on success. */
|
|
1359
1400
|
engineAccountRemove(
|
|
1360
1401
|
ctx: CommandContext,
|
|
1361
|
-
input: { id: string },
|
|
1402
|
+
input: { id: string; scope?: string },
|
|
1362
1403
|
): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
|
|
1363
1404
|
cloneRepo(
|
|
1364
1405
|
ctx: CommandContext,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect-phase timeout for tunnel upstream dials to a memoised address.
|
|
3
|
+
*
|
|
4
|
+
* An ADR-121 machine task's `{host, port}` comes from a 45 s memo
|
|
5
|
+
* (`machine-tunnel-route.ts`) that is dropped when a dial fails. A machine
|
|
6
|
+
* stopped or terminated while its address is memoised does not refuse the
|
|
7
|
+
* dial — it black-holes it: no SYN answer, so neither `connect` nor `error`
|
|
8
|
+
* fires until the kernel gives up on retransmits (~75 s macOS, ~127 s Linux).
|
|
9
|
+
* The memo's failure-driven invalidation never runs in that window and the
|
|
10
|
+
* pane or editor hangs on every open. An in-region machine answers a SYN in
|
|
11
|
+
* milliseconds, the cloud waits 30 s for the ack (`TUNNEL_ACK_TIMEOUT_MS`)
|
|
12
|
+
* and the forward hop 45 s for the head, so a 10 s connect bound is generous
|
|
13
|
+
* to a live machine and well inside what the caller will still accept.
|
|
14
|
+
*
|
|
15
|
+
* Only the connect phase is timed: the deadline is armed while the socket is
|
|
16
|
+
* `connecting` and disarmed by `connect` (or by the dial failing on its own).
|
|
17
|
+
* It is NOT an idle timeout — a quiet VNC/SSE/code-server stream after the
|
|
18
|
+
* handshake must live forever (see the tunnel rules in docs/agent-notes.md).
|
|
19
|
+
* A timeout destroys the socket with an error carrying `code = "ETIMEDOUT"`
|
|
20
|
+
* and `syscall = "connect"`, so the dial path's existing `'error'` handler
|
|
21
|
+
* runs unchanged: it acks the cloud with the same 502 shape as any other
|
|
22
|
+
* dial failure and invalidates the machine route memo.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface DialTarget {
|
|
26
|
+
readonly host: string;
|
|
27
|
+
readonly port: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The slice of `net.Socket` the timeout needs; tests pass a fake. */
|
|
31
|
+
export interface DialSocket {
|
|
32
|
+
/** True from `connect()` until the handshake completes. False on a socket
|
|
33
|
+
* handed over already connected (an agent's reused keep-alive socket, an
|
|
34
|
+
* ADR-106 pre-established apple route) — those are never armed. */
|
|
35
|
+
readonly connecting: boolean;
|
|
36
|
+
once(event: "connect" | "error" | "close", listener: () => void): unknown;
|
|
37
|
+
destroy(error?: Error): unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DialTimers {
|
|
41
|
+
setTimeout(callback: () => void, delayMs: number): NodeJS.Timeout;
|
|
42
|
+
clearTimeout(timer: NodeJS.Timeout): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CONNECT_TIMEOUT_ERROR_CODE = "ETIMEDOUT" as const;
|
|
46
|
+
|
|
47
|
+
export interface ConnectTimeoutError extends Error {
|
|
48
|
+
readonly code: typeof CONNECT_TIMEOUT_ERROR_CODE;
|
|
49
|
+
readonly syscall: "connect";
|
|
50
|
+
readonly address: string;
|
|
51
|
+
readonly port: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function connectTimeoutError(
|
|
55
|
+
target: DialTarget,
|
|
56
|
+
timeoutMs: number,
|
|
57
|
+
): ConnectTimeoutError {
|
|
58
|
+
return Object.assign(
|
|
59
|
+
new Error(
|
|
60
|
+
`connect ETIMEDOUT ${target.host}:${target.port} (no answer within ${timeoutMs} ms)`,
|
|
61
|
+
),
|
|
62
|
+
{
|
|
63
|
+
code: CONNECT_TIMEOUT_ERROR_CODE,
|
|
64
|
+
syscall: "connect" as const,
|
|
65
|
+
address: target.host,
|
|
66
|
+
port: target.port,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isConnectTimeoutError(error: unknown): error is ConnectTimeoutError {
|
|
72
|
+
return (
|
|
73
|
+
error instanceof Error &&
|
|
74
|
+
(error as Partial<ConnectTimeoutError>).code === CONNECT_TIMEOUT_ERROR_CODE &&
|
|
75
|
+
(error as Partial<ConnectTimeoutError>).syscall === "connect"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Arm a connect deadline on a socket that has started (or is about to be
|
|
81
|
+
* handed a started) connect. Returns whether a deadline was armed — false for
|
|
82
|
+
* a socket that is already connected, which has nothing to time.
|
|
83
|
+
*/
|
|
84
|
+
export function armConnectTimeout(
|
|
85
|
+
socket: DialSocket,
|
|
86
|
+
target: DialTarget,
|
|
87
|
+
timeoutMs: number,
|
|
88
|
+
timers: DialTimers = { setTimeout, clearTimeout },
|
|
89
|
+
): boolean {
|
|
90
|
+
if (!socket.connecting) return false;
|
|
91
|
+
const deadline = timers.setTimeout(() => {
|
|
92
|
+
socket.destroy(connectTimeoutError(target, timeoutMs));
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
deadline.unref?.();
|
|
95
|
+
const disarm = (): void => {
|
|
96
|
+
timers.clearTimeout(deadline);
|
|
97
|
+
};
|
|
98
|
+
socket.once("connect", disarm);
|
|
99
|
+
socket.once("error", disarm);
|
|
100
|
+
socket.once("close", disarm);
|
|
101
|
+
return true;
|
|
102
|
+
}
|