OmniRoute/tests/unit/httpClientAbortGuard.test.mjs
R. Beltran f80cba2cde
fix(resilience): keep RELAY_TIMEOUT from killing the server via the crash guard (#15340)
Onda 5. Este squash entra com o crédito de @Theadd (fix(resilience): keep RELAY_TIMEOUT from killing the server via the crash guard).

O board único validou os 28 PRs juntos: file-size OK, changelog OK, i18n drift OK, typecheck:core OK, complexity 2976/3218, cognitive 1358/1437. Testes focados: 545 passaram, 0 falharam, 3 skip em 52 arquivos. Cinco timeouts de resilience-http-e2e eram carga e passaram isolados.

O rebaseline de file-size e o adopt de i18n ficam num follow-up, fora deste squash.

⚠️ base-red inherited: #15306

Obrigado, @Theadd.
2026-10-06 19:14:09 -03:00

350 lines
15 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 {
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(() => {}));
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);
});
test("isClientAbortError absorbs combo abort reasons (exact 2026-08-31 hedge-cancelled crash shape)", () => {
// omniroute.log, 2026-08-31T23:47Z: the process died at this guard's own
// uncaughtException re-throw with
// Error [AbortError]: hedge-cancelled
// at ... AbortController.abort ... handleDisconnect
// targetTimeoutRunner.ts aborts with new Error("hedge-cancelled"); by the
// time it escapes, the thrower has stamped name = "AbortError" on it.
const relabelled = Object.assign(new Error("hedge-cancelled"), { name: "AbortError" });
assert.equal(isClientAbortError(relabelled), true, "relabelled AbortError must be absorbed");
const raw = new Error("hedge-cancelled");
assert.equal(isClientAbortError(raw), true, "raw abort reason (name=Error) must be absorbed");
const stalled = new Error("combo-per-model-timeout");
assert.equal(
isClientAbortError(stalled),
true,
"per-model timeout abort reason must be absorbed"
);
// Look-alikes that are genuine faults keep crash semantics (exact match only).
assert.equal(isClientAbortError(new Error("hedge-cancelled: unexpected state")), false);
assert.equal(
isClientAbortError(new TypeError("Cannot read properties of undefined (reading 'hedge')")),
false
);
assert.equal(
isClientAbortError(Object.assign(new Error("disk full"), { code: "ENOSPC" })),
false
);
});
test("combo abort reasons in the guard stay in sync with comboAbortReasons.ts", async (t) => {
let mod;
try {
mod = await import("../../open-sse/services/combo/comboAbortReasons.ts");
} catch {
t.skip("TypeScript loader (tsx) not active in this run");
return;
}
for (const reason of [mod.COMBO_HEDGE_CANCELLED_REASON, mod.COMBO_PER_MODEL_TIMEOUT_REASON]) {
assert.equal(typeof reason, "string");
assert.equal(isClientAbortError(new Error(reason)), true, reason + " must be absorbed");
}
});
test("shouldSwallowUncaught absorbs the hedge-cancelled uncaughtException (2026-08-31 crash)", () => {
const hedged = Object.assign(new Error("hedge-cancelled"), { name: "AbortError" });
assert.equal(shouldSwallowUncaught(hedged, "uncaughtException"), true);
assert.equal(shouldSwallowUncaught(hedged, "unhandledRejection"), true);
assert.equal(shouldSwallowUncaught(hedged, undefined), true);
});
function runGuardChild(script) {
// Hand the child a file:// URL, not a filesystem path: dynamic import() of a
// bare Windows path ("C:...") fails with ERR_UNSUPPORTED_ESM_URL_SCHEME.
const guardUrl = new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url).href;
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardUrl], {
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);
});
}
// 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 { status, stdout, stderr } = await runGuardChild(`
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);
`);
assert.equal(status, 0, "child must survive benign aborts; stderr: " + stderr);
assert.match(stdout, /ALIVE/);
});
test("installProcessCrashGuard() survives the real hedge-cancelled uncaughtException", async () => {
// Replays the 2026-08-31 production crash through a real process: the abort
// reason escapes an abort listener as an uncaughtException with the exact
// name/message the log recorded. Pre-fix this exits non-zero at the re-throw.
const { status, stdout, stderr } = await runGuardChild(`
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard();
const ctl = new AbortController();
ctl.signal.addEventListener("abort", () => {
const err = ctl.signal.reason;
err.name = "AbortError";
throw err; // escapes the listener -> uncaughtException
}, { once: true });
process.once("exit", (code) => { if (code === 0) console.log("ALIVE"); });
setTimeout(() => process.exit(0), 50);
ctl.abort(new Error("hedge-cancelled"));
// The verified production route: a leaked upstreamTimeouts abortPromise
// listener rejected a promise nothing awaited -> unhandledRejection.
Promise.reject(Object.assign(new Error("hedge-cancelled"), { name: "AbortError" }));
// streamHandler.ts aborts with raw strings; undici can reject with them verbatim.
Promise.reject("hedge-cancelled");
Promise.reject("request_signal_aborted");
`);
assert.equal(status, 0, "child must survive hedge-cancelled; stderr: " + stderr);
assert.match(stdout, /ALIVE/);
});
test("installProcessCrashGuard() survives the real RELAY_TIMEOUT unhandledRejection (2026-10-02 exit-7)", async () => {
// Production crash 2026-10-02 (exit code 7): a pooled relay attempt exceeded
// RELAY_FETCH_TIMEOUT_MS and open-sse/utils/proxyFetch.ts threw
// Error: [ProxyFetch] Relay timed out after 25000ms (...)
// code: 'RELAY_TIMEOUT', errorCode: 'relay_timeout', statusCode: 504
// The rejection escaped its execute chain (stack: proxyFetch -> executor
// execute x2 -> open-sse handler) as an unhandledRejection and the guard
// re-threw it because RELAY_TIMEOUT was not one of its benign classes.
const { status, stdout, stderr } = await runGuardChild(`
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard();
const timeoutErr = Object.assign(
new Error("[ProxyFetch] Relay timed out after 25000ms (https://relay.example)"),
{ code: "RELAY_TIMEOUT", errorCode: "relay_timeout", statusCode: 504 }
);
Promise.reject(timeoutErr);
Promise.reject("RELAY_TIMEOUT");
process.once("exit", (code) => { if (code === 0) console.log("ALIVE"); });
setTimeout(() => process.exit(0), 50);
`);
assert.equal(status, 0, "child must survive the relay timeout; stderr: " + stderr);
assert.match(stdout, /ALIVE/);
});
test("installProcessCrashGuard still crashes on genuine errors (no over-swallowing)", async () => {
const { status, stdout } = await runGuardChild(`
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard();
console.log("LOADED"); // proves the failure below is the re-throw, not a module-load error
process.emit("uncaughtException", new Error("genuine failure"), "uncaughtException");
console.log("SHOULD_NOT_REACH");
`);
assert.match(stdout, /LOADED/, "guard must have loaded before the genuine error was raised");
assert.notEqual(status, 0, "genuine errors must keep crash semantics");
assert.doesNotMatch(stdout, /SHOULD_NOT_REACH/);
});
test("isClientAbortError absorbs raw string abort reasons from streamHandler (undici rejects with signal.reason verbatim)", () => {
for (const reason of [
"hedge-cancelled",
"combo-per-model-timeout",
"request_signal_aborted",
"client_closed",
"cancelled",
]) {
assert.equal(isClientAbortError(reason), true, reason + " must be absorbed");
assert.equal(shouldSwallowUncaught(reason, "unhandledRejection"), true);
}
assert.equal(isClientAbortError("genuine failure"), false);
assert.equal(isClientAbortError(""), false);
assert.equal(isClientAbortError(42), false);
assert.equal(isClientAbortError(null), false);
});
// A swallowed error is the ONLY evidence it ever happened; logging just
// code/message throws away the stack. The logger must receive the full
// error object so the origin stays diagnosable.
test("installProcessCrashGuard logs the full error object for swallowed errors", async () => {
// Same constraint as runGuardChild: the child must receive a file:// URL,
// not a filesystem path (bare "C:\..." breaks import() on Windows).
const guardUrl = new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url).href;
const script = `
const { installProcessCrashGuard } = await import(process.argv[1]);
installProcessCrashGuard((level, ...args) => {
console.log(
"LOGARGS",
level,
args.map((a) => (a instanceof Error ? "Error" : typeof a)).join(",")
);
});
process.emit(
"unhandledRejection",
Object.assign(new Error("hedge-cancelled"), { name: "AbortError" }),
Promise.resolve()
);
`;
const { status, stdout } = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--input-type=module", "-e", script, guardUrl], {
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", (status) => resolve({ status, stdout: out }));
child.on("error", reject);
});
assert.equal(status, 0);
assert.match(stdout, /LOGARGS warn string,Error/);
});