fix(server): survive stray uncaught exceptions and invalid dev-tunnel base URLs

A single uncaught exception (e.g. a Node-internal socket error) no longer
shuts the local server down; only a sustained storm does. The dev-tunnel
client now rejects non-http(s) base URLs cleanly instead of throwing an
uncaught exception in the connection handler.
This commit is contained in:
Bohdan Triapitsyn
2026-08-14 12:45:35 +03:00
parent b5c9da4ff0
commit 7cf869d5eb
5 changed files with 98 additions and 2 deletions
@@ -1,3 +1,7 @@
/** One stray uncaught exception is survivable; a storm means the process is broken. */
const UNCAUGHT_STORM_LIMIT = 10;
const UNCAUGHT_STORM_WINDOW_MS = 60_000;
export const createServerStartupRuntime = (dependencies) => {
const {
process,
@@ -148,9 +152,26 @@ export const createServerStartupRuntime = (dependencies) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
// A single stray exception — a socket teardown race, a Node-internal bug
// like `setTypeOfService EINVAL` — must not take the server down. Nothing
// restarts this process (it is embedded in the desktop app or run by hand
// in a terminal), so shutting down turns every such stray into "the
// instance is unreachable until I restart it". Mirror the
// unhandledRejection policy above: log and keep serving. A sustained storm
// of exceptions is a different situation — the process is genuinely
// broken — so that still shuts down rather than limping along half-alive.
const exceptionTimes = [];
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
gracefulShutdown();
const now = Date.now();
exceptionTimes.push(now);
while (exceptionTimes.length > 0 && now - exceptionTimes[0] > UNCAUGHT_STORM_WINDOW_MS) {
exceptionTimes.shift();
}
if (exceptionTimes.length > UNCAUGHT_STORM_LIMIT) {
console.error(`More than ${UNCAUGHT_STORM_LIMIT} uncaught exceptions within a minute; shutting down.`);
gracefulShutdown();
}
});
};
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import { EventEmitter } from 'node:events';
import { createServerStartupRuntime } from './server-startup-runtime.js';
/**
* The desktop app embeds this server and nothing restarts it, so shutting down
* on a single uncaught exception turned every stray socket error into "the
* instance is unreachable until restarted". Only a sustained storm shuts down.
*/
describe('uncaught exception policy', () => {
const setup = () => {
const fakeProcess = new EventEmitter();
let shutdowns = 0;
const runtime = createServerStartupRuntime({
process: fakeProcess,
gracefulShutdown: () => { shutdowns += 1; },
getSignalsAttached: () => true,
setSignalsAttached: () => {},
syncToHmrState: () => {},
});
runtime.attachProcessHandlers({ attachSignals: false });
return { fakeProcess, shutdowns: () => shutdowns };
};
test('a single uncaught exception keeps the server running', () => {
const { fakeProcess, shutdowns } = setup();
fakeProcess.emit('uncaughtException', new Error('setTypeOfService EINVAL'));
expect(shutdowns()).toBe(0);
});
test('a storm of uncaught exceptions still shuts down', () => {
const { fakeProcess, shutdowns } = setup();
for (let i = 0; i < 11; i += 1) {
fakeProcess.emit('uncaughtException', new Error(`stray ${i}`));
}
expect(shutdowns()).toBeGreaterThan(0);
});
test('an unhandled rejection is logged without shutting down', () => {
const { fakeProcess, shutdowns } = setup();
fakeProcess.emit('unhandledRejection', new Error('late failure'), Promise.resolve());
expect(shutdowns()).toBe(0);
});
});