From 55e33f3dc8aee21a2bb4c37d4f3d063ffa6ed264 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Sat, 29 Aug 2026 18:51:56 -0400 Subject: [PATCH] fix(sse): default crash-guard logger to console.warn, not console (#12042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real production incident (2026-08-29): the crash guard #11556 introduced defaulted its logger to `log ?? console` — console is an object, not a function, so a burst of client aborts (ECONNRESET) reaching the process-level guard threw TypeError inside the uncaughtException handler itself and killed the server, twice in three minutes. Fix: default to console.warn.bind(console). Bug-injection round trip confirms the new test fails on the old default and passes on the fix. Existing guard suite stays green: 9/9 (verified together with the new test). --- src/shared/utils/httpClientAbortGuard.mjs | 5 ++- ...tpClientAbortGuard-default-logger.test.mjs | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/unit/httpClientAbortGuard-default-logger.test.mjs diff --git a/src/shared/utils/httpClientAbortGuard.mjs b/src/shared/utils/httpClientAbortGuard.mjs index ff2fb43ced..bc8f9fb546 100644 --- a/src/shared/utils/httpClientAbortGuard.mjs +++ b/src/shared/utils/httpClientAbortGuard.mjs @@ -117,7 +117,10 @@ export function installProcessCrashGuard(log) { if (crashGuardInstalled) return; crashGuardInstalled = true; - const logger = log ?? console; + // `console` is an object, not a callable: `log ?? console` followed by + // `logger("warn", ...)` throws TypeError and kills the process on the very + // abort the guard exists to swallow. Default to console.warn as a function. + const logger = typeof log === "function" ? log : console.warn.bind(console); process.on("uncaughtException", (err, origin) => { if (shouldSwallowUncaught(err, origin)) { diff --git a/tests/unit/httpClientAbortGuard-default-logger.test.mjs b/tests/unit/httpClientAbortGuard-default-logger.test.mjs new file mode 100644 index 0000000000..8154e4d001 --- /dev/null +++ b/tests/unit/httpClientAbortGuard-default-logger.test.mjs @@ -0,0 +1,33 @@ +"use strict"; + +import assert from "node:assert"; +import { test } from "node:test"; + +// Production calls installProcessCrashGuard() with NO argument in +// apiBridgeServer.ts / liveServer.ts / embedWsProxy.ts. The default logger +// must be callable; this file runs in its own node:test process, so the +// module-level installed flag starts unset and the no-arg path is exercised +// for real. Emitting "uncaughtException" through process.emit would trip the +// test runner's own listener, so the guard handler is invoked directly. +import { installProcessCrashGuard } from "../../src/shared/utils/httpClientAbortGuard.mjs"; + +test("installProcessCrashGuard() with no argument swallows a client abort without throwing", () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + installProcessCrashGuard(); + const handlers = process + .listeners("uncaughtException") + .filter((fn) => fn.toString().includes("swallowed client-abort")); + assert.ok(handlers.length > 0, "guard handler must be registered"); + const abortErr = Object.assign(new Error("aborted"), { code: "ECONNRESET" }); + // A broken default logger (console is an object, not a function) throws + // TypeError here — that is what took the production process down. + assert.doesNotThrow(() => handlers[0](abortErr, "uncaughtException")); + assert.equal(warnings.length, 1, "the swallowed abort must be logged once"); + assert.ok(String(warnings[0][1]).includes("swallowed client-abort")); + } finally { + console.warn = originalWarn; + } +});