mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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>
95 lines
4.3 KiB
JavaScript
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');
|
|
});
|