Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
69 changes: 60 additions & 9 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -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;
}
}
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 +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);
Expand Down Expand Up @@ -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
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
199 changes: 199 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,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"]],
Expand Down
Loading