@runuai/host 0.9.97 → 0.9.99
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/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/machine-tunnel-route.ts +156 -0
- package/src/main.ts +108 -25
- package/src/tunnel-dial.ts +102 -0
package/package.json
CHANGED
|
@@ -35,9 +35,21 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export const LABEL = "com.runuai.host";
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
/** The per-user launchd domain the LaunchAgent is bootstrapped into. */
|
|
39
|
+
export function launchdDomain(uid: number = process.getuid?.() ?? 0): string {
|
|
40
|
+
return `gui/${uid}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The job as `launchctl` addresses it — `gui/<uid>/com.runuai.host`. The
|
|
44
|
+
* service's own activation respawn (`src/activation-respawn.ts`) targets the
|
|
45
|
+
* same string, so the label and domain cannot drift between the CLI and the
|
|
46
|
+
* running host. */
|
|
47
|
+
export function launchdJobTarget(uid: number = process.getuid?.() ?? 0): string {
|
|
48
|
+
return `${launchdDomain(uid)}/${LABEL}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const domain = launchdDomain();
|
|
52
|
+
const target = launchdJobTarget();
|
|
41
53
|
const logDir = resolve(homedir(), "Library", "Logs", "Uai");
|
|
42
54
|
|
|
43
55
|
export function plistPath(): string {
|
|
@@ -934,12 +946,82 @@ export async function restartDarwinService(
|
|
|
934
946
|
}
|
|
935
947
|
}
|
|
936
948
|
|
|
949
|
+
/** What `launchctl print gui/<uid>/com.runuai.host` says about the job. */
|
|
950
|
+
export interface LaunchdJobState {
|
|
951
|
+
readonly loaded: boolean;
|
|
952
|
+
/** `state = …` verbatim: `running`, `not running`, `waiting`, … */
|
|
953
|
+
readonly state: string | null;
|
|
954
|
+
readonly pid: number | null;
|
|
955
|
+
/** `last exit code = …` verbatim, e.g. `75: EX_TEMPFAIL` or `(never exited)`. */
|
|
956
|
+
readonly lastExitCode: string | null;
|
|
957
|
+
/** `pended nondemand spawn = …` — launchd holds a spawn it has not executed
|
|
958
|
+
* (live 2026-09-06: `inefficient`, with the job `not running` after an
|
|
959
|
+
* exit-75 activation restart that KeepAlive never honoured). */
|
|
960
|
+
readonly pendedNondemandSpawn: string | null;
|
|
961
|
+
/** Why the probe failed, when it did not confirm a loaded job. */
|
|
962
|
+
readonly detail: string | null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function launchdPrintField(stdout: string, name: string): string | null {
|
|
966
|
+
// Top-level keys are indented by exactly one tab; nested dicts (endpoints,
|
|
967
|
+
// event triggers) reuse names like `state = active` two tabs deep, so the
|
|
968
|
+
// job's own key is preferred and the loose match is only a fallback. The
|
|
969
|
+
// whitespace class is horizontal only, so `state` never matches the tail
|
|
970
|
+
// of `spawn type` or another longer key.
|
|
971
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
972
|
+
const value = `(.+?)[ \\t]*(?:\\r?\\n|$)`;
|
|
973
|
+
return (
|
|
974
|
+
new RegExp(`(?:^|\\n)\\t${escaped} = ${value}`).exec(stdout)?.[1] ??
|
|
975
|
+
new RegExp(`(?:^|\\n)[ \\t]*${escaped} = ${value}`).exec(stdout)?.[1] ??
|
|
976
|
+
null
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export function parseLaunchdJobPrint(stdout: string): LaunchdJobState {
|
|
981
|
+
const pid = launchdPrintField(stdout, "pid");
|
|
982
|
+
return {
|
|
983
|
+
loaded: true,
|
|
984
|
+
state: launchdPrintField(stdout, "state"),
|
|
985
|
+
pid: pid !== null && /^\d+$/.test(pid) ? Number(pid) : null,
|
|
986
|
+
lastExitCode: launchdPrintField(stdout, "last exit code"),
|
|
987
|
+
pendedNondemandSpawn: launchdPrintField(stdout, "pended nondemand spawn"),
|
|
988
|
+
detail: null,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** One `launchctl print` of the host job, parsed. Read-only: safe to call
|
|
993
|
+
* from the running service (a boot after an activation logs it so a pended
|
|
994
|
+
* respawn is visible in the service log rather than only in `launchctl`). */
|
|
995
|
+
export function describeLaunchdJob(execute: StepExecutor = executeStep): LaunchdJobState {
|
|
996
|
+
const result = execute(false, "launchctl", ["print", target]);
|
|
997
|
+
if (result.status !== 0) {
|
|
998
|
+
return {
|
|
999
|
+
loaded: false,
|
|
1000
|
+
state: null,
|
|
1001
|
+
pid: null,
|
|
1002
|
+
lastExitCode: null,
|
|
1003
|
+
pendedNondemandSpawn: null,
|
|
1004
|
+
detail: resultDetail(result) || null,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return parseLaunchdJobPrint(result.stdout);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function formatLaunchdJobState(job: LaunchdJobState): string {
|
|
1011
|
+
if (!job.loaded) return `not loaded${job.detail ? ` (${job.detail})` : ""}`;
|
|
1012
|
+
const parts = [`state = ${job.state ?? "unknown"}`];
|
|
1013
|
+
if (job.pid !== null) parts.push(`pid = ${job.pid}`);
|
|
1014
|
+
if (job.lastExitCode !== null) parts.push(`last exit code = ${job.lastExitCode}`);
|
|
1015
|
+
if (job.pendedNondemandSpawn !== null) {
|
|
1016
|
+
parts.push(`pended nondemand spawn = ${job.pendedNondemandSpawn}`);
|
|
1017
|
+
}
|
|
1018
|
+
return parts.join(", ");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
937
1021
|
async function status(): Promise<string> {
|
|
938
|
-
const
|
|
939
|
-
if (!
|
|
940
|
-
|
|
941
|
-
const pid = /pid = (\d+)/.exec(out)?.[1];
|
|
942
|
-
return `${state}${pid ? ` (pid ${pid})` : ""}`;
|
|
1022
|
+
const job = describeLaunchdJob();
|
|
1023
|
+
if (!job.loaded) return "not installed / not loaded";
|
|
1024
|
+
return `${job.state ?? "unknown"}${job.pid !== null ? ` (pid ${job.pid})` : ""}`;
|
|
943
1025
|
}
|
|
944
1026
|
|
|
945
1027
|
export const installer: Installer = {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The macOS activation restart must not depend on launchd KeepAlive.
|
|
3
|
+
*
|
|
4
|
+
* A staged runtime activation (the in-app Update, `uai-host update`, the
|
|
5
|
+
* remote hostRestart) drains in-flight work and exits with
|
|
6
|
+
* MANAGED_UPDATE_RESTART_EXIT_CODE (75) so the service supervisor relaunches
|
|
7
|
+
* `runtime/current`. On Linux `Restart=on-failure` does exactly that. On
|
|
8
|
+
* macOS the LaunchAgent is `KeepAlive: true` — and twice on 2026-09-06 launchd
|
|
9
|
+
* did NOT respawn the job: `launchctl print gui/501/com.runuai.host` sat at
|
|
10
|
+
* `state = not running`, `last exit code = 75: EX_TEMPFAIL`,
|
|
11
|
+
* `pended nondemand spawn = inefficient` until the operator ran
|
|
12
|
+
* `uai-host start` (a plain `launchctl kickstart` of the job), which recovered
|
|
13
|
+
* it instantly.
|
|
14
|
+
*
|
|
15
|
+
* So the exiting process schedules that kickstart itself, from a child that
|
|
16
|
+
* survives it: a detached (`setsid`, so it leaves the job's process group and
|
|
17
|
+
* launchd's exit-time group cleanup cannot reach it), unref'd, stdio-less
|
|
18
|
+
* shell that sleeps a few seconds and then kickstarts the job. Plain
|
|
19
|
+
* `kickstart` first — a no-op when KeepAlive did respawn, so a fresh instance
|
|
20
|
+
* is never killed mid-boot — and `kickstart -k` as the fallback should launchd
|
|
21
|
+
* refuse the plain one (kill whatever is there and start again; by then
|
|
22
|
+
* nothing of ours should be running).
|
|
23
|
+
*
|
|
24
|
+
* It is scheduled only where it can matter: darwin, not under the desktop
|
|
25
|
+
* app's supervisor (which relaunches the agent on exit itself), and only when
|
|
26
|
+
* launchd is this process's parent — a `uai-host run` in a terminal, or a test
|
|
27
|
+
* spawning the CLI, has nothing to kick and must never touch a live service.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
import { launchdJobTarget } from "../scripts/install/darwin";
|
|
33
|
+
|
|
34
|
+
/** Long enough for a KeepAlive respawn, if launchd honours it, to have
|
|
35
|
+
* happened first (so the plain kickstart is a no-op); short enough that the
|
|
36
|
+
* host is back within seconds when it does not. */
|
|
37
|
+
export const ACTIVATION_RESPAWN_DELAY_SECONDS = 5;
|
|
38
|
+
|
|
39
|
+
export const ACTIVATION_RESPAWN_SHELL = "/bin/sh";
|
|
40
|
+
/** Absolute: the detached shell inherits the service environment, and a
|
|
41
|
+
* launchd-spawned job's PATH is whatever the plist captured at install. */
|
|
42
|
+
export const ACTIVATION_RESPAWN_LAUNCHCTL = "/bin/launchctl";
|
|
43
|
+
/** `$1` = delay in seconds, `$2` = the launchd job target. Positional, never
|
|
44
|
+
* interpolated, so the command line stays inert whatever the inputs. */
|
|
45
|
+
export const ACTIVATION_RESPAWN_SCRIPT = `sleep "$1"; ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart "$2" || exec ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart -k "$2"`;
|
|
46
|
+
|
|
47
|
+
export interface ActivationRespawnChild {
|
|
48
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
49
|
+
unref(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ActivationRespawnSpawnOptions {
|
|
53
|
+
readonly detached: true;
|
|
54
|
+
readonly stdio: "ignore";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ActivationRespawnSpawn = (
|
|
58
|
+
command: string,
|
|
59
|
+
args: readonly string[],
|
|
60
|
+
options: ActivationRespawnSpawnOptions,
|
|
61
|
+
) => ActivationRespawnChild;
|
|
62
|
+
|
|
63
|
+
export interface ActivationRespawnSeams {
|
|
64
|
+
platform?: NodeJS.Platform;
|
|
65
|
+
/** `process.ppid`; launchd is pid 1 and the parent of every job it runs. */
|
|
66
|
+
parentPid?: number;
|
|
67
|
+
/** `process.getuid()`; `undefined` models a platform without uids. */
|
|
68
|
+
uid?: number | undefined;
|
|
69
|
+
/** `process.versions.electron` — the desktop app runs the agent as a
|
|
70
|
+
* supervised child and restarts it on exit itself. */
|
|
71
|
+
underElectron?: boolean;
|
|
72
|
+
delaySeconds?: number;
|
|
73
|
+
spawn?: ActivationRespawnSpawn;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ActivationRespawnPlan =
|
|
77
|
+
| {
|
|
78
|
+
readonly scheduled: true;
|
|
79
|
+
readonly target: string;
|
|
80
|
+
readonly command: string;
|
|
81
|
+
readonly args: readonly string[];
|
|
82
|
+
readonly delaySeconds: number;
|
|
83
|
+
}
|
|
84
|
+
| { readonly scheduled: false; readonly reason: string };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Call immediately before `process.exit(MANAGED_UPDATE_RESTART_EXIT_CODE)`:
|
|
88
|
+
* the drain is complete, so the delay counts from the moment the old process
|
|
89
|
+
* is gone. `spawn` forks synchronously, so the child exists when this returns
|
|
90
|
+
* and the caller may exit at once.
|
|
91
|
+
*/
|
|
92
|
+
export function scheduleActivationRespawn(
|
|
93
|
+
seams: ActivationRespawnSeams = {},
|
|
94
|
+
): ActivationRespawnPlan {
|
|
95
|
+
const platform = seams.platform ?? process.platform;
|
|
96
|
+
if (platform !== "darwin") {
|
|
97
|
+
return {
|
|
98
|
+
scheduled: false,
|
|
99
|
+
reason: `${platform} relies on its service supervisor's restart policy`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (seams.underElectron ?? Boolean(process.versions.electron)) {
|
|
103
|
+
return {
|
|
104
|
+
scheduled: false,
|
|
105
|
+
reason: "the desktop app's supervisor relaunches the agent",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const parentPid = seams.parentPid ?? process.ppid;
|
|
109
|
+
if (parentPid !== 1) {
|
|
110
|
+
return {
|
|
111
|
+
scheduled: false,
|
|
112
|
+
reason: `not launchd-spawned (parent pid ${parentPid}); nothing to kick`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const uid = "uid" in seams ? seams.uid : process.getuid?.();
|
|
116
|
+
if (uid === undefined) {
|
|
117
|
+
return { scheduled: false, reason: "no uid to address the launchd domain" };
|
|
118
|
+
}
|
|
119
|
+
const delaySeconds = seams.delaySeconds ?? ACTIVATION_RESPAWN_DELAY_SECONDS;
|
|
120
|
+
const target = launchdJobTarget(uid);
|
|
121
|
+
const args = [
|
|
122
|
+
"-c",
|
|
123
|
+
ACTIVATION_RESPAWN_SCRIPT,
|
|
124
|
+
"uai-host-activation-respawn",
|
|
125
|
+
String(delaySeconds),
|
|
126
|
+
target,
|
|
127
|
+
];
|
|
128
|
+
const spawn = seams.spawn ?? (nodeSpawn as unknown as ActivationRespawnSpawn);
|
|
129
|
+
let child: ActivationRespawnChild;
|
|
130
|
+
try {
|
|
131
|
+
child = spawn(ACTIVATION_RESPAWN_SHELL, args, {
|
|
132
|
+
detached: true,
|
|
133
|
+
stdio: "ignore",
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
scheduled: false,
|
|
138
|
+
reason: `could not spawn the respawn shell: ${
|
|
139
|
+
error instanceof Error ? error.message : String(error)
|
|
140
|
+
}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// A spawn that fails after the fork leaves KeepAlive as the only respawn —
|
|
144
|
+
// the pre-0.9.99 behaviour — and must not become an uncaught 'error' in a
|
|
145
|
+
// process that is about to exit anyway.
|
|
146
|
+
child.on("error", () => {});
|
|
147
|
+
child.unref();
|
|
148
|
+
return {
|
|
149
|
+
scheduled: true,
|
|
150
|
+
target,
|
|
151
|
+
command: ACTIVATION_RESPAWN_SHELL,
|
|
152
|
+
args,
|
|
153
|
+
delaySeconds,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -250,6 +250,7 @@ async function cmdRun(): Promise<void> {
|
|
|
250
250
|
installState !== "live" &&
|
|
251
251
|
installState !== "unknown"
|
|
252
252
|
) {
|
|
253
|
+
const activationWasPending = existsSync(managedPaths.activationPending);
|
|
253
254
|
try {
|
|
254
255
|
if (!(await acknowledgeManagedRuntimeActivation(runningVersion))) {
|
|
255
256
|
// Selection changed while the service module was loading. Its task
|
|
@@ -258,6 +259,12 @@ async function cmdRun(): Promise<void> {
|
|
|
258
259
|
host.requestManagedHostRestart();
|
|
259
260
|
return;
|
|
260
261
|
}
|
|
262
|
+
if (activationWasPending) {
|
|
263
|
+
console.log(
|
|
264
|
+
`[host-agent] managed runtime ${runningVersion} activated (pending activation acknowledged)`,
|
|
265
|
+
);
|
|
266
|
+
await logLaunchdJobAfterActivation();
|
|
267
|
+
}
|
|
261
268
|
} catch (error) {
|
|
262
269
|
// A malformed or concurrently changed marker must not leave an imported
|
|
263
270
|
// service running indefinitely. Admission is still closed, so request a
|
|
@@ -305,6 +312,32 @@ async function cmdRun(): Promise<void> {
|
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The boot after an activation is the moment a launchd respawn problem is
|
|
317
|
+
* visible — `last exit code = 75: EX_TEMPFAIL` with a `pended nondemand
|
|
318
|
+
* spawn` is what the 2026-09-06 stalls looked like in `launchctl print`, and
|
|
319
|
+
* nothing in the service log said so. Read-only, macOS, and only when launchd
|
|
320
|
+
* actually spawned this process (pid 1 is its parent): a terminal `uai-host
|
|
321
|
+
* run`, the desktop supervisor, or a test spawning the CLI never runs
|
|
322
|
+
* `launchctl` here.
|
|
323
|
+
*/
|
|
324
|
+
async function logLaunchdJobAfterActivation(): Promise<void> {
|
|
325
|
+
if (process.platform !== "darwin" || process.ppid !== 1) return;
|
|
326
|
+
try {
|
|
327
|
+
const { describeLaunchdJob, formatLaunchdJobState, launchdJobTarget } =
|
|
328
|
+
await import("../scripts/install/darwin");
|
|
329
|
+
console.log(
|
|
330
|
+
`[host-agent] launchd job ${launchdJobTarget()} after activation: ${formatLaunchdJobState(describeLaunchdJob())}`,
|
|
331
|
+
);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.warn(
|
|
334
|
+
`[host-agent] could not read the launchd job state after activation: ${
|
|
335
|
+
error instanceof Error ? error.message : String(error)
|
|
336
|
+
}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
|
|
309
342
|
console.error(
|
|
310
343
|
red(
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121 machine tunnel routing, extracted from main.ts so its one expensive
|
|
3
|
+
* step is bounded and testable. Resolving a machine's address is a cloud API
|
|
4
|
+
* call — `aws ec2 describe-instances` through a spawned CLI, 0.5–1.5 s on the
|
|
5
|
+
* default t4g.medium host — and the cloud opens one tunnel per HTTP request,
|
|
6
|
+
* so a browser pane (noVNC ships dozens of unbundled modules) or the editor
|
|
7
|
+
* (code-server, more) paid that call dozens of times per load. Live
|
|
8
|
+
* 2026-09-06: "connecting, but very slow" on every AWS machine task, while
|
|
9
|
+
* Docker and apple-container hosts, whose routes are a local inspect, felt
|
|
10
|
+
* instant.
|
|
11
|
+
*
|
|
12
|
+
* The memo is per task, short (MACHINE_ROUTE_TTL_MS), and holds only a proven
|
|
13
|
+
* `running` route: a stopped or unknown machine is asked again on every open
|
|
14
|
+
* so a wake is seen promptly; a dial failure drops the entry so a machine that
|
|
15
|
+
* moved (a fresh public IP after stop/start) is re-described on the next open;
|
|
16
|
+
* a stop/down command drops it too — and bumps a per-task generation, so a
|
|
17
|
+
* description that was already in flight when the stop landed cannot
|
|
18
|
+
* re-memoise the pre-stop address. Concurrent cold opens share one resolution
|
|
19
|
+
* (the pane's first burst is half a dozen parallel requests), and a machine
|
|
20
|
+
* seen not running is not asked again for a few seconds (noVNC retries every
|
|
21
|
+
* couple of seconds against a stopped machine; without that, each retry was
|
|
22
|
+
* a CLI spawn). A refused dial invalidates at once. An address that
|
|
23
|
+
* black-holes (an instance stopped while memoised) answers nothing, so the
|
|
24
|
+
* route carries its own connect bound, MACHINE_DIAL_CONNECT_TIMEOUT_MS: both
|
|
25
|
+
* dial paths in main.ts arm it (`tunnel-dial.ts`) and the timeout surfaces as
|
|
26
|
+
* an ordinary dial error, which invalidates the entry exactly like a refusal.
|
|
27
|
+
* Before host 0.9.99 machine dials had no timeout of their own and a
|
|
28
|
+
* black-holed address hung on the kernel's SYN budget (~75 s macOS, ~127 s
|
|
29
|
+
* Linux) — longer than the memo TTL, so the TTL was never the real bound.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type MachineTunnelRoute =
|
|
33
|
+
| { kind: "running"; ipv4Address: string }
|
|
34
|
+
| { kind: "unavailable" }
|
|
35
|
+
| { kind: "unknown"; detail: string };
|
|
36
|
+
|
|
37
|
+
export interface MachineTunnelRouteDeps<Task> {
|
|
38
|
+
reconstruct(task: Task): Promise<{
|
|
39
|
+
inspectRoute?: () => Promise<MachineTunnelRoute>;
|
|
40
|
+
} | null>;
|
|
41
|
+
now(): number;
|
|
42
|
+
ttlMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MachineTunnelRouteRequest {
|
|
46
|
+
taskId: string;
|
|
47
|
+
target: string;
|
|
48
|
+
containerPort?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Long enough to cover a pane or editor load (dozens of opens inside a few
|
|
52
|
+
* seconds), short enough that a machine's address is never trusted past the
|
|
53
|
+
* next stop/start cycle by much; a failed dial drops it sooner. */
|
|
54
|
+
export const MACHINE_ROUTE_TTL_MS = 45_000;
|
|
55
|
+
/** How long a not-running answer is trusted before the machine is asked
|
|
56
|
+
* again — long enough to absorb a reconnecting pane, short enough that a wake
|
|
57
|
+
* is seen within seconds. */
|
|
58
|
+
export const MACHINE_ROUTE_NEGATIVE_TTL_MS = 5_000;
|
|
59
|
+
/** Connect-phase bound for a dial to a memoised machine address. An
|
|
60
|
+
* in-region machine answers a SYN in milliseconds; the cloud waits 30 s for
|
|
61
|
+
* the tunnel ack and the forward hop 45 s for the head, so 10 s is generous
|
|
62
|
+
* to a live machine and still leaves the caller a real answer (a 502 and a
|
|
63
|
+
* dropped memo) instead of the kernel's SYN retry budget. */
|
|
64
|
+
export const MACHINE_DIAL_CONNECT_TIMEOUT_MS = 10_000;
|
|
65
|
+
|
|
66
|
+
/** A resolved machine route: where to dial, and the connect bound the dial
|
|
67
|
+
* must carry because the address is memoised. */
|
|
68
|
+
export interface MachineTunnelRouteTarget {
|
|
69
|
+
host: string;
|
|
70
|
+
port: number;
|
|
71
|
+
connectTimeoutMs: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const memo = new Map<string, { ipv4Address: string; expiresAt: number }>();
|
|
75
|
+
const negative = new Map<string, number>();
|
|
76
|
+
const inFlight = new Map<string, Promise<string | null>>();
|
|
77
|
+
/** Bumped by every invalidation; a resolution only writes the memo if the
|
|
78
|
+
* generation it started under is still current. */
|
|
79
|
+
const generation = new Map<string, number>();
|
|
80
|
+
|
|
81
|
+
export async function resolveMachineTunnelRoute<
|
|
82
|
+
Task extends { codeServerPort?: number | null },
|
|
83
|
+
>(
|
|
84
|
+
request: MachineTunnelRouteRequest,
|
|
85
|
+
task: Task,
|
|
86
|
+
deps: MachineTunnelRouteDeps<Task>,
|
|
87
|
+
): Promise<MachineTunnelRouteTarget | null> {
|
|
88
|
+
// The cheap refusal first: an unroutable request never costs an API call.
|
|
89
|
+
const port =
|
|
90
|
+
request.target === "editor"
|
|
91
|
+
? (task.codeServerPort ?? null)
|
|
92
|
+
: (request.containerPort ?? null);
|
|
93
|
+
if (!port) return null;
|
|
94
|
+
const host = await machineAddress(request.taskId, task, deps);
|
|
95
|
+
return host === null
|
|
96
|
+
? null
|
|
97
|
+
: { host, port, connectTimeoutMs: MACHINE_DIAL_CONNECT_TIMEOUT_MS };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function machineAddress<Task>(
|
|
101
|
+
taskId: string,
|
|
102
|
+
task: Task,
|
|
103
|
+
deps: MachineTunnelRouteDeps<Task>,
|
|
104
|
+
): Promise<string | null> {
|
|
105
|
+
const now = deps.now();
|
|
106
|
+
const hit = memo.get(taskId);
|
|
107
|
+
if (hit && hit.expiresAt > now) return hit.ipv4Address;
|
|
108
|
+
memo.delete(taskId);
|
|
109
|
+
const notRunningUntil = negative.get(taskId);
|
|
110
|
+
if (notRunningUntil !== undefined && notRunningUntil > now) return null;
|
|
111
|
+
negative.delete(taskId);
|
|
112
|
+
const pending = inFlight.get(taskId);
|
|
113
|
+
if (pending) return pending;
|
|
114
|
+
const startedUnder = generation.get(taskId) ?? 0;
|
|
115
|
+
const resolution = (async (): Promise<string | null> => {
|
|
116
|
+
const environment = await deps.reconstruct(task);
|
|
117
|
+
const route = environment?.inspectRoute
|
|
118
|
+
? await environment.inspectRoute()
|
|
119
|
+
: null;
|
|
120
|
+
const settledAt = deps.now();
|
|
121
|
+
const stillCurrent = (generation.get(taskId) ?? 0) === startedUnder;
|
|
122
|
+
if (!route || route.kind !== "running") {
|
|
123
|
+
if (stillCurrent) {
|
|
124
|
+
negative.set(taskId, settledAt + MACHINE_ROUTE_NEGATIVE_TTL_MS);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (stillCurrent) {
|
|
129
|
+
memo.set(taskId, {
|
|
130
|
+
ipv4Address: route.ipv4Address,
|
|
131
|
+
expiresAt: settledAt + (deps.ttlMs ?? MACHINE_ROUTE_TTL_MS),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return route.ipv4Address;
|
|
135
|
+
})().finally(() => {
|
|
136
|
+
inFlight.delete(taskId);
|
|
137
|
+
});
|
|
138
|
+
inFlight.set(taskId, resolution);
|
|
139
|
+
return resolution;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A dial to the memoised address failed, or the task was stopped, started or
|
|
143
|
+
* torn down: the next open must describe the machine again, and a description
|
|
144
|
+
* already in flight must not write what it learned before this point. */
|
|
145
|
+
export function invalidateMachineTunnelRoute(taskId: string): void {
|
|
146
|
+
memo.delete(taskId);
|
|
147
|
+
negative.delete(taskId);
|
|
148
|
+
generation.set(taskId, (generation.get(taskId) ?? 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resetMachineTunnelRoutesForTesting(): void {
|
|
152
|
+
memo.clear();
|
|
153
|
+
negative.clear();
|
|
154
|
+
inFlight.clear();
|
|
155
|
+
generation.clear();
|
|
156
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -112,6 +112,12 @@ import {
|
|
|
112
112
|
import { reconstructPersistedTaskEnvironment } from "../lib/task-environment";
|
|
113
113
|
import { logVnodePressure } from "../lib/vnode-pressure";
|
|
114
114
|
import { resolveAppleTunnelRoute } from "./apple-tunnel-route";
|
|
115
|
+
import {
|
|
116
|
+
invalidateMachineTunnelRoute,
|
|
117
|
+
resolveMachineTunnelRoute,
|
|
118
|
+
} from "./machine-tunnel-route";
|
|
119
|
+
import { armConnectTimeout } from "./tunnel-dial";
|
|
120
|
+
import { scheduleActivationRespawn } from "./activation-respawn";
|
|
115
121
|
import {
|
|
116
122
|
configureBundledContainerRuntime,
|
|
117
123
|
selectedRuntimeMatchesPersistedTaskEnvironments,
|
|
@@ -554,9 +560,16 @@ async function startLocalUi(): Promise<void> {
|
|
|
554
560
|
stopTask: async (taskId) => {
|
|
555
561
|
const runtimeProblem = containerRuntimeProblem();
|
|
556
562
|
if (runtimeProblem) return { ok: false, error: runtimeProblem };
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
563
|
+
// ADR-121: a stopped machine loses its memoised address (same
|
|
564
|
+
// before-and-after discipline as the cloud's taskStop command).
|
|
565
|
+
invalidateMachineTunnelRoute(taskId);
|
|
566
|
+
try {
|
|
567
|
+
const result = await getOrchestrator().stopTask(taskId);
|
|
568
|
+
if (result.ok) queueStoppedTaskStatus(taskId);
|
|
569
|
+
return result;
|
|
570
|
+
} finally {
|
|
571
|
+
invalidateMachineTunnelRoute(taskId);
|
|
572
|
+
}
|
|
560
573
|
},
|
|
561
574
|
});
|
|
562
575
|
console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
|
|
@@ -1852,6 +1865,18 @@ function handleHttpTunnelOpen(
|
|
|
1852
1865
|
},
|
|
1853
1866
|
);
|
|
1854
1867
|
|
|
1868
|
+
// ADR-121: a memoised machine address may black-hole (instance stopped
|
|
1869
|
+
// while memoised), and a black-holed dial neither errors nor answers for
|
|
1870
|
+
// the kernel's SYN budget. Bound the connect phase only — never `timeout`
|
|
1871
|
+
// on the request, which is idle semantics and would cut quiet streams. A
|
|
1872
|
+
// socket the agent reuses arrives connected and is left alone.
|
|
1873
|
+
if (target.connectTimeoutMs !== undefined && !target.socket) {
|
|
1874
|
+
const connectTimeoutMs = target.connectTimeoutMs;
|
|
1875
|
+
upstream.once("socket", (socket: Socket) => {
|
|
1876
|
+
armConnectTimeout(socket, target, connectTimeoutMs);
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1855
1880
|
// Registering replays anything that arrived while the target resolved — for a
|
|
1856
1881
|
// bodyless GET that is the `requestEnd` this request cannot reply without.
|
|
1857
1882
|
tunnels.register(frame.tunnelId, upstream);
|
|
@@ -1861,6 +1886,9 @@ function handleHttpTunnelOpen(
|
|
|
1861
1886
|
// ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
|
|
1862
1887
|
// the next request recreates it (no-op for published / container-IP targets).
|
|
1863
1888
|
invalidatePreviewSidecar(target.port);
|
|
1889
|
+
// ADR-121: a memoised machine address that no longer answers is stale —
|
|
1890
|
+
// a refused dial and a connect timeout (tunnel-dial.ts) both land here.
|
|
1891
|
+
invalidateMachineTunnelRoute(frame.taskId);
|
|
1864
1892
|
if (!acked) {
|
|
1865
1893
|
send(wsSocket, {
|
|
1866
1894
|
kind: "tunnel.ack",
|
|
@@ -1927,6 +1955,8 @@ function handleRawTunnelOpen(
|
|
|
1927
1955
|
// ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
|
|
1928
1956
|
// the next request recreates it (no-op for published / container-IP targets).
|
|
1929
1957
|
invalidatePreviewSidecar(target.port);
|
|
1958
|
+
// ADR-121: a memoised machine address that no longer answers is stale.
|
|
1959
|
+
invalidateMachineTunnelRoute(frame.taskId);
|
|
1930
1960
|
if (!acked) {
|
|
1931
1961
|
send(wsSocket, {
|
|
1932
1962
|
kind: "tunnel.ack",
|
|
@@ -1954,7 +1984,16 @@ function handleRawTunnelOpen(
|
|
|
1954
1984
|
});
|
|
1955
1985
|
});
|
|
1956
1986
|
|
|
1957
|
-
if (!target.socket)
|
|
1987
|
+
if (!target.socket) {
|
|
1988
|
+
upstream.connect(target.port, target.host);
|
|
1989
|
+
// ADR-121: a memoised machine address may black-hole; bound the connect
|
|
1990
|
+
// phase (tunnel-dial.ts) so the 'error' handler above runs and drops the
|
|
1991
|
+
// memo instead of the pane hanging on the kernel's SYN budget. Armed
|
|
1992
|
+
// after connect() so the socket is `connecting`; disarmed on 'connect'.
|
|
1993
|
+
if (target.connectTimeoutMs !== undefined) {
|
|
1994
|
+
armConnectTimeout(upstream, target, target.connectTimeoutMs);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1958
1997
|
// AFTER `connect`, not before: replaying a queued write onto a socket that
|
|
1959
1998
|
// has not started connecting errors, while node buffers writes made once it
|
|
1960
1999
|
// is connecting. An upgrade's own request goes out from the `connect`
|
|
@@ -1988,6 +2027,12 @@ interface UpstreamAddr {
|
|
|
1988
2027
|
* (ADR-106 apple routing). An established TCP peer cannot be retargeted by
|
|
1989
2028
|
* later IP reuse, so consumers must use this socket instead of redialing. */
|
|
1990
2029
|
socket?: Socket;
|
|
2030
|
+
/** Connect-phase bound for a dial to this address, set only by the ADR-121
|
|
2031
|
+
* machine branch: its address is memoised and may black-hole after an
|
|
2032
|
+
* external stop, and a black-holed dial neither errors nor answers for the
|
|
2033
|
+
* kernel's SYN budget. Loopback, sidecar and pre-established (`socket`)
|
|
2034
|
+
* targets carry none — their dials fail fast or are already connected. */
|
|
2035
|
+
connectTimeoutMs?: number;
|
|
1991
2036
|
}
|
|
1992
2037
|
|
|
1993
2038
|
async function resolveTunnelTarget(
|
|
@@ -2004,17 +2049,23 @@ async function resolveTunnelTarget(
|
|
|
2004
2049
|
// the container runtime's health. macOS-local machines need a published
|
|
2005
2050
|
// fallback (bridge IPs are not host-routable there) — recorded ADR-121
|
|
2006
2051
|
// follow-up, not silently misrouted: the connect simply fails.
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2052
|
+
// The address resolution is a cloud API call and the cloud opens one
|
|
2053
|
+
// tunnel per request, so it is memoised per task for a short TTL and
|
|
2054
|
+
// dropped on a failed dial or a stop — see machine-tunnel-route.ts.
|
|
2055
|
+
return resolveMachineTunnelRoute(
|
|
2056
|
+
{
|
|
2057
|
+
taskId: frame.taskId,
|
|
2058
|
+
target: frame.target,
|
|
2059
|
+
...(frame.containerPort !== undefined
|
|
2060
|
+
? { containerPort: frame.containerPort }
|
|
2061
|
+
: {}),
|
|
2062
|
+
},
|
|
2063
|
+
machineTask,
|
|
2064
|
+
{
|
|
2065
|
+
reconstruct: (task) => reconstructPersistedTaskEnvironment(task),
|
|
2066
|
+
now: Date.now,
|
|
2067
|
+
},
|
|
2068
|
+
);
|
|
2018
2069
|
}
|
|
2019
2070
|
if (containerRuntimeProblem()) return null;
|
|
2020
2071
|
const task = getHostTask(frame.taskId);
|
|
@@ -2275,18 +2326,36 @@ async function dispatchCommand(
|
|
|
2275
2326
|
args: unknown[],
|
|
2276
2327
|
): Promise<HostCommandResult<unknown>> {
|
|
2277
2328
|
switch (command) {
|
|
2278
|
-
case "taskUp":
|
|
2279
|
-
|
|
2329
|
+
case "taskUp": {
|
|
2330
|
+
const launchInput = expectTaskLaunchInput(args, 0);
|
|
2331
|
+
// A (re)started machine may come back on a new address, and one just
|
|
2332
|
+
// seen as not-running is about to run: forget both, before and after.
|
|
2333
|
+
invalidateMachineTunnelRoute(launchInput.task.id);
|
|
2334
|
+
return hostCommands
|
|
2335
|
+
.taskUp(ctx, launchInput)
|
|
2336
|
+
.finally(() => invalidateMachineTunnelRoute(launchInput.task.id));
|
|
2337
|
+
}
|
|
2280
2338
|
case "taskDown": {
|
|
2281
2339
|
const downInput = expectTaskDownInput(args, 0);
|
|
2282
|
-
|
|
2340
|
+
// A torn-down machine loses its address: drop the memo before (opens in
|
|
2341
|
+
// flight must not re-memoise) and after (opens that raced the command).
|
|
2342
|
+
invalidateMachineTunnelRoute(downInput.taskId);
|
|
2343
|
+
return hostCommands
|
|
2344
|
+
.taskDown(ctx, downInput)
|
|
2345
|
+
.finally(() => invalidateMachineTunnelRoute(downInput.taskId));
|
|
2346
|
+
}
|
|
2347
|
+
case "taskStop": {
|
|
2348
|
+
const stopTaskId = typeof args[0] === "string" ? args[0] : "";
|
|
2349
|
+
// A stopped machine loses its address; same before-and-after discipline.
|
|
2350
|
+
invalidateMachineTunnelRoute(stopTaskId);
|
|
2351
|
+
return hostCommands
|
|
2352
|
+
.taskStop(
|
|
2353
|
+
ctx,
|
|
2354
|
+
stopTaskId,
|
|
2355
|
+
(args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
|
|
2356
|
+
)
|
|
2357
|
+
.finally(() => invalidateMachineTunnelRoute(stopTaskId));
|
|
2283
2358
|
}
|
|
2284
|
-
case "taskStop":
|
|
2285
|
-
return hostCommands.taskStop(
|
|
2286
|
-
ctx,
|
|
2287
|
-
typeof args[0] === "string" ? args[0] : "",
|
|
2288
|
-
(args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
|
|
2289
|
-
);
|
|
2290
2359
|
case "taskStatus":
|
|
2291
2360
|
return hostCommands.taskStatus(ctx, expectString(args, 0));
|
|
2292
2361
|
case "channelEnsure":
|
|
@@ -3330,13 +3399,27 @@ function maybeExit(): void {
|
|
|
3330
3399
|
}
|
|
3331
3400
|
if (ws.readyState === WebSocket.CLOSING) return;
|
|
3332
3401
|
}
|
|
3402
|
+
if (shutdownExitCode === MANAGED_UPDATE_RESTART_EXIT_CODE) {
|
|
3403
|
+
// The drain is complete: this is the last thing the old generation does.
|
|
3404
|
+
// On a launchd host the respawn is scheduled from here rather than left
|
|
3405
|
+
// to KeepAlive — see activation-respawn.ts for the two 2026-09-06 stalls.
|
|
3406
|
+
const plan = scheduleActivationRespawn();
|
|
3407
|
+
console.log(
|
|
3408
|
+
plan.scheduled
|
|
3409
|
+
? `[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`
|
|
3410
|
+
: `[host-agent] exiting ${shutdownExitCode} for the supervisor to relaunch runtime/current (no launchctl kickstart scheduled: ${plan.reason})`,
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
3333
3413
|
process.exit(shutdownExitCode);
|
|
3334
3414
|
}
|
|
3335
3415
|
|
|
3336
3416
|
/**
|
|
3337
3417
|
* Finish any in-flight bridge command, then let the service supervisor exec
|
|
3338
3418
|
* the newly activated `runtime/current`. The non-zero status is intentional:
|
|
3339
|
-
* Linux uses Restart=on-failure
|
|
3419
|
+
* Linux uses Restart=on-failure and the desktop supervisor restarts on any
|
|
3420
|
+
* exit. launchd's KeepAlive is supposed to respawn it too but has been seen
|
|
3421
|
+
* not to (2026-09-06, twice), so maybeExit() also schedules an explicit
|
|
3422
|
+
* `launchctl kickstart` on macOS before the process exits.
|
|
3340
3423
|
*/
|
|
3341
3424
|
export function requestManagedHostRestart(): void {
|
|
3342
3425
|
shutdownExitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
|
|
@@ -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
|
+
}
|