Files
OmniRoute/tests/unit/httpClientAbortGuard.test.mjs
Beexly 706dc75c13 fix(chatCore): stop executeWithUpstreamStartTimeout leaking its abortPromise listener (hedge-cancelled process exit) (#12406)
* fix(sse): stop mergeAbortSignals from leaking abort listeners

mergeAbortSignals() attached "abort" listeners to its primary/secondary
signals but never removed them once the merged signal settled. Every
executor fetch attempt calls this (fetchWithStartTimeout, once per
URL/retry), so a busy combo request accumulated one live listener per
call on the long-lived combo/client signal. A leaked listener still
fires when that signal is later aborted (e.g. a hedge cancellation
arriving after this merge's own caller already finished), for a merged
output nothing is watching anymore.

Mirrors the already-correct self-cleaning pattern in
open-sse/utils/directResponseStartTimeout.ts's local mergeAbortSignals.

Regression test measures listener growth across repeated merges of the
same long-lived signal: 25 merges leaked exactly 25 listeners pre-fix,
0 post-fix.

(cherry picked from commit 07969655147d3236969b38bfb41280ab4fb52b79)

* fix(server): stop the crash guard re-throwing combo abort reasons

Production crash 2026-08-31 (omniroute.log): on a client disconnect,
handleDisconnect aborted the combo controller and a late abort listener
threw the abort reason on an empty stack:

    Error [AbortError]: hedge-cancelled
        at ... AbortController.abort ... handleDisconnect
    file:///.../src/shared/utils/httpClientAbortGuard.mjs:130  throw err;

isClientAbortError() only knew Node's stream codes and "aborted", so
shouldSwallowUncaught() said false and the guard re-threw, taking the
whole server down.

- Port upstream's AbortError line (name "AbortError" + abort-flavoured
  message) so request_signal_aborted / DOMException aborts are absorbed.
- Add an exact-message match for the combo abort reasons from
  open-sse/services/combo/comboAbortReasons.ts ("hedge-cancelled",
  "combo-per-model-timeout"), name-agnostic because the raw reason is a
  plain Error that only gets name="AbortError" stamped on the way out.
  A losing hedge / stalled target is never a server fault. Inlined so
  this .mjs stays dependency-free for scripts/dev/run-next.mjs.

Tests: port upstream's guard tests, add the exact crash shape, a
child-process replay of the crash (dies pre-fix, survives post-fix), a
genuine-error case that must still crash, and a sync check against
comboAbortReasons.ts. The child-process helper passes a file:// URL, not
a bare path, so the tests run on Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 90c9bce8c474b60c37cc63f4421d50feae4c0ad2)

* fix(chatCore): stop executeWithUpstreamStartTimeout leaking its abortPromise listener

Root cause of the 2026-08-31 production exit (Error [AbortError]:
hedge-cancelled), verified by mapping the crash frames in
.build/next/server/chunks/13721.js back to this file:

- The abortPromise abort listener registered on the long-lived client /
  stream signal was never removed in the finally block (only abortListener
  and timeoutAbortListener were), so every executor attempt (and every
  retry) leaked one listener onto that signal.
- Promise.race only subscribes to abortPromise/timeoutPromise once the
  array literal has been evaluated. When execute() threw synchronously the
  race never ran, abortPromise was orphaned, and the next hedge
  cancellation / client disconnect aborted the signal with the string
  reason streamHandler.ts forwards; createAbortError() rebuilt it as an
  AbortError-named Error and rejected a promise nothing awaited. That
  unhandledRejection reached the process crash guard, which re-threw it as
  an uncaughtException and exited with code 7.

Keep a handle to the listener and remove it with the others, and mark the
two race-loser promises as handled so a synchronous throw from execute()
can never orphan them. Race semantics are unchanged (the race still
observes their rejections).

Regression tests: (1) a resolving execute leaves the listener count on
the client signal unchanged; (2) a synchronously throwing execute leaks
no listener and a later abort with the string "hedge-cancelled" produces
no unhandledRejection. Both fail against the previous implementation.

Note: commit 079696551 (mergeAbortSignals cleanup) is correct listener
hygiene but is not on this crash path; this is the fix for the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e68a50ad854a1945c23e747da4ef15a820cc148d)

* fix(server): absorb raw string abort reasons in the crash guard; document the verified crash path

Follow-ups from the adversarial review of 90c9bce8c:

- open-sse/utils/streamHandler.ts aborts the stream controller with a raw
  string reason (getClientAbortReason / handleDisconnect) and undici
  rejects with signal.reason verbatim, so a cancellation can reach
  process level as a bare string. isClientAbortError() returned false for
  every non-object, which would still have exited the process. Absorb the
  combo abort reasons and the stream-handler disconnect reasons when they
  arrive as strings.
- Correct the mechanism comment: the 2026-08-31 exit was a leaked
  upstreamTimeouts.ts abortPromise listener rejecting a promise nothing
  awaited (unhandledRejection), escalated by this guard, not a listener
  throwing synchronously. The leak is fixed at the source in the previous
  commit; this guard remains the last-resort net.
- Reword the inlining rationale (plain node launcher, no reliance on
  type-stripping for the .ts constants module).
- Tests: the child-process replay now also exercises the
  unhandledRejection route with the exact production error shape and with
  raw string reasons; add unit coverage for string reasons and non-object
  look-alikes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 696fcc8fe1b9b9bc43e6e5f5f5e44b619a86e68a)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Beexly <Beexly@users.noreply.github.com>
2026-09-18 13:13:06 -03:00

328 lines
14 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(() => {}));
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 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 () => {
const guardPath = fileURLToPath(
new URL("../../src/shared/utils/httpClientAbortGuard.mjs", import.meta.url)
);
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, guardPath], {
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/);
});