@north-light/crouter 0.3.226 → 0.3.227

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.
@@ -11,20 +11,32 @@ class FakeSocket extends EventEmitter {
11
11
  function sleep(ms) {
12
12
  return new Promise((resolve) => setTimeout(resolve, ms));
13
13
  }
14
- test('remote attach heartbeat pings an idle viewer and keeps a ponging observer alive', async () => {
14
+ test('a quiet attach puts nothing on the wire', async () => {
15
+ // Regression: an unconditional 25s ping made a hosted guest's connection never
16
+ // idle, so the sandbox never suspended and whole fleets billed for 15h awake.
15
17
  const ws = new FakeSocket();
16
- const stop = startRemoteAttachHeartbeat(ws, 20);
18
+ const heartbeat = startRemoteAttachHeartbeat(ws, 10, 15);
19
+ await sleep(60);
20
+ assert.equal(ws.pings, 1, 'only the first tick inside the quiet window pings');
21
+ assert.equal(ws.terminated, 0, 'a quiet attach is left open, never terminated by the heartbeat');
22
+ heartbeat.stop();
23
+ });
24
+ test('relayed traffic arms the heartbeat and a pong keeps a relaying observer alive', async () => {
25
+ const ws = new FakeSocket();
26
+ const heartbeat = startRemoteAttachHeartbeat(ws, 20, 10_000);
27
+ heartbeat.noteActivity();
17
28
  await sleep(25);
18
- assert.equal(ws.pings, 1, 'an idle attach receives a WebSocket ping before proxy idle timeout');
29
+ assert.equal(ws.pings, 1, 'a relaying attach is pinged across a gap between frames');
19
30
  ws.emit('pong');
20
31
  await sleep(10);
21
32
  assert.equal(ws.terminated, 0, 'a pong renews the heartbeat without terminating the observer');
22
- stop();
33
+ heartbeat.stop();
23
34
  });
24
- test('remote attach heartbeat drops only an unresponsive observer socket', async () => {
35
+ test('an unresponsive peer is dropped only while the relay is active', async () => {
25
36
  const ws = new FakeSocket();
26
- const stop = startRemoteAttachHeartbeat(ws, 5);
37
+ const heartbeat = startRemoteAttachHeartbeat(ws, 5, 10_000);
38
+ heartbeat.noteActivity();
27
39
  await sleep(16);
28
40
  assert.equal(ws.terminated, 1, 'two missed heartbeat intervals terminate the detached viewer');
29
- stop();
41
+ heartbeat.stop();
30
42
  });
@@ -1,11 +1,42 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { Duplex } from 'node:stream';
3
3
  import { type WebSocket } from 'ws';
4
- /** Keep an otherwise quiet observer connection alive through common 60s proxy
5
- * idle limits. A viewer only observes a broker, so a missed heartbeat may close
6
- * this socket but must never affect the broker or its in-flight work. */
4
+ /** Keep a RELAYING connection alive across a gap between frames, through common
5
+ * 60s proxy idle limits. A viewer only observes a broker, so a missed heartbeat
6
+ * may close this socket but must never affect the broker or its in-flight work. */
7
7
  export declare const REMOTE_ATTACH_HEARTBEAT_MS = 25000;
8
- export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number): () => void;
8
+ /** How long after the last relayed frame the heartbeat keeps pinging. Past this,
9
+ * the attach is carrying nothing — no viewer output, no streaming node — and the
10
+ * heartbeat goes silent so the network path underneath can reap the connection.
11
+ *
12
+ * This matters far beyond a socket: on a hosted guest (Blaxel) a connection that
13
+ * is pinged every 25s is never idle, so the host never suspends the sandbox. An
14
+ * unconditional heartbeat held whole fleets awake for 15h at a time. A drop on a
15
+ * quiet attach is a normal close the remote peer re-attaches from, and real work
16
+ * is anchored by its own keep-alive, not by this ping.
17
+ *
18
+ * The clock starts when the attach OPENS, not at its first relayed frame: a
19
+ * never-relaying attach still gets pings until this window elapses. The welcome
20
+ * frame lands in milliseconds, so this only pads the tail of the very first
21
+ * window.
22
+ *
23
+ * ROLLOUT ORDER — this half is only half the answer, and shipping it FIRST is
24
+ * strictly worse than shipping nothing. The host that owns the attach must also
25
+ * let go of a quiet one; Northlight Core does that at its own 60s quiesce
26
+ * (`CROUTER_FOLLOW_QUIESCE_MS`). Against a host that holds the attach forever,
27
+ * going quiet here only moves the drop to the network path, the host reports a
28
+ * detach, its client reconnects, and the box wakes again — the same pin, now
29
+ * with a full re-snapshot every couple of minutes. Ship the host's quiesce
30
+ * first, then roll the guest image. */
31
+ export declare const REMOTE_ATTACH_QUIET_MS = 60000;
32
+ export interface RemoteAttachHeartbeat {
33
+ /** Stop the heartbeat permanently (teardown). Idempotent. */
34
+ stop: () => void;
35
+ /** Record a relayed frame in either direction — this is what keeps the
36
+ * heartbeat armed, and what re-arms it after a quiet stretch. */
37
+ noteActivity: () => void;
38
+ }
39
+ export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number, quietMs?: number): RemoteAttachHeartbeat;
9
40
  /** Handle the HTTP upgrade for `GET /v1/nodes/{id}/attach`. A-9 matches the
10
41
  * route, extracts `{id}`, and calls this with the raw upgrade triplet; this
11
42
  * owns the WS handshake (its own noServer `wss` above) AND the spec §5.3
@@ -33,13 +33,38 @@ import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/vie
33
33
  * client cap) so a remote peer cannot buffer 256MiB before the broker's 24MiB
34
34
  * decoder rejects the message. */
35
35
  const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
36
- /** Keep an otherwise quiet observer connection alive through common 60s proxy
37
- * idle limits. A viewer only observes a broker, so a missed heartbeat may close
38
- * this socket but must never affect the broker or its in-flight work. */
36
+ /** Keep a RELAYING connection alive across a gap between frames, through common
37
+ * 60s proxy idle limits. A viewer only observes a broker, so a missed heartbeat
38
+ * may close this socket but must never affect the broker or its in-flight work. */
39
39
  export const REMOTE_ATTACH_HEARTBEAT_MS = 25_000;
40
- export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS) {
40
+ /** How long after the last relayed frame the heartbeat keeps pinging. Past this,
41
+ * the attach is carrying nothing — no viewer output, no streaming node — and the
42
+ * heartbeat goes silent so the network path underneath can reap the connection.
43
+ *
44
+ * This matters far beyond a socket: on a hosted guest (Blaxel) a connection that
45
+ * is pinged every 25s is never idle, so the host never suspends the sandbox. An
46
+ * unconditional heartbeat held whole fleets awake for 15h at a time. A drop on a
47
+ * quiet attach is a normal close the remote peer re-attaches from, and real work
48
+ * is anchored by its own keep-alive, not by this ping.
49
+ *
50
+ * The clock starts when the attach OPENS, not at its first relayed frame: a
51
+ * never-relaying attach still gets pings until this window elapses. The welcome
52
+ * frame lands in milliseconds, so this only pads the tail of the very first
53
+ * window.
54
+ *
55
+ * ROLLOUT ORDER — this half is only half the answer, and shipping it FIRST is
56
+ * strictly worse than shipping nothing. The host that owns the attach must also
57
+ * let go of a quiet one; Northlight Core does that at its own 60s quiesce
58
+ * (`CROUTER_FOLLOW_QUIESCE_MS`). Against a host that holds the attach forever,
59
+ * going quiet here only moves the drop to the network path, the host reports a
60
+ * detach, its client reconnects, and the box wakes again — the same pin, now
61
+ * with a full re-snapshot every couple of minutes. Ship the host's quiesce
62
+ * first, then roll the guest image. */
63
+ export const REMOTE_ATTACH_QUIET_MS = 60_000;
64
+ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS, quietMs = REMOTE_ATTACH_QUIET_MS) {
41
65
  let alive = true;
42
66
  let stopped = false;
67
+ let lastActivityAt = Date.now();
43
68
  const acknowledge = () => { alive = true; };
44
69
  const stop = () => {
45
70
  if (stopped)
@@ -50,6 +75,13 @@ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTB
50
75
  };
51
76
  ws.on('pong', acknowledge);
52
77
  const timer = setInterval(() => {
78
+ if (Date.now() - lastActivityAt > quietMs) {
79
+ // Nothing is flowing: send no ping and run no liveness check, so an idle
80
+ // attach puts zero packets on the wire. A dead peer here is closed by the
81
+ // network path or by teardown, not by us.
82
+ alive = true;
83
+ return;
84
+ }
53
85
  if (!alive) {
54
86
  stop();
55
87
  ws.terminate();
@@ -65,7 +97,15 @@ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTB
65
97
  }
66
98
  }, intervalMs);
67
99
  timer.unref();
68
- return stop;
100
+ return {
101
+ stop,
102
+ // Frames are not liveness evidence: only a pong proves the peer is there.
103
+ // Marking a relayed frame alive would disable dead-peer detection for
104
+ // exactly as long as a turn streams — the one stretch it is for.
105
+ noteActivity: () => {
106
+ lastActivityAt = Date.now();
107
+ },
108
+ };
69
109
  }
70
110
  /** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
71
111
  function clampReason(reason) {
@@ -140,7 +180,7 @@ function bridgeConnection(ws, nodeId) {
140
180
  const decoder = new FrameDecoder(CLIENT_READ_CAPS);
141
181
  let closed = false;
142
182
  let viewerFaultRecorded = false;
143
- let stopHeartbeat;
183
+ let heartbeat;
144
184
  socket.on('connect', () => {
145
185
  clearFault(nodeId, { link: 'relay↔broker' });
146
186
  });
@@ -148,7 +188,7 @@ function bridgeConnection(ws, nodeId) {
148
188
  if (closed)
149
189
  return;
150
190
  closed = true;
151
- stopHeartbeat?.();
191
+ heartbeat?.stop();
152
192
  if (!socket.destroyed) {
153
193
  try {
154
194
  socket.destroy();
@@ -164,9 +204,10 @@ function bridgeConnection(ws, nodeId) {
164
204
  /* ignore */
165
205
  }
166
206
  };
167
- stopHeartbeat = startRemoteAttachHeartbeat(ws);
207
+ heartbeat = startRemoteAttachHeartbeat(ws);
168
208
  // socket → WS: bounded decode, one WS text message per complete frame.
169
209
  socket.on('data', (chunk) => {
210
+ heartbeat?.noteActivity();
170
211
  let frames;
171
212
  try {
172
213
  frames = decoder.push(chunk);
@@ -245,6 +286,7 @@ function bridgeConnection(ws, nodeId) {
245
286
  ws.on('message', (data) => {
246
287
  if (closed || socket.destroyed)
247
288
  return;
289
+ heartbeat?.noteActivity();
248
290
  const buf = Array.isArray(data)
249
291
  ? Buffer.concat(data)
250
292
  : Buffer.isBuffer(data)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.226",
3
+ "version": "0.3.227",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.226",
3
+ "version": "0.3.227",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.226",
9
+ "version": "0.3.227",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {