Files
OmniRoute/tests/unit/httpClientAbortGuard.test.mjs
Alvin T. Veroy 668beed5b8 fix(sse): absorb AbortError/request_signal_aborted in the client-abort crash guard (#12165)
OmniRoute's SSE teardown aborts in-flight legs with
`Error [AbortError]: request_signal_aborted` on client disconnects
(open-sse/utils/streamHandler.ts getClientAbortReason), and fetch/DOM
cancellation surfaces as AbortError with an abort-flavoured message.
isClientAbortError() only matched message 'aborted'/'Aborted' plus errno
codes, so these shapes fell through shouldSwallowUncaught() and were
re-thrown from the process-level uncaughtException/unhandledRejection
handlers — killing the whole server on a routine client disconnect
(observed as repeated exit-code-7 crashes with
'uncaughtException: Error [AbortError]: request_signal_aborted').

Match AbortError by name when the message is abort-flavoured; genuine
errors that merely mention 'abort' (e.g. TypeError) still crash loudly.

Tests: new unit cases for the SSE/DOM AbortError shapes, a child-process
regression proving the process survives both benign emissions with the
production no-logger install shape, and a child-process test proving
genuine errors keep crash semantics.
2026-08-31 14:10:33 -03:00

205 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
import assert from "node:assert";
import { test } from "node:test";
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
isClientAbortError,
shouldSwallowUncaught,
attachRequestStreamGuards,
installProcessCrashGuard,
} from "../../scripts/dev/httpClientAbortGuard.mjs";
import * as sharedGuard from "../../src/shared/utils/httpClientAbortGuard.mjs";
// The dev server imports from scripts/dev/httpClientAbortGuard.mjs, while the
// TypeScript servers (apiBridgeServer, liveServer, embedWsProxy) import from
// src/shared/utils/httpClientAbortGuard.mjs. The scripts/dev copy must be a pure
// re-export of the shared implementation — verify they are the SAME functions
// (single source of truth, no drift).
test("scripts/dev guard re-exports the shared src implementation (single source of truth)", () => {
assert.equal(isClientAbortError, sharedGuard.isClientAbortError);
assert.equal(shouldSwallowUncaught, sharedGuard.shouldSwallowUncaught);
assert.equal(attachRequestStreamGuards, sharedGuard.attachRequestStreamGuards);
assert.equal(installProcessCrashGuard, sharedGuard.installProcessCrashGuard);
// And the shared module exposes everything the TS servers rely on.
for (const name of [
"isClientAbortError",
"shouldSwallowUncaught",
"attachRequestStreamGuards",
"installProcessCrashGuard",
]) {
assert.equal(typeof sharedGuard[name], "function", `shared guard must export ${name}`);
}
});
// Minimal stand-ins for IncomingMessage / ServerResponse that expose the
// `error` event (Node's http streams are EventEmitters).
function makeReq() {
return new EventEmitter();
}
function makeRes() {
const res = new EventEmitter();
res.end = () => res;
res.write = () => true;
return res;
}
test("isClientAbortError matches the exact production crash signature", () => {
// Reproduces the Node `abortIncoming` error seen in the app log:
// uncaughtException: aborted / Error: aborted (no code)
const aborted = Object.assign(new Error("aborted"), {});
assert.equal(isClientAbortError(aborted), true, "plain 'aborted' must be absorbed");
for (const code of [
"ECONNRESET",
"EPIPE",
"ERR_STREAM_PREMATURE_CLOSE",
"ECONNABORTED",
"ETIMEDOUT",
"ENOTCONN",
"ECANCELED",
]) {
const err = Object.assign(new Error(code), { code });
assert.equal(isClientAbortError(err), true, `${code} must be absorbed`);
}
});
test("isClientAbortError rejects genuine server errors", () => {
const real = Object.assign(new Error("boom"), { code: "ENOSPC" });
assert.equal(isClientAbortError(real), false);
const noCode = new Error("something else entirely");
assert.equal(isClientAbortError(noCode), false);
});
test("attachRequestStreamGuards swallows a client abort on req without throwing", () => {
const req = makeReq();
const res = makeRes();
attachRequestStreamGuards(req, res);
// Must NOT throw / bubble as uncaughtException.
assert.doesNotThrow(() => {
req.emit("error", Object.assign(new Error("aborted"), {}));
res.emit("error", Object.assign(new Error("aborted"), {}));
});
});
test("attachRequestStreamGuards is idempotent (no double listeners / no throw)", () => {
const req = makeReq();
const res = makeRes();
attachRequestStreamGuards(req, res);
assert.doesNotThrow(() => attachRequestStreamGuards(req, res));
// A second abort must also be absorbed quietly.
assert.doesNotThrow(() => {
req.emit("error", Object.assign(new Error("ECONNRESET"), { code: "ECONNRESET" }));
});
});
test("shouldSwallowUncaught absorbs the real 'aborted' uncaughtException signature", () => {
// The exact error Node raises from http.Server#abortIncoming in the log:
// uncaughtException: aborted / Error: aborted (no code)
const abortErr = new Error("aborted");
assert.equal(shouldSwallowUncaught(abortErr, "uncaughtException"), true);
assert.equal(shouldSwallowUncaught(abortErr, undefined), true);
assert.equal(
shouldSwallowUncaught(Object.assign(new Error("ECONNRESET"), { code: "ECONNRESET" }), "uncaughtException"),
true
);
});
test("shouldSwallowUncaught preserves crash semantics for genuine errors", () => {
const realErr = new Error("genuine failure");
assert.equal(shouldSwallowUncaught(realErr, "uncaughtException"), false);
const realErr2 = Object.assign(new Error("disk full"), { code: "ENOSPC" });
assert.equal(shouldSwallowUncaught(realErr2, "uncaughtException"), false);
});
test("installProcessCrashGuard does not throw on import and is idempotent", () => {
assert.doesNotThrow(() => installProcessCrashGuard(() => {}));
});
test("isClientAbortError matches OmniRoute SSE AbortError shapes (#fix-crash-guard-logger-7)", () => {
// Exact production shape from the 2026-08-31 crash log:
// unhandledRejection: Error [AbortError]: request_signal_aborted
const sseAbort = Object.assign(new Error("request_signal_aborted"), { name: "AbortError" });
assert.equal(isClientAbortError(sseAbort), true, "SSE teardown AbortError must be absorbed");
// fetch / DOMException-style cancellation
const domAbort = new DOMException("This operation was aborted", "AbortError");
assert.equal(isClientAbortError(domAbort), true, "DOMException AbortError must be absorbed");
// A genuine TypeError that merely MENTIONS 'abort' must NOT be absorbed.
const typo = new TypeError("Cannot read properties of undefined (reading 'abort')");
assert.equal(isClientAbortError(typo), false);
});
test("shouldSwallowUncaught absorbs SSE AbortError rejections", () => {
const sseAbort = Object.assign(new Error("request_signal_aborted"), { name: "AbortError" });
assert.equal(shouldSwallowUncaught(sseAbort, "unhandledRejection"), true);
});
// Production crash (2026-08-25 → 08-31, ~170 restarts, exit code 7):
// every real call site installs the guard with NO logger, so the old
// `const logger = log ?? console` default invoked the console OBJECT as a
// function inside the uncaughtException handler → TypeError inside
// process._fatalException → Node exit code 7. These children run the REAL
// production call shape; the process must survive benign aborts and still
// crash on genuine errors.
test("installProcessCrashGuard() with no logger swallows aborts instead of dying (exit-7 regression)", async () => {
const guardPath = fileURLToPath(
new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url)
);
const script = `
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard(); // production call sites pass NO logger
process.emit(
"uncaughtException",
Object.assign(new Error("aborted"), { code: "ECONNRESET" }),
"uncaughtException"
);
process.emit(
"unhandledRejection",
Object.assign(new Error("request_signal_aborted"), { name: "AbortError" }),
Promise.resolve()
);
console.log("ALIVE");
process.exit(0);
`;
const { status, stdout, stderr } = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardPath], {
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("close", (status) => resolve({ status, stdout: out, stderr: err }));
child.on("error", reject);
});
assert.equal(status, 0, `child must survive benign aborts; stderr: ${stderr}`);
assert.match(stdout, /ALIVE/);
});
test("installProcessCrashGuard still crashes on genuine errors (no over-swallowing)", async () => {
const guardPath = fileURLToPath(
new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url)
);
const script = `
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard();
process.emit("uncaughtException", new Error("genuine failure"), "uncaughtException");
console.log("SHOULD_NOT_REACH");
`;
const { status, stdout, stderr: _stderr } = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardPath], {
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("close", (status) => resolve({ status, stdout: out, stderr: err }));
child.on("error", reject);
});
assert.notEqual(status, 0, "genuine errors must keep crash semantics");
assert.doesNotMatch(stdout, /SHOULD_NOT_REACH/);
});