Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
7672d2cf38 fix: exit process on unhandled exceptions in process.nextTick callbacks
Previously, exceptions thrown inside process.nextTick callbacks (including
EventEmitter "error" handlers scheduled via nextTick) were caught and
reported but did not terminate the process. This caused execution to
continue after the throw, the error handler to fire multiple times, and
the process to exit with code 0 instead of 1.

The fix has two parts:

1. In processTicksAndRejections, if reportUncaughtException indicates the
   error was not handled (no uncaughtException listener), rethrow it so
   the exception propagates to the C++ event loop layer.

2. In VirtualMachine.uncaughtException, for actual uncaught exceptions
   (not promise rejections) with no handler, immediately exit the process
   with code 1 instead of just setting the exit code and continuing.

This matches Node.js behavior where unhandled exceptions in nextTick
callbacks crash the process immediately.

Closes #17382

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 03:04:02 +00:00
6 changed files with 129 additions and 7 deletions

View File

@@ -673,7 +673,15 @@ pub fn uncaughtException(this: *jsc.VirtualMachine, globalObject: *JSGlobalObjec
defer this.is_handling_uncaught_exception = false;
const handled = Bun__handleUncaughtException(globalObject, err.toError() orelse err, if (is_rejection) 1 else 0) > 0;
if (!handled) {
// TODO maybe we want a separate code path for uncaught exceptions
if (!is_rejection) {
// For actual uncaught exceptions (not promise rejections), exit the
// process immediately if there is no uncaughtException handler,
// matching Node.js behavior.
this.runErrorHandler(err, null);
bun.api.node.process.exit(globalObject, 1);
@panic("made it past Bun__Process__exit");
}
// For unhandled promise rejections, just set exit code and continue.
this.unhandled_error_counter += 1;
this.exit_handler.exit_code = 1;
this.onUnhandledRejection(this, globalObject, err);

View File

@@ -3497,8 +3497,7 @@ static JSValue constructMemoryUsage(VM& vm, JSObject* processObject)
JSC_DEFINE_HOST_FUNCTION(jsFunctionReportUncaughtException, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
JSValue arg0 = callFrame->argument(0);
Bun__reportUnhandledError(globalObject, JSValue::encode(arg0));
return JSValue::encode(jsUndefined());
return Bun__reportUnhandledError(globalObject, JSValue::encode(arg0));
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionDrainMicrotaskQueue, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))

View File

@@ -79,7 +79,7 @@ class EventTarget;
}
extern "C" void Bun__reportError(JSC::JSGlobalObject*, JSC::EncodedJSValue);
extern "C" void Bun__reportUnhandledError(JSC::JSGlobalObject*, JSC::EncodedJSValue);
extern "C" JSC::EncodedJSValue Bun__reportUnhandledError(JSC::JSGlobalObject*, JSC::EncodedJSValue);
extern "C" bool Bun__VirtualMachine__isShuttingDown(void* /* BunVM */);

View File

@@ -86,9 +86,10 @@ pub export fn Bun__reportUnhandledError(globalObject: *JSGlobalObject, value: JS
jsc.markBinding(@src());
if (!value.isTerminationException()) {
_ = globalObject.bunVM().uncaughtException(globalObject, value, false);
const handled = globalObject.bunVM().uncaughtException(globalObject, value, false);
return if (handled) .true else .false;
}
return .js_undefined;
return .true;
}
/// This function is called on another thread

View File

@@ -324,7 +324,9 @@ export function initializeNextTickQueue(
}
}
} catch (e) {
reportUncaughtException(e);
if (!reportUncaughtException(e)) {
throw e;
}
} finally {
$putInternalField($asyncContext, 0, restore);
}

View File

@@ -0,0 +1,112 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/17382
// Exceptions thrown inside EventEmitter "error" handlers scheduled via
// process.nextTick should propagate as uncaught exceptions and stop execution
// (matching Node.js behavior).
test("exception thrown in stream error handler via nextTick stops execution", async () => {
// This reproduces the original issue: a TCP socket fails to connect,
// the stream's error handler throws, but execution continues and the
// error handler fires twice.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const net = require("net");
const socket = new net.Socket();
let errorCount = 0;
socket.on("error", (err) => {
errorCount++;
console.log("ERROR_COUNT:" + errorCount);
throw new Error("re-thrown: " + err.message);
});
try {
socket.connect(14582, "localhost");
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("UNREACHABLE_END");
} catch(e) {
console.error("CAUGHT:" + e.message);
process.exit(1);
}
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Error handler should fire exactly once
expect(stdout).toContain("ERROR_COUNT:1");
expect(stdout).not.toContain("ERROR_COUNT:2");
// Code should not continue after the throw
expect(stdout).not.toContain("UNREACHABLE_END");
expect(stderr).toContain("re-thrown:");
expect(exitCode).not.toBe(0);
});
test("exception in nextTick callback stops the tick loop", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
process.nextTick(() => {
throw new Error("first tick error");
});
process.nextTick(() => {
console.log("SECOND_TICK");
});
setTimeout(() => {
console.log("UNREACHABLE_TIMEOUT");
}, 100);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("first tick error");
// Second nextTick should NOT run after the first one throws
expect(stdout).not.toContain("SECOND_TICK");
expect(stdout).not.toContain("UNREACHABLE_TIMEOUT");
expect(exitCode).not.toBe(0);
});
test("process.on('uncaughtException') handles nextTick errors", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
process.on("uncaughtException", (err) => {
console.log("CAUGHT:" + err.message);
process.exit(42);
});
process.nextTick(() => {
throw new Error("should be caught");
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("CAUGHT:should be caught");
expect(exitCode).toBe(42);
});