screentinker/server/test/register-insert-crash.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

95 lines
4.3 KiB
JavaScript

'use strict';
// #146 scale-hardening — a DB error in the device:register provisioning INSERT must
// reject THAT device's registration, NOT throw out of the handler -> uncaughtException
// -> logFatalAndExit -> whole-server exit. Found in the alpha load test: client-chosen
// pairing codes collide by birthday paradox, the UNIQUE INSERT threw, and the server
// crash-LOOPED, dropping the entire fleet.
//
// Teeth: a process 'uncaughtException' listener captures any escaped throw. With the
// fix the array stays empty (the INSERT error is caught in-handler) and the colliding
// device gets device:auth-error while the server keeps serving. Neutralize the
// try/catch around the INSERT in deviceSocket.js and this test goes RED — the second
// (colliding) registration throws an uncaughtException.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-regcrash-' + crypto.randomBytes(4).toString('hex'));
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const http = require('node:http');
const { Server } = require('socket.io');
const ioClient = require('socket.io-client');
const setupDeviceSocket = require('../ws/deviceSocket');
let httpServer, io, base;
const uncaught = [];
const onUncaught = (e) => uncaught.push(e);
before(async () => {
process.on('uncaughtException', onUncaught); // capture escaped throws instead of dying
httpServer = http.createServer();
io = new Server(httpServer);
setupDeviceSocket(io);
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}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
// Register with a payload; resolves the outcome (registered | authError | timeout).
function register(payload) {
return new Promise((resolve) => {
const s = connect();
let done = false;
const fin = (r) => { if (done) return; done = true; try { s.close(); } catch { /* */ } resolve(r); };
s.on('connect', () => s.emit('device:register', payload));
s.on('device:registered', (d) => fin({ registered: true, id: d.device_id }));
s.on('device:auth-error', (e) => fin({ authError: true, error: e && e.error }));
setTimeout(() => fin({ timeout: true }), 3000);
});
}
const settle = () => new Promise((r) => setTimeout(r, 150));
test('pairing-code collision rejects the 2nd device, does NOT crash the server', async () => {
const code = String(crypto.randomInt(100000, 1000000));
const a = await register({ pairing_code: code });
assert.ok(a.registered, 'first device with the code registers');
// Same code again -> UNIQUE constraint on devices.pairing_code.
const b = await register({ pairing_code: code });
await settle();
assert.ok(b.authError, 'the colliding 2nd registration is rejected (device:auth-error)');
assert.equal(uncaught.length, 0, 'a collision must NOT raise an uncaughtException');
// Server is still alive: a fresh unique registration still works.
const c = await register({ pairing_code: String(crypto.randomInt(100000, 1000000)) });
assert.ok(c.registered, 'server keeps serving after the collision');
assert.equal(uncaught.length, 0, 'still no uncaughtException');
});
test('a general DB error in the register INSERT also rejects-one, not crash', async () => {
// device_info.screen_width as an object makes better-sqlite3's bind throw a
// DIFFERENT error than UNIQUE — proves the catch is error-type-agnostic.
const r = await register({ pairing_code: String(crypto.randomInt(100000, 1000000)), device_info: { screen_width: {} } });
await settle();
assert.ok(r.authError || r.timeout, 'the bad-bind registration is rejected, not completed');
assert.equal(uncaught.length, 0, 'a general DB/bind error must NOT raise an uncaughtException');
// Server still serving.
const ok = await register({ pairing_code: String(crypto.randomInt(100000, 1000000)) });
assert.ok(ok.registered, 'server keeps serving after a general register error');
assert.equal(uncaught.length, 0, 'still no uncaughtException');
});