From 668beed5b8385c0cd05a0785a5fba87faa027bae Mon Sep 17 00:00:00 2001 From: "Alvin T. Veroy" Date: Tue, 1 Sep 2026 01:10:33 +0800 Subject: [PATCH] fix(sse): absorb AbortError/request_signal_aborted in the client-abort crash guard (#12165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...ort-guard-absorb-request-signal-aborted.md | 1 + src/shared/utils/httpClientAbortGuard.mjs | 7 ++ tests/unit/httpClientAbortGuard.test.mjs | 87 ++++++++++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/abort-guard-absorb-request-signal-aborted.md diff --git a/changelog.d/fixes/abort-guard-absorb-request-signal-aborted.md b/changelog.d/fixes/abort-guard-absorb-request-signal-aborted.md new file mode 100644 index 0000000000..7b08f4fd55 --- /dev/null +++ b/changelog.d/fixes/abort-guard-absorb-request-signal-aborted.md @@ -0,0 +1 @@ +- Absorb `Error [AbortError]: request_signal_aborted` and DOMException AbortError shapes in the process-level client-abort crash guard so routine client disconnects no longer kill the server (exit code 7). diff --git a/src/shared/utils/httpClientAbortGuard.mjs b/src/shared/utils/httpClientAbortGuard.mjs index bc8f9fb546..41d5b07cb5 100644 --- a/src/shared/utils/httpClientAbortGuard.mjs +++ b/src/shared/utils/httpClientAbortGuard.mjs @@ -42,6 +42,13 @@ export function isClientAbortError(err) { 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; + // OmniRoute's SSE teardown aborts in-flight legs with + // `Error [AbortError]: request_signal_aborted` on client disconnects + // (open-sse/utils/streamHandler.ts), and fetch/DOM cancellation surfaces as + // `AbortError` with an abort-flavoured message. Same benign class as + // `Error: aborted` — an emitter-left 'error' event on any of these used to + // kill the process (#fix-dev-server-aborted). + if (e.name === "AbortError" && /abort/i.test(String(e.message))) return true; switch (e.code) { case "ERR_STREAM_PREMATURE_CLOSE": case "ECONNRESET": diff --git a/tests/unit/httpClientAbortGuard.test.mjs b/tests/unit/httpClientAbortGuard.test.mjs index a7044207b0..293e8ae0ca 100644 --- a/tests/unit/httpClientAbortGuard.test.mjs +++ b/tests/unit/httpClientAbortGuard.test.mjs @@ -3,6 +3,8 @@ 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, @@ -115,5 +117,88 @@ test("shouldSwallowUncaught preserves crash semantics for genuine errors", () => 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); +}); + +// 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/); });