Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/bun-types/overrides.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 55 additions & 9 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,21 @@
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))
Expand Down Expand Up @@ -1201,6 +1216,17 @@

extern "C" void Bun__logUnhandledException(JSC::EncodedJSValue exception);

// An exception thrown from an exception-capture callback cannot be handled; log it and exit.
static void abortOnCaptureCallbackException(JSC::JSGlobalObject* globalObject, JSC::TopExceptionScope& scope)
{
auto ex = scope.exception();
if (!ex)
return;
(void)scope.tryClearException();
Bun__logUnhandledException(JSValue::encode(JSValue(ex)));
Bun__Process__exit(globalObject, 1);
}

extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue exception, int isRejection)
{
if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info()))
Expand Down Expand Up @@ -1230,19 +1256,37 @@
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);
abortOnCaptureCallbackException(lexicalGlobalObject, scope);
if (handled.isTrue()) {
return true;
}
}

Check failure on line 1280 in src/jsc/bindings/BunProcess.cpp

View check run for this annotation

Claude / Claude Code Review

Auxiliary callback loop continues after Bun__Process__exit returns in workers

In a Worker thread, `Bun__Process__exit` is not noreturn — it requests termination and returns (per the comment at line 3357). So when an auxiliary callback throws, `abortOnCaptureCallbackException()` returns normally, `handled` is the empty `JSValue`, and the loop keeps iterating: each remaining `call()` raises the TerminationException, which gets logged and re-triggers `Bun__Process__exit`, then control falls through and returns `false`. The primary-callback path at lines 1258–1260 already doe
Comment thread
robobun marked this conversation as resolved.
}
} 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)
{
Expand Down Expand Up @@ -3324,6 +3368,7 @@
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);
Expand Down Expand Up @@ -4369,6 +4414,7 @@
_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
Expand Down
9 changes: 9 additions & 0 deletions src/jsc/bindings/BunProcess.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "BunBuiltinNames.h"
#include "BunClientData.h"
#include "JSEventEmitter.h"
#include "WriteBarrierList.h"

namespace Zig {
class GlobalObject;
Expand All @@ -27,6 +28,9 @@ class Process : public WebCore::JSEventEmitter {
// Only used by internal code via passing to queueNextTick
LazyProperty<Process, JSFunction> m_emitHelperFunction;
WriteBarrier<Unknown> m_uncaughtExceptionCaptureCallback;
// process.addUncaughtExceptionCaptureCallback registrations. These coexist with
// m_uncaughtExceptionCaptureCallback and are only consulted when it is unset.
WriteBarrierList<JSObject> m_uncaughtExceptionAuxiliaryCallbacks;
WriteBarrier<JSObject> m_nextTickFunction;
// https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/internal/bootstrap/switches/does_own_process_state.js#L113-L116
WriteBarrier<JSString> m_cachedCwd;
Expand Down Expand Up @@ -120,6 +124,11 @@ class Process : public WebCore::JSEventEmitter {
return m_uncaughtExceptionCaptureCallback.get();
}

inline WriteBarrierList<JSObject>& 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); }
Expand Down
5 changes: 5 additions & 0 deletions test/integration/bun-types/fixture/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
146 changes: 146 additions & 0 deletions test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,152 @@ 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);
});
});

it("process.execArgv", async () => {
const fixtures = [
["index.ts --bun -a -b -c", [], ["--bun", "-a", "-b", "-c"]],
Expand Down
Loading