screentinker/server/test/socket-handler-isolation.test.js
ScreenTinker cbf81a05a3 fix(#146): crash-hardening — one device's handler throw can't take down the fleet
Found in the alpha load test: client-chosen pairing codes collide by birthday
paradox, the provisioning INSERT hit UNIQUE(devices.pairing_code), the SqliteError
threw out of the (synchronous) socket handler -> uncaughtException -> logFatalAndExit
-> the WHOLE server exited and every device dropped. The colliding flood crash-LOOPED
the container (2 restarts).

Two layers, same "one device can't take down the fleet" theme as #142/#143/#144:

1. Narrow (deviceSocket.js): wrap the device:register provisioning INSERT in
   try/catch — a UNIQUE pairing_code collision (or ANY db error) rejects THAT
   registration (device:auth-error -> client retries) instead of throwing.
   currentDeviceId/authenticated now set only AFTER the row exists (no half-auth
   socket on failure).

2. Broader (lib/safe-socket.js): protectSocket() overrides socket.on per connection
   so any handler throw is caught, logged (event + id + stack), the socket told, and
   DISCONNECTED — per-CONNECTION fail-fast, not whole-PROCESS. We don't keep serving a
   connection from possibly-half-mutated state (honors the existing fail-fast intent),
   we just contain it to "one device reconnects" (a non-event after beta5). Wired into
   both the /device and /dashboard connection handlers; auto-covers future handlers.
   Audited first: no handler throws as control flow, so blanket-wrapping is safe.

Tests (mutation-verified, fail without their fix):
- register-insert-crash.test.js: a pairing_code collision AND a general bind error
  each reject-one-device with no uncaughtException; server keeps serving.
- socket-handler-isolation.test.js: a throwing handler disconnects only that socket;
  the server + other sockets stay alive.
Full suite 243/243.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:38:11 -05:00

71 lines
3 KiB
JavaScript

'use strict';
// #146 scale-hardening — protectSocket() narrows fail-fast from whole-PROCESS to
// single-CONNECTION. A throwing socket handler must disconnect ONLY that socket and
// leave the server + every other socket fully alive — instead of escalating to
// uncaughtException -> process exit -> fleet outage (the alpha load-test crash).
//
// Teeth: an 'uncaughtException' capture asserts no throw escaped; client B proves the
// server kept serving. Swap protectSocket() for a raw socket.on (mutation) and this
// goes RED — the throw becomes an uncaughtException and socket A is never disconnected.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { Server } = require('socket.io');
const ioClient = require('socket.io-client');
const { protectSocket } = require('../lib/safe-socket');
let httpServer, io, base;
const uncaught = [];
const onUncaught = (e) => uncaught.push(e);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
before(async () => {
process.on('uncaughtException', onUncaught);
httpServer = http.createServer();
io = new Server(httpServer);
io.of('/t').on('connection', (socket) => {
protectSocket(socket, () => socket.id); // <-- the fix under test
socket.on('boom', () => { throw new Error('handler blew up'); });
socket.on('ping', (data, ack) => { if (ack) ack('pong'); });
});
await new Promise((r) => httpServer.listen(0, r));
base = `http://127.0.0.1:${httpServer.address().port}`;
});
after(() => {
process.off('uncaughtException', onUncaught);
try { io.close(); } catch { /* */ }
try { httpServer.close(); } catch { /* */ }
});
const connect = () => ioClient(`${base}/t`, { transports: ['websocket'], reconnection: false, forceNew: true });
const onceConnected = (s) => new Promise((r) => s.on('connect', r));
test('a throwing handler disconnects only that socket; server + other sockets survive', async () => {
const A = connect(), B = connect();
await Promise.all([onceConnected(A), onceConnected(B)]);
let aGotError = false;
A.on('server:error', (m) => { if (m && m.event === 'boom') aGotError = true; });
const aDisconnected = new Promise((r) => A.on('disconnect', () => r(true)));
// Sanity: B works before the boom.
const pre = await new Promise((r) => B.emit('ping', {}, r));
assert.equal(pre, 'pong', 'B responsive before the throw');
// A triggers a handler throw.
A.emit('boom', {});
const dropped = await Promise.race([aDisconnected, sleep(2000).then(() => false)]);
assert.equal(dropped, true, 'the throwing socket (A) is disconnected');
assert.equal(uncaught.length, 0, 'no uncaughtException escaped to the process');
// The server is still up and a DIFFERENT socket is unaffected.
const post = await new Promise((r) => B.emit('ping', {}, r));
assert.equal(post, 'pong', 'B still served after A threw — server survived');
assert.ok(aGotError, 'A was told via server:error before being dropped');
try { B.close(); } catch { /* */ }
});