mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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.
205 lines
8.5 KiB
JavaScript
205 lines
8.5 KiB
JavaScript
"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/);
|
||
});
|