@runuai/host 0.9.97 → 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.
- package/package.json +1 -1
- package/src/machine-tunnel-route.ts +136 -0
- package/src/main.ts +62 -23
package/package.json
CHANGED
|
@@ -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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|