Files
gods-eye-view/src/data/aisWatchdogTransport.test.mjs
T

144 lines
5.7 KiB
JavaScript

// Transport proof for the AISStream watchdog's central design choice:
// teardown must be ws.terminate(), never a graceful close().
//
// This is the assumption the reverted watchdog got wrong. It is pinned here
// against a real black-hole peer — a server that completes the websocket
// handshake and then never speaks again, which is exactly how AISStream fails
// (upstream: handshake succeeds, no frames, no error, no close). Everything
// runs on an ephemeral localhost port; no network access is involved.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { createHash } from 'node:crypto';
import WebSocketImpl from 'ws';
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
/**
* A websocket peer that upgrades the connection and then goes silent forever.
* It never sends frames and never answers a close frame, so a graceful close
* handshake can never complete.
*/
function startBlackHoleServer() {
let liveConnections = 0;
/** Every accepted socket, so teardown can destroy the wedged ones. */
const accepted = new Set();
const server = net.createServer((socket) => {
liveConnections += 1;
accepted.add(socket);
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('latin1');
if (!buffer.includes('\r\n\r\n')) return;
const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buffer)?.[1]?.trim();
if (!key) return;
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n'
+ 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
);
buffer = '';
// ...and then nothing, ever. Incoming CLOSE frames are ignored.
});
socket.on('close', () => {
liveConnections -= 1;
accepted.delete(socket);
});
socket.on('error', () => {});
});
return {
server,
listen: () => new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve(`ws://127.0.0.1:${server.address().port}`));
}),
get liveConnections() { return liveConnections; },
close: () => new Promise((resolve) => {
// A socket this suite deliberately wedged in CLOSING keeps the event loop
// alive forever; server.close() only stops new accepts, so the wedged
// connections have to be destroyed by hand.
for (const socket of [...accepted]) socket.destroy();
accepted.clear();
server.close(() => resolve());
}),
};
}
/** Resolve to true if `event` fires on `emitter` within `ms`. */
function firedWithin(subscribe, ms) {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), ms);
subscribe(() => {
clearTimeout(timer);
resolve(true);
});
});
}
const blackHole = startBlackHoleServer();
const url = await blackHole.listen();
after(() => blackHole.close());
test('ws.terminate() hard-aborts a black-holed socket and frees the connection slot', async () => {
const socket = new WebSocketImpl(url);
await new Promise((resolve) => socket.once('open', resolve));
assert.equal(socket.readyState, WebSocketImpl.OPEN);
assert.equal(typeof socket.terminate, 'function', 'the adapter depends on terminate()');
assert.equal(blackHole.liveConnections, 1);
const closed = firedWithin((done) => socket.once('close', done), 1_000);
const startedAt = Date.now();
socket.terminate();
assert.equal(await closed, true, 'terminate() must complete against a silent peer');
assert.ok(Date.now() - startedAt < 1_000);
assert.equal(socket.readyState, WebSocketImpl.CLOSED);
// The TCP connection is genuinely gone — this is what protects AISStream's
// one-connection-per-key limit when the watchdog recycles.
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(blackHole.liveConnections, 0, 'the socket slot must actually be released');
});
test('the built-in WebSocket close() never completes on a black-holed socket', async () => {
// This is the regression that wedged the reverted watchdog: Node's global
// WebSocket exposes no terminate(), and close() waits for a close frame the
// dead peer will never send. If a future Node release fixes this, the
// assertion below fails loudly and the design can be revisited.
assert.equal(typeof WebSocket, 'function');
assert.equal(
typeof (new WebSocket(url)).terminate, 'undefined',
'built-in WebSocket has no hard-abort; that is why ws is a dependency',
);
const socket = new WebSocket(url);
await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true }));
assert.equal(socket.readyState, WebSocket.OPEN);
const closed = firedWithin(
(done) => socket.addEventListener('close', done, { once: true }),
1_500,
);
socket.close(1000, 'watchdog recycle');
assert.equal(socket.readyState, WebSocket.CLOSING);
assert.equal(await closed, false, 'close() hangs in CLOSING — measured well past 30s by hand');
assert.equal(socket.readyState, WebSocket.CLOSING, 'the socket slot is never handed back');
});
test('a terminated socket hands the single connection slot straight to its replacement', async () => {
const first = new WebSocketImpl(url);
await new Promise((resolve) => first.once('open', resolve));
first.terminate();
await new Promise((resolve) => first.once('close', resolve));
// The watchdog's reconnect happens right here; it must not be refused.
const second = new WebSocketImpl(url);
await new Promise((resolve) => second.once('open', resolve));
assert.equal(second.readyState, WebSocketImpl.OPEN);
second.terminate();
await new Promise((resolve) => second.once('close', resolve));
});