@runuai/host 0.9.98 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.98",
3
+ "version": "0.9.99",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -35,9 +35,21 @@ import {
35
35
 
36
36
  export const LABEL = "com.runuai.host";
37
37
 
38
- const uid = process.getuid?.() ?? 0;
39
- const domain = `gui/${uid}`;
40
- const target = `${domain}/${LABEL}`;
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 out = step(false, "launchctl", ["print", target], { ignoreError: true });
939
- if (!out) return "not installed / not loaded";
940
- const state = /state = (\S+)/.exec(out)?.[1] ?? "unknown";
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(
@@ -19,10 +19,14 @@
19
19
  * (the pane's first burst is half a dozen parallel requests), and a machine
20
20
  * seen not running is not asked again for a few seconds (noVNC retries every
21
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) hangs on the OS connect
24
- * timeout instead — machine dials carry no connect timeout of their own,
25
- * which predates this module — so the TTL is the real bound there.
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.
26
30
  */
27
31
 
28
32
  export type MachineTunnelRoute =
@@ -52,6 +56,20 @@ export const MACHINE_ROUTE_TTL_MS = 45_000;
52
56
  * again — long enough to absorb a reconnecting pane, short enough that a wake
53
57
  * is seen within seconds. */
54
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
+ }
55
73
 
56
74
  const memo = new Map<string, { ipv4Address: string; expiresAt: number }>();
57
75
  const negative = new Map<string, number>();
@@ -66,7 +84,7 @@ export async function resolveMachineTunnelRoute<
66
84
  request: MachineTunnelRouteRequest,
67
85
  task: Task,
68
86
  deps: MachineTunnelRouteDeps<Task>,
69
- ): Promise<{ host: string; port: number } | null> {
87
+ ): Promise<MachineTunnelRouteTarget | null> {
70
88
  // The cheap refusal first: an unroutable request never costs an API call.
71
89
  const port =
72
90
  request.target === "editor"
@@ -74,7 +92,9 @@ export async function resolveMachineTunnelRoute<
74
92
  : (request.containerPort ?? null);
75
93
  if (!port) return null;
76
94
  const host = await machineAddress(request.taskId, task, deps);
77
- return host === null ? null : { host, port };
95
+ return host === null
96
+ ? null
97
+ : { host, port, connectTimeoutMs: MACHINE_DIAL_CONNECT_TIMEOUT_MS };
78
98
  }
79
99
 
80
100
  async function machineAddress<Task>(
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,
@@ -1863,6 +1865,18 @@ function handleHttpTunnelOpen(
1863
1865
  },
1864
1866
  );
1865
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
+
1866
1880
  // Registering replays anything that arrived while the target resolved — for a
1867
1881
  // bodyless GET that is the `requestEnd` this request cannot reply without.
1868
1882
  tunnels.register(frame.tunnelId, upstream);
@@ -1872,7 +1886,8 @@ function handleHttpTunnelOpen(
1872
1886
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1873
1887
  // the next request recreates it (no-op for published / container-IP targets).
1874
1888
  invalidatePreviewSidecar(target.port);
1875
- // ADR-121: a memoised machine address that no longer answers is stale.
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.
1876
1891
  invalidateMachineTunnelRoute(frame.taskId);
1877
1892
  if (!acked) {
1878
1893
  send(wsSocket, {
@@ -1969,7 +1984,16 @@ function handleRawTunnelOpen(
1969
1984
  });
1970
1985
  });
1971
1986
 
1972
- if (!target.socket) upstream.connect(target.port, target.host);
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
+ }
1973
1997
  // AFTER `connect`, not before: replaying a queued write onto a socket that
1974
1998
  // has not started connecting errors, while node buffers writes made once it
1975
1999
  // is connecting. An upgrade's own request goes out from the `connect`
@@ -2003,6 +2027,12 @@ interface UpstreamAddr {
2003
2027
  * (ADR-106 apple routing). An established TCP peer cannot be retargeted by
2004
2028
  * later IP reuse, so consumers must use this socket instead of redialing. */
2005
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;
2006
2036
  }
2007
2037
 
2008
2038
  async function resolveTunnelTarget(
@@ -3369,13 +3399,27 @@ function maybeExit(): void {
3369
3399
  }
3370
3400
  if (ws.readyState === WebSocket.CLOSING) return;
3371
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
+ }
3372
3413
  process.exit(shutdownExitCode);
3373
3414
  }
3374
3415
 
3375
3416
  /**
3376
3417
  * Finish any in-flight bridge command, then let the service supervisor exec
3377
3418
  * the newly activated `runtime/current`. The non-zero status is intentional:
3378
- * Linux uses Restart=on-failure, while launchd KeepAlive also respawns it.
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.
3379
3423
  */
3380
3424
  export function requestManagedHostRestart(): void {
3381
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
+ }