@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('
|
|
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
|
|
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, '
|
|
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('
|
|
35
|
+
test('an unresponsive peer is dropped only while the relay is active', async () => {
|
|
25
36
|
const ws = new FakeSocket();
|
|
26
|
-
const
|
|
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
|
|
5
|
-
* idle limits. A viewer only observes a broker, so a missed heartbeat
|
|
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
|
-
|
|
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
|
|
37
|
-
* idle limits. A viewer only observes a broker, so a missed heartbeat
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
9
|
+
"version": "0.3.227",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|