@runuai/host 0.9.96 → 0.9.98

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.
@@ -933,7 +933,7 @@ console.log(
933
933
  * under tiny restart loops — x11vnc EXITS whenever the X server it watches
934
934
  * isn't up yet (the race that killed the first live run), and the loops
935
935
  * also reattach after the display owner changes. Safe to run repeatedly:
936
- * every piece is pgrep-guarded, including the loops themselves.
936
+ * every piece is guarded by the X lockfile or a pidfile, loops included.
937
937
  */
938
938
  /**
939
939
  * The viewer daemons, one docker-exec each (the single-line nohup shape is
@@ -950,6 +950,22 @@ console.log(
950
950
  * proxy only SOMETIMES and reconnects the rest (live 2026-09-02). So the
951
951
  * start branch — reached only when the tracked loop is already dead — first
952
952
  * reaps anything still holding the port, guaranteeing exactly one listener.
953
+ *
954
+ * That reap MUST be `pkill -A` (--ignore-ancestors). The step runs as
955
+ * `sh -c "<step>"`, so the launching shell's own cmdline carries the launch
956
+ * line and matches the reap pattern: plain `pkill -f` SIGTERMed that shell
957
+ * before it reached the nohup, and neither daemon ever started — host
958
+ * 0.9.94–0.9.96, every Linux runtime (the pgrep self-match above, wearing a
959
+ * kill; reproduced in the uai-linux VM: step exit 143, no pidfile). Both
960
+ * shipped images run procps-ng 4.0.2 (Debian bookworm), which has -A; an
961
+ * older procps rejects the flag with a usage error and reaps nothing, which
962
+ * merely restores the pre-reap behaviour and never the self-kill.
963
+ * `-A` protects the launching chain only: the apt worker below inlines
964
+ * these steps into `su -c` / `nohup sh -c` wrappers whose argv also
965
+ * matches, and a per-session reap that lands inside that worker's ~1 s
966
+ * viewer window can kill its node-uid shell. That only costs the worker
967
+ * its done-marker (set -e), so it re-runs on the next ensure and finds the
968
+ * pidfiles alive — self-healing, and no worse than the pre-reap world.
953
969
  */
954
970
  const VIEWER_STEPS: string[] = [
955
971
  // Xvfb — one-shot; the X lockfile is the truth (also set when an agent
@@ -958,9 +974,9 @@ const VIEWER_STEPS: string[] = [
958
974
  `[ -x /usr/bin/Xvfb ] || exit 0; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
959
975
  // x11vnc under a restart loop (it exits whenever the X server isn't up
960
976
  // yet). Pidfile-guarded.
961
- `[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
977
+ `[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
962
978
  // websockify/noVNC under the same pattern.
963
- `[ -x /usr/bin/websockify ] || exit 0; [ -f /tmp/uai-novnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -f 'websockify.*6080' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do /usr/bin/websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
979
+ `[ -x /usr/bin/websockify ] || exit 0; [ -f /tmp/uai-novnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'websockify.*6080' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do /usr/bin/websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
964
980
  ];
965
981
 
966
982
  const DEFAULT_NODE_BIN =
@@ -1085,13 +1101,20 @@ export async function setupBrowserTesting(
1085
1101
  // costs ~0.5s once the build is present.
1086
1102
  const installed = environment
1087
1103
  ? await (async () => {
1088
- await environment.exec({
1104
+ const chown = await environment.exec({
1089
1105
  argv: ["/usr/bin/chown", "node:node", "/opt/pw-browsers"],
1090
1106
  user: "root",
1091
1107
  env: authorityEnv,
1092
1108
  timeoutMs: 5_000,
1093
1109
  maxOutputBytes: 64 * 1024,
1094
1110
  });
1111
+ if (chown.exitCode !== 0) {
1112
+ // Same non-fatal stance as the CLI path below.
1113
+ console.warn(
1114
+ `[browser] task ${taskId}: chown /opt/pw-browsers failed (status ${chown.exitCode}); ` +
1115
+ `continuing with the prewarm. ${Buffer.from(chown.stderr).toString("utf8").slice(0, 200)}`,
1116
+ );
1117
+ }
1095
1118
  return environment.exec({
1096
1119
  argv: ["sh", "-lc", PREWARM_CMD],
1097
1120
  cwd: "/workspace",
@@ -1113,7 +1136,16 @@ export async function setupBrowserTesting(
1113
1136
  ],
1114
1137
  { timeoutMs: 5_000 },
1115
1138
  ).then(async (chown) => {
1116
- if (chown.status !== 0) return chown;
1139
+ if (chown.status !== 0) {
1140
+ // Not fatal. On apple-container /opt/pw-browsers is a host-wide
1141
+ // virtiofs volume whose ownership the guest cannot change (EPERM)
1142
+ // although it is already node-owned inside the container; the
1143
+ // prewarm below is the real test of writability, so let it decide.
1144
+ console.warn(
1145
+ `[browser] task ${taskId}: chown /opt/pw-browsers failed (status ${chown.status}); ` +
1146
+ `continuing with the prewarm. ${chown.stderr.slice(0, 200)}`,
1147
+ );
1148
+ }
1117
1149
  return taskContainerCli(
1118
1150
  [
1119
1151
  "exec",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.96",
3
+ "version": "0.9.98",
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>",
@@ -0,0 +1,136 @@
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) 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.
26
+ */
27
+
28
+ export type MachineTunnelRoute =
29
+ | { kind: "running"; ipv4Address: string }
30
+ | { kind: "unavailable" }
31
+ | { kind: "unknown"; detail: string };
32
+
33
+ export interface MachineTunnelRouteDeps<Task> {
34
+ reconstruct(task: Task): Promise<{
35
+ inspectRoute?: () => Promise<MachineTunnelRoute>;
36
+ } | null>;
37
+ now(): number;
38
+ ttlMs?: number;
39
+ }
40
+
41
+ export interface MachineTunnelRouteRequest {
42
+ taskId: string;
43
+ target: string;
44
+ containerPort?: number;
45
+ }
46
+
47
+ /** Long enough to cover a pane or editor load (dozens of opens inside a few
48
+ * seconds), short enough that a machine's address is never trusted past the
49
+ * next stop/start cycle by much; a failed dial drops it sooner. */
50
+ export const MACHINE_ROUTE_TTL_MS = 45_000;
51
+ /** How long a not-running answer is trusted before the machine is asked
52
+ * again — long enough to absorb a reconnecting pane, short enough that a wake
53
+ * is seen within seconds. */
54
+ export const MACHINE_ROUTE_NEGATIVE_TTL_MS = 5_000;
55
+
56
+ const memo = new Map<string, { ipv4Address: string; expiresAt: number }>();
57
+ const negative = new Map<string, number>();
58
+ const inFlight = new Map<string, Promise<string | null>>();
59
+ /** Bumped by every invalidation; a resolution only writes the memo if the
60
+ * generation it started under is still current. */
61
+ const generation = new Map<string, number>();
62
+
63
+ export async function resolveMachineTunnelRoute<
64
+ Task extends { codeServerPort?: number | null },
65
+ >(
66
+ request: MachineTunnelRouteRequest,
67
+ task: Task,
68
+ deps: MachineTunnelRouteDeps<Task>,
69
+ ): Promise<{ host: string; port: number } | null> {
70
+ // The cheap refusal first: an unroutable request never costs an API call.
71
+ const port =
72
+ request.target === "editor"
73
+ ? (task.codeServerPort ?? null)
74
+ : (request.containerPort ?? null);
75
+ if (!port) return null;
76
+ const host = await machineAddress(request.taskId, task, deps);
77
+ return host === null ? null : { host, port };
78
+ }
79
+
80
+ async function machineAddress<Task>(
81
+ taskId: string,
82
+ task: Task,
83
+ deps: MachineTunnelRouteDeps<Task>,
84
+ ): Promise<string | null> {
85
+ const now = deps.now();
86
+ const hit = memo.get(taskId);
87
+ if (hit && hit.expiresAt > now) return hit.ipv4Address;
88
+ memo.delete(taskId);
89
+ const notRunningUntil = negative.get(taskId);
90
+ if (notRunningUntil !== undefined && notRunningUntil > now) return null;
91
+ negative.delete(taskId);
92
+ const pending = inFlight.get(taskId);
93
+ if (pending) return pending;
94
+ const startedUnder = generation.get(taskId) ?? 0;
95
+ const resolution = (async (): Promise<string | null> => {
96
+ const environment = await deps.reconstruct(task);
97
+ const route = environment?.inspectRoute
98
+ ? await environment.inspectRoute()
99
+ : null;
100
+ const settledAt = deps.now();
101
+ const stillCurrent = (generation.get(taskId) ?? 0) === startedUnder;
102
+ if (!route || route.kind !== "running") {
103
+ if (stillCurrent) {
104
+ negative.set(taskId, settledAt + MACHINE_ROUTE_NEGATIVE_TTL_MS);
105
+ }
106
+ return null;
107
+ }
108
+ if (stillCurrent) {
109
+ memo.set(taskId, {
110
+ ipv4Address: route.ipv4Address,
111
+ expiresAt: settledAt + (deps.ttlMs ?? MACHINE_ROUTE_TTL_MS),
112
+ });
113
+ }
114
+ return route.ipv4Address;
115
+ })().finally(() => {
116
+ inFlight.delete(taskId);
117
+ });
118
+ inFlight.set(taskId, resolution);
119
+ return resolution;
120
+ }
121
+
122
+ /** A dial to the memoised address failed, or the task was stopped, started or
123
+ * torn down: the next open must describe the machine again, and a description
124
+ * already in flight must not write what it learned before this point. */
125
+ export function invalidateMachineTunnelRoute(taskId: string): void {
126
+ memo.delete(taskId);
127
+ negative.delete(taskId);
128
+ generation.set(taskId, (generation.get(taskId) ?? 0) + 1);
129
+ }
130
+
131
+ export function resetMachineTunnelRoutesForTesting(): void {
132
+ memo.clear();
133
+ negative.clear();
134
+ inFlight.clear();
135
+ generation.clear();
136
+ }
package/src/main.ts CHANGED
@@ -112,6 +112,10 @@ 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";
115
119
  import {
116
120
  configureBundledContainerRuntime,
117
121
  selectedRuntimeMatchesPersistedTaskEnvironments,
@@ -554,9 +558,16 @@ async function startLocalUi(): Promise<void> {
554
558
  stopTask: async (taskId) => {
555
559
  const runtimeProblem = containerRuntimeProblem();
556
560
  if (runtimeProblem) return { ok: false, error: runtimeProblem };
557
- const result = await getOrchestrator().stopTask(taskId);
558
- if (result.ok) queueStoppedTaskStatus(taskId);
559
- return result;
561
+ // ADR-121: a stopped machine loses its memoised address (same
562
+ // before-and-after discipline as the cloud's taskStop command).
563
+ invalidateMachineTunnelRoute(taskId);
564
+ try {
565
+ const result = await getOrchestrator().stopTask(taskId);
566
+ if (result.ok) queueStoppedTaskStatus(taskId);
567
+ return result;
568
+ } finally {
569
+ invalidateMachineTunnelRoute(taskId);
570
+ }
560
571
  },
561
572
  });
562
573
  console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
@@ -1861,6 +1872,8 @@ function handleHttpTunnelOpen(
1861
1872
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1862
1873
  // the next request recreates it (no-op for published / container-IP targets).
1863
1874
  invalidatePreviewSidecar(target.port);
1875
+ // ADR-121: a memoised machine address that no longer answers is stale.
1876
+ invalidateMachineTunnelRoute(frame.taskId);
1864
1877
  if (!acked) {
1865
1878
  send(wsSocket, {
1866
1879
  kind: "tunnel.ack",
@@ -1927,6 +1940,8 @@ function handleRawTunnelOpen(
1927
1940
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1928
1941
  // the next request recreates it (no-op for published / container-IP targets).
1929
1942
  invalidatePreviewSidecar(target.port);
1943
+ // ADR-121: a memoised machine address that no longer answers is stale.
1944
+ invalidateMachineTunnelRoute(frame.taskId);
1930
1945
  if (!acked) {
1931
1946
  send(wsSocket, {
1932
1947
  kind: "tunnel.ack",
@@ -2004,17 +2019,23 @@ async function resolveTunnelTarget(
2004
2019
  // the container runtime's health. macOS-local machines need a published
2005
2020
  // fallback (bridge IPs are not host-routable there) — recorded ADR-121
2006
2021
  // follow-up, not silently misrouted: the connect simply fails.
2007
- const environment = await reconstructPersistedTaskEnvironment(machineTask);
2008
- const route = environment?.inspectRoute
2009
- ? await environment.inspectRoute()
2010
- : null;
2011
- if (!route || route.kind !== "running") return null;
2012
- const port =
2013
- frame.target === "editor"
2014
- ? (machineTask.codeServerPort ?? null)
2015
- : (frame.containerPort ?? null);
2016
- if (!port) return null;
2017
- return { host: route.ipv4Address, port };
2022
+ // The address resolution is a cloud API call and the cloud opens one
2023
+ // tunnel per request, so it is memoised per task for a short TTL and
2024
+ // dropped on a failed dial or a stop — see machine-tunnel-route.ts.
2025
+ return resolveMachineTunnelRoute(
2026
+ {
2027
+ taskId: frame.taskId,
2028
+ target: frame.target,
2029
+ ...(frame.containerPort !== undefined
2030
+ ? { containerPort: frame.containerPort }
2031
+ : {}),
2032
+ },
2033
+ machineTask,
2034
+ {
2035
+ reconstruct: (task) => reconstructPersistedTaskEnvironment(task),
2036
+ now: Date.now,
2037
+ },
2038
+ );
2018
2039
  }
2019
2040
  if (containerRuntimeProblem()) return null;
2020
2041
  const task = getHostTask(frame.taskId);
@@ -2275,18 +2296,36 @@ async function dispatchCommand(
2275
2296
  args: unknown[],
2276
2297
  ): Promise<HostCommandResult<unknown>> {
2277
2298
  switch (command) {
2278
- case "taskUp":
2279
- return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
2299
+ case "taskUp": {
2300
+ const launchInput = expectTaskLaunchInput(args, 0);
2301
+ // A (re)started machine may come back on a new address, and one just
2302
+ // seen as not-running is about to run: forget both, before and after.
2303
+ invalidateMachineTunnelRoute(launchInput.task.id);
2304
+ return hostCommands
2305
+ .taskUp(ctx, launchInput)
2306
+ .finally(() => invalidateMachineTunnelRoute(launchInput.task.id));
2307
+ }
2280
2308
  case "taskDown": {
2281
2309
  const downInput = expectTaskDownInput(args, 0);
2282
- return hostCommands.taskDown(ctx, downInput);
2310
+ // A torn-down machine loses its address: drop the memo before (opens in
2311
+ // flight must not re-memoise) and after (opens that raced the command).
2312
+ invalidateMachineTunnelRoute(downInput.taskId);
2313
+ return hostCommands
2314
+ .taskDown(ctx, downInput)
2315
+ .finally(() => invalidateMachineTunnelRoute(downInput.taskId));
2316
+ }
2317
+ case "taskStop": {
2318
+ const stopTaskId = typeof args[0] === "string" ? args[0] : "";
2319
+ // A stopped machine loses its address; same before-and-after discipline.
2320
+ invalidateMachineTunnelRoute(stopTaskId);
2321
+ return hostCommands
2322
+ .taskStop(
2323
+ ctx,
2324
+ stopTaskId,
2325
+ (args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
2326
+ )
2327
+ .finally(() => invalidateMachineTunnelRoute(stopTaskId));
2283
2328
  }
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
2329
  case "taskStatus":
2291
2330
  return hostCommands.taskStatus(ctx, expectString(args, 0));
2292
2331
  case "channelEnsure":