mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
Real production incident (2026-08-29): the crash guard #11556 introduced defaulted its logger to `log ?? console` — console is an object, not a function, so a burst of client aborts (ECONNRESET) reaching the process-level guard threw TypeError inside the uncaughtException handler itself and killed the server, twice in three minutes. Fix: default to console.warn.bind(console). Bug-injection round trip confirms the new test fails on the old default and passes on the fix. Existing guard suite stays green: 9/9 (verified together with the new test).
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* HTTP client-abort crash guard (#fix-dev-server-aborted).
|
|
*
|
|
* Node's http.Server turns an 'error' event on an IncomingMessage/ServerResponse
|
|
* into an uncaughtException (and therefore a process exit) WHENEVER the emitter
|
|
* has no listener. The single most common such error is a *client* abort: the
|
|
* browser closes the TCP socket (navigation, Back/Forward cache, HMR reconnect,
|
|
* cancelling a fetch) while the server is still streaming the response. Node
|
|
* emits `Error: aborted` / `ERR_STREAM_PREMATURE_CLOSE` / `ECONNRESET` on the
|
|
* request stream, and absent a handler it kills the whole server process.
|
|
*
|
|
* That surfaced as "login succeeds, then the dashboard hangs with a wall of
|
|
* `net::ERR_CONNECTION_REFUSED`": after auth the SPA opens many polling
|
|
* connections + a live WebSocket; stray client-side socket closes during
|
|
* navigation/HMR were taking the dev server down.
|
|
*
|
|
* Two layers:
|
|
* 1. `attachRequestStreamGuards(req, res)` — per-request listeners that absorb
|
|
* client-abort errors so they never bubble to the process level. Call it
|
|
* inside every `http.createServer((req, res) => …)` request listener.
|
|
* 2. `installProcessCrashGuard()` — a last-resort safety net on
|
|
* `process.on('uncaughtException' | 'unhandledRejection')` that swallows
|
|
* the same benign client-abort errors but otherwise preserves the existing
|
|
* crash semantics (so genuine bugs still surface). Idempotent.
|
|
*
|
|
* Kept as a `.mjs` module (no build step) so it is importable both from the
|
|
* Node-only dev server (`scripts/dev/run-next.mjs`) and from the TypeScript
|
|
* servers under `src/` (tsconfig `allowJs: true`).
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
/**
|
|
* @param {unknown} err
|
|
* @returns {boolean} true when `err` represents a client closing the
|
|
* connection rather than a server-side fault.
|
|
*/
|
|
export function isClientAbortError(err) {
|
|
if (!err || typeof err !== "object") return false;
|
|
const e = /** @type {NodeJS.ErrnoException} */ (err);
|
|
// Node emits `Error: aborted` (no code) from http.Server#abortIncoming.
|
|
if (e.message === "aborted" || e.message === "Aborted") return true;
|
|
switch (e.code) {
|
|
case "ERR_STREAM_PREMATURE_CLOSE":
|
|
case "ECONNRESET":
|
|
case "EPIPE":
|
|
case "ECONNABORTED":
|
|
case "ETIMEDOUT":
|
|
case "ENOTCONN":
|
|
case "ECANCELED":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decide whether a process-level uncaughtException/unhandledRejection should be
|
|
* swallowed (benign client-abort) or allowed to surface (genuine bug).
|
|
*
|
|
* Pure + exported so it can be unit-tested without poking process listeners.
|
|
*
|
|
* @param {unknown} err
|
|
* @param {string | undefined} origin Node's uncaughtException origin (e.g.
|
|
* "uncaughtException" / "unhandledRejection"); absent/empty for rejections.
|
|
* @returns {boolean} true => swallow (log only), false => re-throw / let crash.
|
|
*/
|
|
export function shouldSwallowUncaught(err, origin) {
|
|
if (!isClientAbortError(err)) return false;
|
|
// Only swallow when the origin matches what the guard installed for. If some
|
|
// other subsystem raised it (e.g. a deliberate `throw` in a domain), keep the
|
|
// existing crash semantics.
|
|
return !origin || origin === "uncaughtException" || origin === "unhandledRejection";
|
|
}
|
|
|
|
/**
|
|
* Attach `error` listeners to a request/response pair that swallow client-abort
|
|
* errors. Idempotent per (req, res) pair via a Symbol flag.
|
|
*
|
|
* @param {import("node:http").IncomingMessage} req
|
|
* @param {import("node:http").ServerResponse} res
|
|
*/
|
|
export function attachRequestStreamGuards(req, res) {
|
|
const flag = Symbol.for("omniroute.requestAbortGuard");
|
|
if (req[flag] || res[flag]) return;
|
|
req[flag] = true;
|
|
res[flag] = true;
|
|
|
|
req.on("error", (err) => {
|
|
if (!isClientAbortError(err)) {
|
|
// Re-emit a genuine request error through the normal channel so it is
|
|
// still observable in logs, but never as an uncaughtException.
|
|
console.error("[server] request stream error:", err);
|
|
}
|
|
});
|
|
|
|
res.on("error", (err) => {
|
|
if (!isClientAbortError(err)) {
|
|
console.error("[server] response stream error:", err);
|
|
}
|
|
});
|
|
}
|
|
|
|
let crashGuardInstalled = false;
|
|
|
|
/**
|
|
* Install process-level safety nets. Idempotent. Benign client-abort errors are
|
|
* logged once and swallowed; everything else is re-thrown on a fresh stack so
|
|
* the process keeps its current crash semantics (genuine bugs still crash/hang
|
|
* loudly, and a supervisor or test harness sees them).
|
|
*
|
|
* @param {(level: "warn" | "error", ...args: unknown[]) => void} [log]
|
|
*/
|
|
export function installProcessCrashGuard(log) {
|
|
if (crashGuardInstalled) return;
|
|
crashGuardInstalled = true;
|
|
|
|
// `console` is an object, not a callable: `log ?? console` followed by
|
|
// `logger("warn", ...)` throws TypeError and kills the process on the very
|
|
// abort the guard exists to swallow. Default to console.warn as a function.
|
|
const logger = typeof log === "function" ? log : console.warn.bind(console);
|
|
|
|
process.on("uncaughtException", (err, origin) => {
|
|
if (shouldSwallowUncaught(err, origin)) {
|
|
logger("warn", "[server] swallowed client-abort uncaughtException:", err?.message ?? err);
|
|
return;
|
|
}
|
|
throw err;
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason) => {
|
|
if (shouldSwallowUncaught(reason, "unhandledRejection")) {
|
|
logger(
|
|
"warn",
|
|
"[server] swallowed client-abort unhandledRejection:",
|
|
reason?.message ?? reason
|
|
);
|
|
return;
|
|
}
|
|
throw reason;
|
|
});
|
|
}
|