@runuai/host 0.8.44 → 0.8.45

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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tunnel upstreams, plus the frames that arrive before an upstream exists.
3
+ *
4
+ * `tunnel.open` is handled ASYNCHRONOUSLY on the host: resolving a target can
5
+ * await the task lifecycle lock and, for an ad-hoc preview, `docker run` of a
6
+ * sidecar (ADR-043). The frames that follow it — `tunnel.data`,
7
+ * `tunnel.requestEnd`, `tunnel.close` — are handled synchronously off the same
8
+ * WebSocket, and the cloud emits `requestEnd` for a bodyless GET on the tick
9
+ * after `open`. So they routinely land while the open is still resolving.
10
+ *
11
+ * Looking the tunnel up with `tunnels.get(id)?.` silently dropped them, and a
12
+ * dropped `requestEnd` is unrecoverable rather than merely late. The HTTP open
13
+ * deliberately leaves its `ClientRequest` unended so request bodies can stream,
14
+ * and node writes no header until `write`/`end` — so the upstream socket
15
+ * CONNECTS and then receives nothing at all. On the wire that reads as an
16
+ * established TCP connection carrying zero bytes, an upstream that never saw a
17
+ * request, and a flat 30s `504 host did not respond in time` from the cloud's
18
+ * ack deadline: four symptoms that each point somewhere different, none of them
19
+ * at the open that never finished.
20
+ *
21
+ * So ops for an opening tunnel are QUEUED and replayed on `register`. The
22
+ * queue is bounded by the request itself — the cloud stops sending once its
23
+ * ack deadline fires — and is dropped by `abandon` when an open fails, so a
24
+ * tunnel that never opens leaves nothing behind.
25
+ */
26
+
27
+ export interface TunnelUpstream {
28
+ write(chunk: Buffer): boolean;
29
+ end(): void;
30
+ destroy(): void;
31
+ }
32
+
33
+ type TunnelOp = (upstream: TunnelUpstream) => void;
34
+
35
+ export class TunnelRegistry {
36
+ private readonly open = new Map<string, TunnelUpstream>();
37
+ /** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
38
+ private readonly opening = new Map<string, TunnelOp[]>();
39
+
40
+ /**
41
+ * Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
42
+ * is received — before the first `await` of target resolution — or the frames
43
+ * that follow have nothing to queue against and are dropped, which is the
44
+ * whole bug this class exists for.
45
+ */
46
+ markOpening(tunnelId: string): void {
47
+ this.opening.set(tunnelId, []);
48
+ }
49
+
50
+ /** Target resolved: publish the upstream and replay what arrived meanwhile. */
51
+ register(tunnelId: string, upstream: TunnelUpstream): void {
52
+ this.open.set(tunnelId, upstream);
53
+ const queued = this.opening.get(tunnelId);
54
+ this.opening.delete(tunnelId);
55
+ if (!queued) return;
56
+ for (const op of queued) op(upstream);
57
+ }
58
+
59
+ /** Open failed (no target, or it errored) — nothing will ever service these. */
60
+ abandon(tunnelId: string): void {
61
+ this.opening.delete(tunnelId);
62
+ }
63
+
64
+ get(tunnelId: string): TunnelUpstream | undefined {
65
+ return this.open.get(tunnelId);
66
+ }
67
+
68
+ delete(tunnelId: string): void {
69
+ this.open.delete(tunnelId);
70
+ }
71
+
72
+ /**
73
+ * Run `op` against the upstream now, or queue it if the tunnel is still
74
+ * opening. A tunnel this registry has never heard of is dropped, exactly as
75
+ * the old `?.` did — that is a frame for a tunnel already torn down, not a
76
+ * race.
77
+ */
78
+ apply(tunnelId: string, op: TunnelOp): void {
79
+ const upstream = this.open.get(tunnelId);
80
+ if (upstream) {
81
+ op(upstream);
82
+ return;
83
+ }
84
+ this.opening.get(tunnelId)?.push(op);
85
+ }
86
+
87
+ /**
88
+ * Tear the tunnel down now, or as soon as it opens. Queued ops are discarded
89
+ * rather than replayed first: they exist to be written to an upstream that is
90
+ * about to be destroyed, and a `write` after `destroy` is at best wasted and
91
+ * at worst an unhandled error on a socket nobody is listening to any more.
92
+ */
93
+ abort(tunnelId: string): void {
94
+ const upstream = this.open.get(tunnelId);
95
+ this.open.delete(tunnelId);
96
+ if (upstream) {
97
+ upstream.destroy();
98
+ return;
99
+ }
100
+ if (this.opening.has(tunnelId)) {
101
+ this.opening.set(tunnelId, [(u) => u.destroy()]);
102
+ }
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.44",
3
+ "version": "0.8.45",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/main.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  ensurePreviewSidecar,
60
60
  invalidatePreviewSidecar,
61
61
  } from "../lib/preview-sidecar";
62
+ import { TunnelRegistry } from "../lib/tunnel-registry";
62
63
  import { newId } from "../lib/ulid";
63
64
  import {
64
65
  capabilities as agentKindCapabilities,
@@ -126,13 +127,7 @@ let reconnectAttempt = 0;
126
127
  let inFlight = 0;
127
128
  let shutdownRequested = false;
128
129
  let pendingBinaryTunnelId: string | null = null;
129
- const tunnels = new Map<string, TunnelUpstream>();
130
-
131
- interface TunnelUpstream {
132
- write(chunk: Buffer): boolean;
133
- end(): void;
134
- destroy(): void;
135
- }
130
+ const tunnels = new TunnelRegistry();
136
131
 
137
132
  interface PausableSource {
138
133
  pause(): unknown;
@@ -301,7 +296,8 @@ function connect(): void {
301
296
  const tunnelId = pendingBinaryTunnelId;
302
297
  pendingBinaryTunnelId = null;
303
298
  if (!tunnelId) return;
304
- tunnels.get(tunnelId)?.write(rawDataToBuffer(data));
299
+ const chunk = rawDataToBuffer(data);
300
+ tunnels.apply(tunnelId, (upstream) => upstream.write(chunk));
305
301
  return;
306
302
  }
307
303
 
@@ -315,6 +311,10 @@ function connect(): void {
315
311
  void handleCommand(socket, frame);
316
312
  break;
317
313
  case "tunnel.open":
314
+ // Synchronously, BEFORE handleTunnelOpen's first await: the frames that
315
+ // follow this one (a bodyless GET's `requestEnd` arrives on the next
316
+ // tick) need somewhere to queue while the target resolves.
317
+ tunnels.markOpening(frame.tunnelId);
318
318
  void handleTunnelOpen(socket, frame);
319
319
  break;
320
320
  case "tunnel.data":
@@ -322,7 +322,10 @@ function connect(): void {
322
322
  break;
323
323
  case "tunnel.requestEnd":
324
324
  // Request body complete → finish the upstream request so it can reply.
325
- tunnels.get(frame.tunnelId)?.end();
325
+ // Queued if the open is still resolving: node flushes no request header
326
+ // until `end`, so losing this strands the upstream on a connected
327
+ // socket that never receives a byte.
328
+ tunnels.apply(frame.tunnelId, (upstream) => upstream.end());
326
329
  break;
327
330
  case "tunnel.close":
328
331
  closeTunnel(socket, frame.tunnelId, frame.reason);
@@ -497,6 +500,8 @@ async function handleTunnelOpen(
497
500
  ): Promise<void> {
498
501
  const target = await resolveTunnelTarget(frame);
499
502
  if (!target) {
503
+ // Nothing will ever service what queued behind this open.
504
+ tunnels.abandon(frame.tunnelId);
500
505
  send(wsSocket, {
501
506
  kind: "tunnel.ack",
502
507
  tunnelId: frame.tunnelId,
@@ -555,7 +560,9 @@ function handleHttpTunnelOpen(
555
560
  },
556
561
  );
557
562
 
558
- tunnels.set(frame.tunnelId, upstream);
563
+ // Registering replays anything that arrived while the target resolved — for a
564
+ // bodyless GET that is the `requestEnd` this request cannot reply without.
565
+ tunnels.register(frame.tunnelId, upstream);
559
566
 
560
567
  upstream.on("error", (err) => {
561
568
  tunnels.delete(frame.tunnelId);
@@ -595,7 +602,6 @@ function handleRawTunnelOpen(
595
602
  target: UpstreamAddr,
596
603
  ): void {
597
604
  const upstream = new Socket();
598
- tunnels.set(frame.tunnelId, upstream);
599
605
 
600
606
  let acked = false;
601
607
  let responseBuffer = Buffer.alloc(0);
@@ -660,6 +666,11 @@ function handleRawTunnelOpen(
660
666
  });
661
667
 
662
668
  upstream.connect(target.port, target.host);
669
+ // AFTER `connect`, not before: replaying a queued write onto a socket that
670
+ // has not started connecting errors, while node buffers writes made once it
671
+ // is connecting. An upgrade's own request goes out from the `connect`
672
+ // handler, so only client frames that overtook the open replay here.
673
+ tunnels.register(frame.tunnelId, upstream);
663
674
  }
664
675
 
665
676
  function closeTunnel(
@@ -667,9 +678,9 @@ function closeTunnel(
667
678
  tunnelId: string,
668
679
  reason: string | undefined,
669
680
  ): void {
670
- const upstream = tunnels.get(tunnelId);
671
- tunnels.delete(tunnelId);
672
- upstream?.destroy();
681
+ // `abort`, not get+delete: a client that gives up while the target is still
682
+ // resolving would otherwise leave the tunnel to open onto nobody.
683
+ tunnels.abort(tunnelId);
673
684
  send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
674
685
  }
675
686