diff --git a/packages/bun-types/overrides.d.ts b/packages/bun-types/overrides.d.ts index 06647269025c..40f146065afa 100644 --- a/packages/bun-types/overrides.d.ts +++ b/packages/bun-types/overrides.d.ts @@ -94,6 +94,19 @@ declare global { _exiting: boolean; noDeprecation?: boolean | undefined; + /** + * Adds a callback that is invoked when an uncaught exception occurs, + * receiving the exception as its first argument. + * + * Unlike {@link setUncaughtExceptionCaptureCallback}, multiple callbacks + * can be registered and they do not conflict with the `domain` module. + * Callbacks run in reverse order of registration (most recent first). + * If a callback returns `true`, the remaining callbacks and the default + * `'uncaughtException'` handling are skipped. + * @since Node.js v25.9.0 + */ + addUncaughtExceptionCaptureCallback(fn: (err: Error) => boolean | void): void; + /** * Emitted when the operating system signals that available memory is * running low. Use this to release caches or reap idle resources instead diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 956f5364844d..b1072cbcc54a 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -920,6 +920,21 @@ JSC_DEFINE_HOST_FUNCTION(Process_hasUncaughtExceptionCaptureCallback, (JSC::JSGl return JSValue::encode(jsBoolean(true)); } +JSC_DEFINE_HOST_FUNCTION(Process_addUncaughtExceptionCaptureCallback, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + auto arg0 = callFrame->argument(0); + V::validateFunction(throwScope, globalObject, arg0, "fn"_s); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto* process = globalObject->processObject(); + process->uncaughtExceptionAuxiliaryCallbacks().append(vm, process, arg0.getObject()); + return JSC::JSValue::encode(jsUndefined()); +} + extern "C" uint64_t Bun__readOriginTimer(void*); JSC_DEFINE_HOST_FUNCTION(Process_functionHRTime, (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame)) @@ -1201,6 +1216,20 @@ void signalHandler(uv_signal_t* signal, int signalNumber) extern "C" void Bun__logUnhandledException(JSC::EncodedJSValue exception); +// An exception thrown from an exception-capture callback cannot be handled; log it and exit. +// Returns true if an exception was present. In a Worker, Bun__Process__exit returns after +// requesting termination, so callers must bail out instead of re-entering the terminating VM. +static bool abortOnCaptureCallbackException(JSC::JSGlobalObject* globalObject, JSC::TopExceptionScope& scope) +{ + auto ex = scope.exception(); + if (!ex) + return false; + (void)scope.tryClearException(); + Bun__logUnhandledException(JSValue::encode(JSValue(ex))); + Bun__Process__exit(globalObject, 1); + return true; +} + extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue exception, int isRejection) { if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info())) @@ -1230,19 +1259,39 @@ extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalOb if (!capture.isEmpty() && !capture.isUndefinedOrNull()) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); (void)call(lexicalGlobalObject, capture, args, "uncaughtExceptionCaptureCallback"_s); - if (auto ex = scope.exception()) { - (void)scope.tryClearException(); - // if an exception is thrown in the uncaughtException handler, we abort - Bun__logUnhandledException(JSValue::encode(JSValue(ex))); - Bun__Process__exit(lexicalGlobalObject, 1); + abortOnCaptureCallbackException(lexicalGlobalObject, scope); + return true; + } + + // Auxiliary callbacks from process.addUncaughtExceptionCaptureCallback run + // most-recent-first; returning exactly `true` marks the exception as handled. + auto& auxiliary = process->uncaughtExceptionAuxiliaryCallbacks(); + if (!auxiliary.isEmpty()) { + // Snapshot: a callback may register another one, reallocating the backing list. + MarkedArgumentBuffer callbacks; + for (auto& callback : auxiliary.list()) + callbacks.append(callback.get()); + + if (!callbacks.hasOverflowed()) [[likely]] { + for (size_t i = callbacks.size(); i-- > 0;) { + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSValue handled = call(lexicalGlobalObject, callbacks.at(i), args, "uncaughtExceptionCaptureCallback"_s); + if (abortOnCaptureCallbackException(lexicalGlobalObject, scope)) { + return true; + } + if (handled.isTrue()) { + return true; + } + } } - } else if (wrapped.listenerCount(uncaughtExceptionIdent) > 0) { + } + + if (wrapped.listenerCount(uncaughtExceptionIdent) > 0) { wrapped.emit(uncaughtExceptionIdent, args); - } else { - return false; + return true; } - return true; + return false; } extern "C" bool Bun__promises__isErrorLike(JSC::JSGlobalObject* globalObject, JSC::JSValue obj) { @@ -3324,6 +3373,7 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); visitor.append(thisObject->m_uncaughtExceptionCaptureCallback); + thisObject->m_uncaughtExceptionAuxiliaryCallbacks.visit(thisObject, visitor); visitor.append(thisObject->m_nextTickFunction); visitor.append(thisObject->m_cachedCwd); visitor.append(thisObject->m_argv); @@ -4369,6 +4419,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu _stopProfilerIdleNotifier Process_stubEmptyFunction Function 0 _tickCallback Process_stubEmptyFunction Function 0 abort Process_functionAbort Function 1 + addUncaughtExceptionCaptureCallback Process_addUncaughtExceptionCaptureCallback Function 1 allowedNodeEnvironmentFlags Process_stubEmptySet PropertyCallback arch constructArch PropertyCallback argv processArgv CustomAccessor diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 02b049ebf1e5..58fe44aae4f3 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -5,6 +5,7 @@ #include "BunBuiltinNames.h" #include "BunClientData.h" #include "JSEventEmitter.h" +#include "WriteBarrierList.h" namespace Zig { class GlobalObject; @@ -27,6 +28,9 @@ class Process : public WebCore::JSEventEmitter { // Only used by internal code via passing to queueNextTick LazyProperty m_emitHelperFunction; WriteBarrier m_uncaughtExceptionCaptureCallback; + // process.addUncaughtExceptionCaptureCallback registrations. These coexist with + // m_uncaughtExceptionCaptureCallback and are only consulted when it is unset. + WriteBarrierList m_uncaughtExceptionAuxiliaryCallbacks; WriteBarrier m_nextTickFunction; // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/internal/bootstrap/switches/does_own_process_state.js#L113-L116 WriteBarrier m_cachedCwd; @@ -120,6 +124,11 @@ class Process : public WebCore::JSEventEmitter { return m_uncaughtExceptionCaptureCallback.get(); } + inline WriteBarrierList& uncaughtExceptionAuxiliaryCallbacks() + { + return m_uncaughtExceptionAuxiliaryCallbacks; + } + inline Structure* cpuUsageStructure() { return m_cpuUsageStructure.getInitializedOnMainThread(this); } inline Structure* resourceUsageStructure() { return m_resourceUsageStructure.getInitializedOnMainThread(this); } inline Structure* memoryUsageStructure() { return m_memoryUsageStructure.getInitializedOnMainThread(this); } diff --git a/test/integration/bun-types/fixture/process.ts b/test/integration/bun-types/fixture/process.ts index 6ea7cf043d90..da892568c1fe 100644 --- a/test/integration/bun-types/fixture/process.ts +++ b/test/integration/bun-types/fixture/process.ts @@ -24,6 +24,11 @@ process.once("SIGINT", () => { console.log("Interrupt from keyboard"); }); +process.addUncaughtExceptionCaptureCallback(err => { + console.log(err.message); + return true; +}); + // commented methods are not yet implemented console.log(process.allowedNodeEnvironmentFlags); // console.log(process.channel); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 00c694455113..eeab632e94d2 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -887,6 +887,205 @@ it("process.hasUncaughtExceptionCaptureCallback", () => { process.setUncaughtExceptionCaptureCallback(null); }); +// Callbacks registered with addUncaughtExceptionCaptureCallback cannot be removed, so +// every case runs in its own subprocess. +describe.concurrent("process.addUncaughtExceptionCaptureCallback", () => { + async function run(src) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + it("validates its argument and does not affect set/has", async () => { + const { stdout, stderr, exitCode } = await run(` + const out = [typeof process.addUncaughtExceptionCaptureCallback]; + try { + process.addUncaughtExceptionCaptureCallback(42); + out.push("no-throw"); + } catch (e) { + out.push(e.code + "|" + e.message); + } + process.addUncaughtExceptionCaptureCallback(() => {}); + // auxiliary callbacks are invisible to hasUncaughtExceptionCaptureCallback... + out.push(process.hasUncaughtExceptionCaptureCallback()); + // ...and do not conflict with setUncaughtExceptionCaptureCallback + process.setUncaughtExceptionCaptureCallback(() => {}); + out.push(process.hasUncaughtExceptionCaptureCallback()); + process.setUncaughtExceptionCaptureCallback(null); + out.push(process.hasUncaughtExceptionCaptureCallback()); + console.log(JSON.stringify(out)); + `); + expect({ out: JSON.parse(stdout), exitCode }, stderr).toEqual({ + out: [ + "function", + 'ERR_INVALID_ARG_TYPE|The "fn" argument must be of type function. Received type number (42)', + false, + true, + false, + ], + exitCode: 0, + }); + }); + + it("dispatches most-recent-first, short-circuits on `=== true`, and yields to the primary callback", async () => { + const { stdout, stderr, exitCode } = await run(` + const order = []; + process.on("uncaughtExceptionMonitor", (err, origin) => order.push("monitor:" + err.message + ":" + origin)); + process.on("uncaughtException", err => order.push("listener:" + err.message)); + + // registered first -> runs last + process.addUncaughtExceptionCaptureCallback(err => { + order.push("aux1:" + err.message); + if (err.message === "stop-at-1") return true; + }); + // registered second -> runs first + process.addUncaughtExceptionCaptureCallback(err => { + order.push("aux2:" + err.message); + if (err.message === "stop-at-2") return true; + if (err.message === "truthy") return 1; // truthy but not \`=== true\`, must not stop + }); + + const steps = [ + () => { throw new Error("stop-at-2"); }, + () => { throw new Error("stop-at-1"); }, + () => { throw new Error("truthy"); }, + () => { throw new Error("fallthrough"); }, + // once a primary callback is set, auxiliary callbacks are skipped entirely + () => process.setUncaughtExceptionCaptureCallback(err => order.push("primary:" + err.message)), + () => { throw new Error("primary-wins"); }, + () => console.log(JSON.stringify(order)), + ]; + (function next() { + const step = steps.shift(); + if (!step) return; + setImmediate(() => { setImmediate(next); step(); }); + })(); + `); + expect({ order: JSON.parse(stdout), exitCode }, stderr).toEqual({ + order: [ + "monitor:stop-at-2:uncaughtException", + "aux2:stop-at-2", + "monitor:stop-at-1:uncaughtException", + "aux2:stop-at-1", + "aux1:stop-at-1", + "monitor:truthy:uncaughtException", + "aux2:truthy", + "aux1:truthy", + "listener:truthy", + "monitor:fallthrough:uncaughtException", + "aux2:fallthrough", + "aux1:fallthrough", + "listener:fallthrough", + "monitor:primary-wins:uncaughtException", + "primary:primary-wins", + ], + exitCode: 0, + }); + }); + + it("a handled exception keeps the process alive with no uncaughtException listener", async () => { + const { stdout, stderr, exitCode } = await run(` + const seen = []; + process.addUncaughtExceptionCaptureCallback(err => { + seen.push(err.message); + return true; + }); + // survives GC: the list is traced from the process object + for (let i = 0; i < 10; i++) Bun.gc(true); + setImmediate(() => { + setImmediate(() => { + console.log(JSON.stringify(seen)); + process.exit(42); + }); + throw new Error("boom"); + }); + `); + expect({ seen: JSON.parse(stdout), exitCode }, stderr).toEqual({ seen: ["boom"], exitCode: 42 }); + }); + + it("an unhandled exception is still fatal after the callbacks run", async () => { + const { stdout, stderr, exitCode } = await run(` + process.addUncaughtExceptionCaptureCallback(err => console.log("aux:" + err.message)); + throw new Error("boom"); + `); + expect({ stdout, fatal: stderr.includes("boom") }).toEqual({ stdout: "aux:boom", fatal: true }); + expect(exitCode).toBe(1); + }); + + it("a callback that throws aborts the process, like the primary callback", async () => { + // The inner message is built at runtime so it can't be satisfied by bun + // echoing the script source back in an unrelated error. + const { stdout, stderr, exitCode } = await run(` + process.addUncaughtExceptionCaptureCallback(err => { + console.log("caught:" + err.message); + throw new Error("inner-" + err.message); + }); + throw new Error("outer"); + `); + expect(stdout).toBe("caught:outer"); + expect(stderr).toContain("inner-outer"); + expect(exitCode).toBe(1); + }); + + // In a Worker, Bun__Process__exit returns after requesting termination instead of + // exiting the process, so this also pins that the dispatch loop bails out after a + // throwing callback instead of re-entering the terminating VM, which fired a + // spurious null 'error' event on the parent Worker handle. + it("a callback that throws in a Worker terminates the worker like the primary callback", async () => { + // Error messages are concatenated so the contiguous strings never appear in + // the script source that bun echoes back into error reports. + const thrower = `err => { + console.log("thrower-ran:" + err.message); + throw new Error("inner" + "-oops"); + }`; + using dir = tempDir("add-capture-worker", { + "parent.js": ` + const { Worker } = require("node:worker_threads"); + const { join } = require("node:path"); + const w = new Worker(join(__dirname, process.argv[2])); + w.on("error", e => console.log("parent-error:" + (e && e.message))); + w.on("exit", code => console.log("parent-exit:" + code)); + `, + "set-child.js": ` + process.setUncaughtExceptionCaptureCallback(${thrower}); + throw new Error("original" + "-oops"); + `, + "add-child.js": ` + // registered first -> would run last, but the throw above it stops the chain + process.addUncaughtExceptionCaptureCallback(() => console.log("older-ran")); + process.addUncaughtExceptionCaptureCallback(${thrower}); + throw new Error("original" + "-oops"); + `, + }); + + async function runVariant(child) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js", child], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The worker and the parent interleave on the shared stdout, so compare as a set. + return { lines: stdout.trim().split("\n").sort(), innerReported: stderr.includes("inner-oops"), exitCode }; + } + + const [set, add] = await Promise.all([runVariant("set-child.js"), runVariant("add-child.js")]); + expect(set).toEqual({ + lines: ["parent-exit:1", "thrower-ran:original-oops"], + innerReported: true, + exitCode: 0, + }); + expect(add).toEqual(set); + }); +}); + it("process.execArgv", async () => { const fixtures = [ ["index.ts --bun -a -b -c", [], ["--bun", "-a", "-b", "-c"]],