diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index eda454878846..f7b3374737d9 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -232,17 +232,27 @@ export default function ( if (debugUrl) { const { protocol, href, host, pathname } = debugUrl; if (!protocol.includes("unix")) { - Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n"); - Bun.write(Bun.stderr, `Listening:\n ${dim(href)}\n`); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); + Bun.write(Bun.stderr, `Listening:\n ${dim(href)}\n`).catch(kIgnoreWriteError); if (protocol.includes("ws")) { - Bun.write(Bun.stderr, `Inspect in browser:\n ${link(`https://debug.bun.sh/#${host}${pathname}`)}\n`); + Bun.write(Bun.stderr, `Inspect in browser:\n ${link(`https://debug.bun.sh/#${host}${pathname}`)}\n`).catch( + kIgnoreWriteError, + ); } - Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n"); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); } } else { - Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n"); - Bun.write(Bun.stderr, `Listening on ${dim(url)}\n`); - Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n"); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); + Bun.write(Bun.stderr, `Listening on ${dim(url)}\n`).catch(kIgnoreWriteError); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); } } @@ -882,6 +892,8 @@ function reset(): string { return ""; } +function kIgnoreWriteError(): void {} + function notify(options): void { Bun.connect({ ...options, diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index b61680929c69..51dd46e3ecc8 100644 --- a/src/js/internal/fs/watch.ts +++ b/src/js/internal/fs/watch.ts @@ -1,6 +1,7 @@ // fs.watch is lazily loaded so the FSWatcher class is only set up when it is used. const EventEmitter = require("node:events"); const { basename } = require("node:path"); +const { guardCallback } = require("internal/shared"); // The native `node:fs` binding, shared via `internal/fs/binding`. const fs = require("internal/fs/binding"); @@ -158,7 +159,7 @@ class FSWatcher extends EventEmitter { this.#ignoreMatcher = createIgnoreMatcher(options?.ignore); this.#listener = listener; try { - this.#watcher = fs.watch(path, options || {}, this.#onEvent.bind(this)); + this.#watcher = fs.watch(path, options || {}, guardCallback(this.#onEvent.bind(this))); } catch (e: any) { e.path = path; e.filename = path; diff --git a/src/js/internal/fs/watchfile.ts b/src/js/internal/fs/watchfile.ts index d09944a6b653..9e77a0a1626f 100644 --- a/src/js/internal/fs/watchfile.ts +++ b/src/js/internal/fs/watchfile.ts @@ -2,6 +2,7 @@ // machinery is not set up until it is actually used. const EventEmitter = require("node:events"); const { getValidatedPath, throwIfNullBytesInFileName } = require("internal/validators"); +const { guardCallback } = require("internal/shared"); // The native `node:fs` binding, shared via `internal/fs/binding`. const fs = require("internal/fs/binding"); @@ -22,7 +23,7 @@ class StatWatcher extends EventEmitter { constructor(path, options) { super(); - this._handle = fs.watchFile(path, options, this.#onChange.bind(this)); + this._handle = fs.watchFile(path, options, guardCallback(this.#onChange.bind(this))); } #onChange(curr, prev) { diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 4e3d2ade03b7..1aaefbe68f00 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -130,7 +130,9 @@ class ReadableFromWeb extends Readable { try { callback(error); } catch (error) { - globalThis.reportError(error); + process.nextTick(() => { + throw error; + }); } } } diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index ed70f054cd77..77f01dd370f5 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1,7 +1,7 @@ // Hardcoded module "node:child_process" const EventEmitter = require("node:events"); const OsModule = require("node:os"); -const { kHandle } = require("internal/shared"); +const { kHandle, reportUncaughtException } = require("internal/shared"); const { validateBoolean, validateFunction, @@ -1505,7 +1505,11 @@ class ChildProcess extends EventEmitter { } #emitIpcMessage(message, _, handle) { - this.emit(isInternalIpcMessage(message) ? "internalMessage" : "message", message, handle); + try { + this.emit(isInternalIpcMessage(message) ? "internalMessage" : "message", message, handle); + } catch (err) { + reportUncaughtException(err); + } } #send(message, handle, options, callback) { diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 2324ae3ef9bc..8156092d8dbd 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -44,7 +44,13 @@ const { kStateSymbol, guessHandleType } = require("internal/dgram"); const kOwnerSymbol = Symbol("owner symbol"); const async_id_symbol = Symbol("async_id_symbol"); -const { throwNotImplemented, ErrnoException, ExceptionWithHostPort } = require("internal/shared"); +const { + throwNotImplemented, + ErrnoException, + ExceptionWithHostPort, + guardCallback, + reportUncaughtException, +} = require("internal/shared"); const { validateString, validateNumber, @@ -714,24 +720,30 @@ function startBunSocket(self, state, createOptions, sharedHandle?) { const udpOptions: any = { ...createOptions, socket: { + // Five args from native, past guardCallback's arity fast path, so the + // per-packet handler reroutes its throw inline. data: (_socket, data, port, address, flags) => { - // Per-packet, from the received sockaddr's family: bind({ fd }) can - // adopt a descriptor of the other family than `type`. - const family = flags?.ipv6 ? "IPv6" : "IPv4"; - if (state.receiveBlockList?.check(address, flags?.ipv6 ? "ipv6" : "ipv4")) { - return; + try { + // Per-packet, from the received sockaddr's family: bind({ fd }) can + // adopt a descriptor of the other family than `type`. + const family = flags?.ipv6 ? "IPv6" : "IPv4"; + if (state.receiveBlockList?.check(address, flags?.ipv6 ? "ipv6" : "ipv4")) { + return; + } + self.emit("message", data, { + port: port, + address: address, + size: data.length, + family, + }); + } catch (err) { + reportUncaughtException(err); } - self.emit("message", data, { - port: port, - address: address, - size: data.length, - family, - }); }, - drain: () => { + drain: guardCallback(() => { handleDrain.$call(state.handle); - }, - error: error => { + }), + error: guardCallback(error => { if (error?.syscall === "recv") { // Drop errqueue-origin ICMP errors on unconnected sockets like // Node (which never enables IP_RECVERR); always emit real @@ -747,7 +759,7 @@ function startBunSocket(self, state, createOptions, sharedHandle?) { return; } self.emit("error", error); - }, + }), }, }; // Private name: a cluster-shared descriptor is read one datagram at a time so workers share the load. diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 4c26ff1017fe..bb86c689330c 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -83,7 +83,9 @@ function wrapStoreRun(store, data, next, transform = defaultTransform) { try { context = transform(data); } catch (err) { - process.nextTick(() => reportError(err)); + process.nextTick(() => { + throw err; + }); return next(); } @@ -144,7 +146,9 @@ class ActiveChannel { const onMessage = this._subscribers[i]; onMessage(data, this.name); } catch (err) { - process.nextTick(() => reportError(err)); + process.nextTick(() => { + throw err; + }); } } } diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index 8d17bf0e0352..11cc4d7acf8c 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -234,19 +234,6 @@ function validateLocalAddresses(first, second) { } } -function invalidHostname(hostname) { - if (invalidHostname.warned) { - return; - } - - invalidHostname.warned = true; - process.emitWarning( - `The provided hostname "${String(hostname)}" is not a valid hostname, and is supported in the dns module solely for compatibility.`, - "DeprecationWarning", - "DEP0118", - ); -} - function translateLookupOptions(options) { if (!options || typeof options !== "object") { options = { family: options }; @@ -302,13 +289,7 @@ function lookup(hostname, options, callback) { validateLookupOptions(options); if (!hostname) { - invalidHostname(hostname); - if (options.all) { - callback(null, []); - } else { - callback(null, null, 4); - } - return; + throw $ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string"); } const family = isIP(hostname); @@ -751,15 +732,7 @@ const promises = { validateLookupOptions(options); if (!hostname) { - invalidHostname(hostname); - return Promise.$resolve( - options.all - ? [] - : { - address: null, - family: 4, - }, - ); + return Promise.$reject($ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string")); } const family = isIP(hostname); diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index f973f2297264..765bfd208e87 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -10,6 +10,7 @@ const { validateAbortSignal, validateEncoding, } = require("internal/validators"); +const { guardCallback } = require("internal/shared"); const constants = $processBindingConstants.fs; @@ -97,17 +98,21 @@ function watch( }; } - const watcher = fs.watch(filename, options || {}, (eventType: string, filename: string | Buffer | undefined) => { - if (eventType !== "close" && eventType !== "error" && filename != null && ignoreMatcher?.(filename)) { - return; - } - queue.push({ __proto__: null, eventType, filename }); - if (nextEventResolve) { - const resolve = nextEventResolve; - nextEventResolve = null; - resolve(); - } - }); + const watcher = fs.watch( + filename, + options || {}, + guardCallback((eventType: string, filename: string | Buffer | undefined) => { + if (eventType !== "close" && eventType !== "error" && filename != null && ignoreMatcher?.(filename)) { + return; + } + queue.push({ __proto__: null, eventType, filename }); + if (nextEventResolve) { + const resolve = nextEventResolve; + nextEventResolve = null; + resolve(); + } + }), + ); function onAbort() { watcher.close(); diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 1e9d83b4dda9..a7f49176c198 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -35,6 +35,7 @@ const { hasObserver, startPerf, stopPerf, + reportUncaughtException, } = require("internal/shared"); import type { Socket, SocketHandler, SocketListener } from "bun"; import type { Server as NetServer, Socket as NetSocket, ServerOpts } from "node:net"; @@ -299,9 +300,6 @@ function onClientHandshakeComplete(self, socket, verifyError) { self._secureEstablished = true; self[kVerifyError] = verifyError ?? null; self.alpnProtocol = socket.alpnProtocol; - // Node has no try/catch around these emits; a listener throw reaches - // InternalCallbackScope as uncaughtException. reportError mirrors that - // without changing Bun.connect's handshake-throw-to-error-handler contract. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1107 try { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1662-L1673 @@ -344,7 +342,7 @@ function onClientHandshakeComplete(self, socket, verifyError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1810 self.emit("secure", self); } catch (err) { - reportError(err); + reportUncaughtException(err); } } function onConnectEnd() { @@ -1012,7 +1010,7 @@ const ServerHandlers: SocketHandler = { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1810 if (!server) self.emit("secure", self); } catch (err) { - reportError(err); + reportUncaughtException(err); } }, error(socket, error) { @@ -1714,7 +1712,7 @@ function Socket(options?) { // The native data dispatch would otherwise route a throw to the // socket error handler; hand it to the uncaught-exception path // synchronously the way node's bare call does. - reportError(e); + reportUncaughtException(e); } if (self.destroyed) return; if (ret === false || self.isPaused()) { @@ -1744,7 +1742,7 @@ function Socket(options?) { } catch (e) { // Same as above: report then fall through so the next slice is // delivered, matching node's per-onStreamRead behavior. - reportError(e); + reportUncaughtException(e); } if (self.destroyed) return; if (ret === false || self.isPaused()) { diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 1a22a21c98cf..4dfbea0045d7 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -371,98 +371,62 @@ class BunWebSocket extends EventEmitter { } } - #onOrOnce(event, listener, once) { + #armAndOn(event, listener) { if (event === "redirect") { emitWarning(event, "ws.WebSocket '" + event + "' event is not implemented in bun"); } if (event === "upgrade" || event === "unexpected-response") { this.#ensureHandshakeListener(); - return once ? super.once(event, listener) : super.on(event, listener); + return super.on(event, listener); } const mask = 1 << eventIds[event]; - const hasPersistentListener = mask && (this.#eventId & mask) === mask; - // Add a native listener if: - // 1. For `on()`: no native listener exists yet (will be persistent) - // 2. For `once()`: no persistent `on()` listener exists (otherwise the persistent one forwards events) - // If only `once()` listeners exist, each needs its own native listener since they auto-remove - if (mask && !hasPersistentListener) { - // Only set the eventId bit for persistent `on` listeners, not for `once` - if (!once) { - this.#eventId |= mask; - } + if (mask && (this.#eventId & mask) !== mask) { + this.#eventId |= mask; if (event === "open") { - this.#ws.addEventListener( - "open", - () => { - this.emit("open"); - }, - once, - ); + this.#ws.addEventListener("open", () => { + this.emit("open"); + }); } else if (event === "close") { - this.#ws.addEventListener( - "close", - ({ code, reason, wasClean }) => { - this.emit("close", code, reason, wasClean); - }, - once, - ); + this.#ws.addEventListener("close", ({ code, reason, wasClean }) => { + this.emit("close", code, reason, wasClean); + }); } else if (event === "message") { - this.#ws.addEventListener( - "message", - ({ data }) => { - const isBinary = typeof data !== "string"; - if (isBinary) { - this.emit("message", this.#fragments ? [data] : data, isBinary); - } else { - let encoded = encoder.encode(data); - if (this.#binaryType !== "arraybuffer") { - encoded = Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength); - } - this.emit("message", this.#fragments ? [encoded] : encoded, isBinary); + this.#ws.addEventListener("message", ({ data }) => { + const isBinary = typeof data !== "string"; + if (isBinary) { + this.emit("message", this.#fragments ? [data] : data, isBinary); + } else { + let encoded = encoder.encode(data); + if (this.#binaryType !== "arraybuffer") { + encoded = Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength); } - }, - once, - ); + this.emit("message", this.#fragments ? [encoded] : encoded, isBinary); + } + }); } else if (event === "error") { - this.#ws.addEventListener( - "error", - err => { - if (this.#unexpectedResponseEmitted) return; - this.emit("error", err); - }, - once, - ); + this.#ws.addEventListener("error", err => { + if (this.#unexpectedResponseEmitted) return; + this.emit("error", err); + }); } else if (event === "ping") { - this.#ws.addEventListener( - "ping", - ({ data }) => { - this.emit("ping", data); - }, - once, - ); + this.#ws.addEventListener("ping", ({ data }) => { + this.emit("ping", data); + }); } else if (event === "pong") { - this.#ws.addEventListener( - "pong", - ({ data }) => { - this.emit("pong", data); - }, - once, - ); + this.#ws.addEventListener("pong", ({ data }) => { + this.emit("pong", data); + }); } } - return once ? super.once(event, listener) : super.on(event, listener); + return super.on(event, listener); } on(event, listener) { - return this.#onOrOnce(event, listener, undefined); - } - - once(event, listener) { - return this.#onOrOnce(event, listener, onceObject); + return this.#armAndOn(event, listener); } addListener(event, listener) { - return this.#onOrOnce(event, listener, undefined); + return this.#armAndOn(event, listener); } prependListener(event, listener) { @@ -484,7 +448,7 @@ class BunWebSocket extends EventEmitter { if (eventIds[event] === undefined) return; const mask = 1 << eventIds[event]; if ((this.#eventId & mask) === mask) return; - this.#onOrOnce(event, noopBridgeListener, undefined); + this.#armAndOn(event, noopBridgeListener); super.off(event, noopBridgeListener); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index c56527ceb06a..8b69fac8591b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -314,6 +314,10 @@ pub struct VirtualMachine { pub on_print_error_zig_exception_ctx: *mut c_void, pub(crate) is_handling_uncaught_exception: bool, pub(crate) exit_on_uncaught_exception: bool, + /// Set by `bun repl` so `uncaught_exception_fatal` stays at print-and-continue instead of + /// terminating the session. Node's REPL wraps evaluation in a domain for the same reason: + /// https://github.com/nodejs/node/blob/main/lib/repl.js + pub suppress_fatal_uncaught: bool, pub modules: crate::async_module::Queue, pub aggressive_garbage_collection: GCLevel, @@ -1215,14 +1219,13 @@ impl VirtualMachine { .platform_loop_opt() .map(|h| h.is_active()) .unwrap_or(false); - self.unhandled_error_counter == 0 - && ((active as usize) - + self.active_tasks - + el.tasks.readable_length() - + el.yield_tasks.len() - + (!el.concurrent_tasks.is_empty() as usize) - + (el.has_pending_refs() as usize) - > 0) + (active as usize) + + self.active_tasks + + el.tasks.readable_length() + + el.yield_tasks.len() + + (!el.concurrent_tasks.is_empty() as usize) + + (el.has_pending_refs() as usize) + > 0 } pub fn is_event_loop_alive(&self) -> bool { @@ -1531,6 +1534,25 @@ impl VirtualMachine { global_object: &JSGlobalObject, err: JSValue, origin: UncaughtExceptionOrigin, + ) -> bool { + self.uncaught_exception_impl(global_object, err, origin, false) + } + + pub fn uncaught_exception_fatal( + &mut self, + global_object: &JSGlobalObject, + err: JSValue, + origin: UncaughtExceptionOrigin, + ) -> bool { + self.uncaught_exception_impl(global_object, err, origin, true) + } + + fn uncaught_exception_impl( + &mut self, + global_object: &JSGlobalObject, + err: JSValue, + origin: UncaughtExceptionOrigin, + fatal_exit: bool, ) -> bool { if self.is_shutting_down() { return true; @@ -1589,6 +1611,27 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } + if fatal_exit + && !self.suppress_fatal_uncaught + && self.is_main_thread() + && self.hot_reload == HotReload::None + && origin != UncaughtExceptionOrigin::EntryPointRejection + { + self.unhandled_error_counter += 1; + self.exit_handler.exit_code = 1; + (self.on_unhandled_rejection)(self, global_object, err); + bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); + bun_core::pretty_errorln!( + "\n{}", + bun_core::Global::unhandled_error_bun_version_string, + ); + self.is_handling_uncaught_exception = false; + self.exit_on_uncaught_exception = true; + // SAFETY: see above. + unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; + panic!("made it past process.exit()"); + } + // --abort-on-uncaught-exception already handled in Bun__handleUncaughtException. self.unhandled_error_counter += 1; self.exit_handler.exit_code = 1; (self.on_unhandled_rejection)(self, global_object, err); @@ -3682,7 +3725,22 @@ impl VirtualMachine { if handle_unhandled() { return; } - // continue to default handler + if self.hot_reload == HotReload::None { + let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception( + global_object, + reason, + ); + if self.uncaught_exception_fatal( + global_object, + wrapped, + UncaughtExceptionOrigin::Rejection, + ) { + drain(self); + return; + } + let _ = self.event_loop_mut().drain_microtasks(); + return; + } } Mode::None => { let _ = handle_unhandled(); @@ -3710,13 +3768,12 @@ impl VirtualMachine { Mode::Strict => { let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); - let _ = self.uncaught_exception( + let _ = self.uncaught_exception_fatal( global_object, wrapped, UncaughtExceptionOrigin::Rejection, ); - let handled = handle_unhandled(); - if !handled { + if !handle_unhandled() { emit_warning(self); } drain(self); @@ -3729,7 +3786,7 @@ impl VirtualMachine { } let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); - if self.uncaught_exception( + if self.uncaught_exception_fatal( global_object, wrapped, UncaughtExceptionOrigin::Rejection, @@ -3737,12 +3794,8 @@ impl VirtualMachine { drain(self); return; } - // continue to default handler — but RETURN if this drain - // errors (the VM is dead; don't bump the counter or invoke the - // handler). - if self.event_loop_mut().drain_microtasks().is_err() { - return; - } + let _ = self.event_loop_mut().drain_microtasks(); + return; } } self.unhandled_error_counter += 1; diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index 84f110ec3ab5..4e5b7b26140e 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -21,6 +21,7 @@ #include "JSEventListener.h" #include "BunProcess.h" +#include "ZigGlobalObject.h" #include "EventNames.h" #include "JSDOMConvertNullable.h" #include "JSDOMConvertStrings.h" @@ -135,17 +136,26 @@ void JSEventListener::visitJSFunction(SlotVisitor& visitor) { visitJSFunctionImp JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtException, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { - auto exception = callFrame->argument(0); - reportException(lexicalGlobalObject, exception); + Bun__reportUnhandledError(lexicalGlobalObject, JSValue::encode(callFrame->argument(0))); return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtExceptionNextTick, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) + +// Node defers a listener throw via process.nextTick so the dispatch loop and post-dispatch code +// complete first: https://github.com/nodejs/node/blob/main/lib/internal/event_target.js (emitUncaughtException) +static void queueUncaughtExceptionNextTick(JSC::JSGlobalObject* lexicalGlobalObject, JSValue exception) { Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = globalObject->vm(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); Bun::Process* process = globalObject->processObject(); - auto exception = callFrame->argument(0); - auto func = JSFunction::create(globalObject->vm(), globalObject, 1, String(), jsFunctionEmitUncaughtException, JSC::ImplementationVisibility::Private); + auto func = JSFunction::create(vm, globalObject, 1, String(), jsFunctionEmitUncaughtException, JSC::ImplementationVisibility::Private); process->queueNextTick(lexicalGlobalObject, func, exception); + (void)scope.tryClearException(); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtExceptionNextTick, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + queueUncaughtExceptionNextTick(lexicalGlobalObject, callFrame->argument(0)); return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -189,13 +199,13 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext auto* exception = scope.exception(); (void)scope.tryClearException(); event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, exception); + queueUncaughtExceptionNextTick(lexicalGlobalObject, exception); return; } callData = getCallData(handleEventFunction); if (callData.type == CallData::Type::None) { event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, createTypeError(lexicalGlobalObject, "'handleEvent' property of event listener should be callable"_s)); + queueUncaughtExceptionNextTick(lexicalGlobalObject, createTypeError(lexicalGlobalObject, "'handleEvent' property of event listener should be callable"_s)); return; } } @@ -224,7 +234,7 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext auto handleExceptionIfNeeded = [&](JSC::Exception* exception) -> bool { if (exception) { event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, exception); + queueUncaughtExceptionNextTick(lexicalGlobalObject, exception); return true; } return false; @@ -241,7 +251,7 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext auto* exception = scope.exception(); (void)scope.tryClearException(); event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, exception); + queueUncaughtExceptionNextTick(lexicalGlobalObject, exception); return; } if (then.isCallable()) { @@ -253,7 +263,7 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext auto* exception = scope.exception(); (void)scope.tryClearException(); event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, exception); + queueUncaughtExceptionNextTick(lexicalGlobalObject, exception); return; } } diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 8e5ac354a6bd..8d39853e1a65 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -76,7 +76,7 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) { crate::mark_binding!(); if !value.is_termination_exception() { - let _ = global.bun_vm().as_mut().uncaught_exception( + let _ = global.bun_vm().as_mut().uncaught_exception_fatal( global, value, crate::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index ea9ede4d3ad9..357af017c758 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1811,13 +1811,34 @@ impl CronJob { let _ev_guard = vm.enter_event_loop_scope(); this_ref.in_fire.set(true); - // A top-level call: what the tick throws is reported here (before the + // A top-level call: what the tick throws is reported here, before the // job is re-armed, so an `uncaughtException` handler's `stop()` is - // observed by `schedule_next`), and does not stop the job — as with a - // rejected tick. - let result = - vm.event_loop_mut() - .run_callback_with_result(cb, &this_ref.global, js_this, &[]); + // observed by `schedule_next`. Reported on the fatal path like a + // setTimeout tick, so an unhandled throw exits instead of leaving the + // job ticking; the same entry gate as `EventLoop::run_callback`. + let result = if this_ref.global.has_exception() { + JSValue::ZERO + } else { + match cb.call(&this_ref.global, js_this, &[]) { + Ok(v) => v, + Err(err) => { + let err = this_ref.global.take_exception(err); + if err.is_termination_exception() { + this_ref.in_fire.set(false); + Self::self_stop(this, vm); + return; + } + let global_ref = vm.global(); + // SAFETY: single JS thread; `&mut` via the thread-local raw pointer. + let _ = VirtualMachine::get().as_mut().uncaught_exception_fatal( + global_ref, + err, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + JSValue::ZERO + } + } + }; this_ref.in_fire.set(false); // terminate() may have arrived while the callback was running; bail out diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index 061ac4de2b1a..4e3daf9fc82f 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -236,7 +236,9 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.on_before_exit(); } } else { - // Interactive: run the REPL loop + // Interactive REPL: keep async throws at print-and-continue like Node's domain-wrapped + // REPL (https://github.com/nodejs/node/blob/main/lib/repl.js); `-e`/`-p` stay fatal. + vm.suppress_fatal_uncaught = true; if let Err(err) = this.repl.run_with_vm(Some(VirtualMachine::get())) { bun_core::pretty_errorln!("REPL error: {}", err.name()); } diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 9dc7092069f7..259b77cef872 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -184,6 +184,23 @@ impl NapiEnv { } } +/// Reports the exception a native addon callback left pending. Node runs these +/// callbacks through `CallbackIntoModule`, so an uncaught throw is fatal, unlike +/// the keep-alive fold the caller's dispatcher applies to whatever is returned +/// here: a termination, which stays pending for that dispatcher to stand down on. +fn report_addon_exception(global: &JSGlobalObject, proof: jsc::JsError) -> JsResult<()> { + let exception = global.take_exception(proof); + if exception.is_termination_exception() { + return Err(jsc::JsError::Thrown); + } + let _ = global.bun_vm().as_mut().uncaught_exception_fatal( + global, + exception, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + Ok(()) +} + // SAFETY: NapiEnv refcount is managed externally by C++ via NapiEnv__ref/NapiEnv__deref; // the pointee remains valid while the count is > 0. unsafe impl bun_ptr::ExternalSharedDescriptor for NapiEnv { @@ -1969,7 +1986,9 @@ impl napi_async_work { complete(env, status as napi_status, self.data); // SAFETY: env is valid for the duration of this call. - unsafe { &*env }.surface_exception(global) + unsafe { &*env } + .surface_exception(global) + .or_else(|proof| report_addon_exception(global, proof)) } } @@ -2395,7 +2414,10 @@ impl Finalizer { // SAFETY: env is valid; passes the C finalizer back for bookkeeping. unsafe { napi_internal_remove_finalizer(env, Some(self.fun), self.hint, self.data) }; - env_ref.surface_exception(env_ref.to_js()) + let global = env_ref.to_js(); + env_ref + .surface_exception(global) + .or_else(|proof| report_addon_exception(global, proof)) } // `deinit` is handled by Drop on NapiEnvRef. @@ -2793,7 +2815,7 @@ impl ThreadSafeFunction { env_ref.surface_exception(global_object) } }; - match called { + match called.or_else(|proof| report_addon_exception(global_object, proof)) { Ok(()) => Ok(()), Err(err) => bun_jsc::task::report_error_or_terminate(global_object, err), } diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 2bce2e3c961e..115b5c055559 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -393,8 +393,8 @@ static void wsOnMessage(void* ctx, std::span utf8) auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); t.handleMessage(utf8); if (auto* ex = catchScope.exception()) [[unlikely]] { - catchScope.clearExceptionExceptTermination(); - t.m_global->reportUncaughtExceptionAtEventLoop(t.m_global, ex); + if (!catchScope.clearExceptionExceptTermination()) return; + Bun__reportError(t.m_global, JSC::JSValue::encode(JSC::JSValue(ex))); } } @@ -554,7 +554,7 @@ void Transport::onData(const char* data, int length) if (auto* ex = catchScope.exception()) [[unlikely]] { if (!catchScope.clearExceptionExceptTermination()) break; - m_global->reportUncaughtExceptionAtEventLoop(m_global, ex); + Bun__reportError(m_global, JSC::JSValue::encode(JSC::JSValue(ex))); } } if (off) m_rx.removeAt(0, off); diff --git a/src/runtime/webview/WebKitBackend.cpp b/src/runtime/webview/WebKitBackend.cpp index 37cd5ea345e9..816f5efac481 100644 --- a/src/runtime/webview/WebKitBackend.cpp +++ b/src/runtime/webview/WebKitBackend.cpp @@ -514,7 +514,7 @@ void HostClient::onData(const char* data, int length) // clear so one bad frame doesn't poison the rest of the batch. if (auto* exception = catchScope.exception()) [[unlikely]] { if (!catchScope.clearExceptionExceptTermination()) break; - global->reportUncaughtExceptionAtEventLoop(global, exception); + Bun__reportError(global, JSC::JSValue::encode(JSC::JSValue(exception))); } } if (off) rx.removeAt(0, off); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 83443329adce..6b0a90381e44 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -348,6 +348,18 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("an async throw from nextTick keeps the session alive", async () => { + const { stdout, stderr, exitCode } = await runRepl([ + "process.nextTick(() => { throw new Error('from-tick') })", + "'REPL-SURVIVED:' + (7 * 6)", + ".exit", + ]); + const allOutput = stripAnsi(stdout + stderr); + expect(allOutput).toContain("from-tick"); + expect(allOutput).toContain("REPL-SURVIVED:42"); + expect(exitCode).toBe(1); + }); + test("shows system error properties", async () => { const { stdout, stderr, exitCode } = await runRepl(["fs.readFileSync('/nonexistent/path/file.txt')", ".exit"]); const allOutput = stripAnsi(stdout + stderr); diff --git a/test/js/node/child_process/child_process_ipc.test.js b/test/js/node/child_process/child_process_ipc.test.js index 2e2b3e6143f4..60c064c30dd3 100644 --- a/test/js/node/child_process/child_process_ipc.test.js +++ b/test/js/node/child_process/child_process_ipc.test.js @@ -1,5 +1,5 @@ import { $ } from "bun"; -import { bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; test("child_process ipc", async () => { const output = await $`${bunExe()} ${import.meta.dir}/fixtures/ipc_fixture.js`.text(); @@ -13,3 +13,39 @@ test("child_process ipc", async () => { " `); }); + +// A throwing "message" listener on a ChildProcess is a fatal uncaught +// exception, as in node (the channel's onread runs via MakeCallback). +// Previously it was reported but the child and channel refs kept the event +// loop alive, so the parent hung. +test("a throwing 'message' listener is a fatal uncaught exception", async () => { + using dir = tempDir("cp-message-throw", { + "parent.js": ` + const { fork } = require("node:child_process"); + const cp = fork(require("node:path").join(__dirname, "child.js"), { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + cp.on("message", () => { throw new Error("cp-message-boom"); }); + `, + "child.js": ` + process.send("hi"); + // Holds refs so a keep-alive (non-fatal) report would hang the parent; + // exits when the parent's death closes the channel. + process.on("disconnect", () => process.exit(0)); + setInterval(() => {}, 100); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("cp-message-boom"), + exitCode: 1, + }); +}); diff --git a/test/js/node/dgram/node-dgram.test.js b/test/js/node/dgram/node-dgram.test.js index 39181b8b5192..0f56c09bd04e 100644 --- a/test/js/node/dgram/node-dgram.test.js +++ b/test/js/node/dgram/node-dgram.test.js @@ -105,3 +105,30 @@ function getInterface() { return "::%lo"; } + +test("node:dgram 'message' listener throw is a fatal uncaught exception", async () => { + const fixture = ` + const dgram = require("node:dgram"); + const rx = dgram.createSocket("udp4"); + rx.on("message", () => { + throw new Error("dgram-boom"); + }); + rx.bind(0, "127.0.0.1", () => { + const tx = dgram.createSocket("udp4"); + // Resend until one lands; the fatal exit ends the process. + setInterval(() => tx.send("hi", rx.address().port, "127.0.0.1"), 20); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("dgram-boom"), + exitCode: 1, + }); +}, 15_000); diff --git a/test/js/node/dns/node-dns.test.js b/test/js/node/dns/node-dns.test.js index 8bac8693cda6..a3d2092f9c06 100644 --- a/test/js/node/dns/node-dns.test.js +++ b/test/js/node/dns/node-dns.test.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, setDefaultTimeout, test } from "bun:test"; +import { beforeAll, describe, expect, it, jest, setDefaultTimeout, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows } from "harness"; import * as dgram from "node:dgram"; import * as dns from "node:dns"; @@ -696,13 +696,30 @@ describe("dns.lookupService", () => { }); }); -// Deprecated reference: https://nodejs.org/api/deprecations.html#DEP0118 -describe("lookup deprecated behavior", () => { - it.each([undefined, false, null, NaN, ""])("dns.lookup", domain => { - dns.lookup(domain, (error, address, family) => { - expect(error).toBeNull(); - expect(address).toBeNull(); - expect(family).toBe(4); +describe("lookup rejects falsy hostnames", () => { + it.each([undefined, false, null, NaN, ""])("dns.lookup(%p) throws without calling back", domain => { + const callback = jest.fn(); + expect(() => dns.lookup(domain, callback)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_VALUE", + name: "TypeError", + message: `The argument 'hostname' must be a non-empty string. Received ${util.inspect(domain)}`, + }), + ); + expect(callback).not.toHaveBeenCalled(); + }); + + it("dns.promises.lookup('') rejects instead of throwing", async () => { + const p = dns_promises.lookup(""); + expect(p).toBeInstanceOf(Promise); + expect( + await p.then( + () => null, + e => ({ code: e.code, message: e.message }), + ), + ).toEqual({ + code: "ERR_INVALID_ARG_VALUE", + message: "The argument 'hostname' must be a non-empty string. Received ''", }); }); }); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 6b15191eddc3..d01dcbf53d41 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2745,6 +2745,297 @@ describe("NODE_NO_WARNINGS", () => { }); }); +it("a fatal uncaught exception exits before already-queued work runs", async () => { + using dir = tempDir("fatal-uncaught-order", { + "fatal.js": ` + const fs = require("fs"); + fs.stat(".", () => console.log("IO-CALLBACK-RAN")); + process.on("exit", (code) => console.log("EXIT-HANDLER code=" + code)); + process.on("beforeExit", () => console.log("BEFORE-EXIT-RAN")); + setImmediate(() => console.log("IMMEDIATE-RAN")); + setTimeout(() => console.log("TIMER-RAN"), 0); + process.nextTick(() => { throw new Error("fatal"); }); + process.nextTick(() => console.log("LATER-TICK-RAN")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fatal.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("EXIT-HANDLER code=1"); + expect(stderr).toContain("fatal"); + expect(exitCode).toBe(1); +}); + +it("a handled uncaughtException keeps the event loop running", async () => { + using dir = tempDir("handled-uncaught-order", { + "handled.js": ` + const fs = require("fs"); + process.on("uncaughtException", (e) => console.log("HANDLED:" + e.message)); + fs.stat(".", () => console.log("IO-CALLBACK-RAN")); + setTimeout(() => console.log("TIMER-RAN"), 0); + process.nextTick(() => { throw new Error("caught-me"); }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "handled.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout.trim().split(/\r?\n/).sort(); + expect(lines).toEqual(["HANDLED:caught-me", "IO-CALLBACK-RAN", "TIMER-RAN"]); + expect(exitCode).toBe(0); +}); + +it("a throwing Bun.listen data handler with no error: handler keeps the server alive", async () => { + using dir = tempDir("bun-listen-handler-throw", { + "server.js": ` + let hits = 0; + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() {}, + data(socket) { + socket.end(); + console.log("DATA-HANDLER-RAN:" + ++hits); + if (hits === 2) server.stop(true); + throw new Error("handler-boom"); + }, + }, + }); + for (let i = 0; i < 2; i++) { + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { open(s) { s.write("x"); }, data() {}, close() {} }, + }); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim().split(/\r?\n/)).toEqual(["DATA-HANDLER-RAN:1", "DATA-HANDLER-RAN:2"]); + expect(stderr).toContain("handler-boom"); + expect(exitCode).toBe(1); +}); + +it("a Bun.listen error: handler that itself throws keeps the server alive", async () => { + using dir = tempDir("bun-listen-error-handler-throw", { + "server.js": ` + let hits = 0; + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() {}, + data(socket) { + socket.end(); + console.log("DATA-HANDLER-RAN:" + ++hits); + if (hits === 2) server.stop(true); + throw new Error("from-data"); + }, + error() { throw new Error("from-error-handler"); }, + }, + }); + for (let i = 0; i < 2; i++) { + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { open(s) { s.write("x"); }, data() {}, close() {} }, + }); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim().split(/\r?\n/)).toEqual(["DATA-HANDLER-RAN:1", "DATA-HANDLER-RAN:2"]); + expect(stderr).toContain("from-error-handler"); + expect(exitCode).toBe(1); +}); + +it("a throwing EventTarget listener lets dispatch complete, then fatal-exits next tick", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `setInterval(() => console.log("TICK"), 5000); + const ac = new AbortController(); + ac.signal.addEventListener("abort", () => { throw new Error("from-first"); }); + ac.signal.addEventListener("abort", () => console.log("SECOND-LISTENER")); + ac.abort(); + console.log("AFTER-ABORT");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim().split(/\r?\n/)).toEqual(["SECOND-LISTENER", "AFTER-ABORT"]); + expect(stdout).not.toContain("TICK"); + expect(stderr).toContain("from-first"); + expect(exitCode).toBe(1); +}); + +it("a rejecting async EventTarget listener fatal-exits next tick", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `setInterval(() => console.log("TICK"), 5000); + const t = new EventTarget(); + t.addEventListener("x", async () => { throw new Error("from-async"); }); + t.dispatchEvent(new Event("x")); + console.log("AFTER-DISPATCH");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("AFTER-DISPATCH"); + expect(stdout).not.toContain("TICK"); + expect(stderr).toContain("from-async"); + expect(exitCode).toBe(1); +}); + +it.each([undefined, "throw", "strict"])( + "an unhandled rejection fatal-exits with pending work (--unhandled-rejections=%s)", + async mode => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + ...(mode ? [`--unhandled-rejections=${mode}`] : []), + "-e", + `setInterval(() => console.log("TICK"), 5000); Promise.reject(new Error("rejected"))`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).not.toContain("TICK"); + expect(stderr).toContain("rejected"); + expect(exitCode).toBe(1); + }, +); + +it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { + using dir = tempDir("spawn-ipc-throw", { + "parent.js": ` + const child = Bun.spawn({ + cmd: [process.execPath, "child.js"], + ipc(message) { + if (message === "boom") throw new Error("ipc-boom"); + console.log("got:" + message); + child.send("ack"); + }, + }); + await child.exited; + `, + "child.js": ` + process.send("boom"); + process.send("second"); + process.on("message", () => process.exit(0)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("got:second"); + expect(stderr).toContain("ipc-boom"); + expect(exitCode).toBe(1); +}); + +it("a throwing Bun.serve websocket message handler keeps the server serving", async () => { + using dir = tempDir("ws-throw-alive", { + "server.js": ` + const server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("http-ok"); + }, + websocket: { + message(ws, msg) { + if (msg === "boom") throw new Error("ws-boom"); + ws.send("echo:" + msg); + }, + }, + }); + console.log(server.port); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const reader = proc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const port = parseInt(new TextDecoder().decode(value).trim()); + + const first = new WebSocket("ws://127.0.0.1:" + port); + await new Promise((resolve, reject) => { + first.onopen = resolve; + first.onerror = () => reject(new Error("first connection failed to open")); + }); + first.send("boom"); + + let stderrText = ""; + const errReader = proc.stderr.getReader(); + const errDecoder = new TextDecoder(); + while (!stderrText.includes("ws-boom")) { + const { value, done } = await errReader.read(); + if (done) throw new Error("stderr ended before the throw was reported: " + stderrText); + stderrText += errDecoder.decode(value); + } + + const echoed = await new Promise((resolve, reject) => { + const ws = new WebSocket("ws://127.0.0.1:" + port); + ws.onopen = () => ws.send("after"); + ws.onmessage = e => resolve(e.data); + ws.onclose = e => reject(new Error("second connection closed: " + e.code)); + ws.onerror = () => reject(new Error("second connection errored")); + }); + expect(echoed).toBe("echo:after"); + first.close(); + + proc.kill(); + while (true) { + const { done } = await errReader.read(); + if (done) break; + } + await proc.exited; +}); + it("process.exit() does not run microtasks or nextTicks that were queued before it", async () => { // Node runs 'exit' handlers and nothing queued before them; the exit-time // teardown must discard, not drain, the pre-exit microtask/nextTick queues. diff --git a/test/js/node/test/parallel/test-c-ares.js b/test/js/node/test/parallel/test-c-ares.js index 0d32d871dc60..c2cc051ece62 100644 --- a/test/js/node/test/parallel/test-c-ares.js +++ b/test/js/node/test/parallel/test-c-ares.js @@ -29,9 +29,9 @@ const dnsPromises = dns.promises; (async function() { let res; - res = await dnsPromises.lookup(null); - assert.strictEqual(res.address, null); - assert.strictEqual(res.family, 4); + await assert.rejects(dnsPromises.lookup(null), { + code: 'ERR_INVALID_ARG_VALUE', + }); res = await dnsPromises.lookup('127.0.0.1'); assert.strictEqual(res.address, '127.0.0.1'); @@ -43,10 +43,9 @@ const dnsPromises = dns.promises; })().then(common.mustCall()); // Try resolution without hostname. -dns.lookup(null, common.mustSucceed((result, addressType) => { - assert.strictEqual(result, null); - assert.strictEqual(addressType, 4); -})); +assert.throws(() => dns.lookup(null, common.mustNotCall()), { + code: 'ERR_INVALID_ARG_VALUE', +}); dns.lookup('127.0.0.1', common.mustSucceed((result, addressType) => { assert.strictEqual(result, '127.0.0.1'); diff --git a/test/js/node/test/parallel/test-dns-lookup.js b/test/js/node/test/parallel/test-dns-lookup.js index bef563df6087..fe9df376353a 100644 --- a/test/js/node/test/parallel/test-dns-lookup.js +++ b/test/js/node/test/parallel/test-dns-lookup.js @@ -28,19 +28,14 @@ const dnsPromises = dns.promises; } // This also verifies different expectWarning notations. -common.expectWarning({ - // For 'internal/test/binding' module. - ...(typeof Bun === "undefined"? { +if (typeof Bun === "undefined") { + common.expectWarning({ + // For 'internal/test/binding' module. 'internal/test/binding': [ 'These APIs are for internal testing only. Do not use them.', - ] - } : {}), - // For calling `dns.lookup` with falsy `hostname`. - 'DeprecationWarning': { - DEP0118: 'The provided hostname "false" is not a valid ' + - 'hostname, and is supported in the dns module solely for compatibility.' - } -}); + ], + }); +} assert.throws(() => { dns.lookup(false, 'cb'); @@ -151,12 +146,13 @@ assert.throws(() => dnsPromises.lookup(false, () => {}), (async function() { let res; - res = await dnsPromises.lookup(false, { + await assert.rejects(dnsPromises.lookup(false, { hints: 0, family: 0, all: true + }), { + code: 'ERR_INVALID_ARG_VALUE', }); - assert.deepStrictEqual(res, []); res = await dnsPromises.lookup('127.0.0.1', { hints: 0, @@ -173,14 +169,13 @@ assert.throws(() => dnsPromises.lookup(false, () => {}), assert.deepStrictEqual(res, { address: '127.0.0.1', family: 4 }); })().then(common.mustCall()); -dns.lookup(false, { +assert.throws(() => dns.lookup(false, { hints: 0, family: 0, all: true -}, common.mustSucceed((result, addressType) => { - assert.deepStrictEqual(result, []); - assert.strictEqual(addressType, undefined); -})); +}, common.mustNotCall()), { + code: 'ERR_INVALID_ARG_VALUE', +}); dns.lookup('127.0.0.1', { hints: 0, @@ -220,4 +215,4 @@ tickValue = 1; // Should fail due to stub. assert.rejects(dnsPromises.lookup('example.com'), - { code: 'ENOMEM', hostname: 'example.com' }).then(common.mustCall()); + { code: 'ENOMEM', hostname: 'example.com' }).then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-dns.js b/test/js/node/test/parallel/test-dns.js index 8c2b0f8e480e..efd6232c2d07 100644 --- a/test/js/node/test/parallel/test-dns.js +++ b/test/js/node/test/parallel/test-dns.js @@ -191,16 +191,13 @@ assert.deepStrictEqual(dns.getServers(), []); // dns.lookup should accept falsey values { - const checkCallback = (err, address, family) => { - assert.ifError(err); - assert.strictEqual(address, null); - assert.strictEqual(family, 4); - }; - ['', null, undefined, 0, NaN].forEach(async (value) => { - const res = await dnsPromises.lookup(value); - assert.deepStrictEqual(res, { address: null, family: 4 }); - dns.lookup(value, common.mustCall(checkCallback)); + await assert.rejects(dnsPromises.lookup(value), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.throws(() => dns.lookup(value, common.mustNotCall()), { + code: 'ERR_INVALID_ARG_VALUE', + }); }); } @@ -245,52 +242,104 @@ assert.throws(() => dns.lookup('', { name: 'TypeError' }); -dns.lookup('', { family: 4, hints: 0 }, common.mustCall()); +assert.throws(() => { + dns.lookup('', { family: 4, hints: 0 }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - family: 6, - hints: dns.ADDRCONFIG -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + family: 6, + hints: dns.ADDRCONFIG + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { hints: dns.V4MAPPED }, common.mustCall()); +assert.throws(() => { + dns.lookup('', { hints: dns.V4MAPPED }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.ADDRCONFIG | dns.V4MAPPED -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.ADDRCONFIG | dns.V4MAPPED + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.ALL -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.ALL + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.V4MAPPED | dns.ALL -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.V4MAPPED | dns.ALL + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL, - family: 'IPv4' -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL, + family: 'IPv4' + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); -dns.lookup('', { - hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL, - family: 'IPv6' -}, common.mustCall()); +assert.throws(() => { + dns.lookup('', { + hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL, + family: 'IPv6' + }, common.mustNotCall()); +}, { + code: 'ERR_INVALID_ARG_VALUE', +}); (async function() { - await dnsPromises.lookup('', { family: 4, hints: 0 }); - await dnsPromises.lookup('', { family: 6, hints: dns.ADDRCONFIG }); - await dnsPromises.lookup('', { hints: dns.V4MAPPED }); - await dnsPromises.lookup('', { hints: dns.ADDRCONFIG | dns.V4MAPPED }); - await dnsPromises.lookup('', { hints: dns.ALL }); - await dnsPromises.lookup('', { hints: dns.V4MAPPED | dns.ALL }); - await dnsPromises.lookup('', { + await assert.rejects(dnsPromises.lookup('', { family: 4, hints: 0 }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { family: 6, hints: dns.ADDRCONFIG }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { hints: dns.V4MAPPED }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { hints: dns.ADDRCONFIG | dns.V4MAPPED }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { hints: dns.ALL }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { hints: dns.V4MAPPED | dns.ALL }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { hints: dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL + }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + await assert.rejects(dnsPromises.lookup('', { order: 'verbatim' }), { + code: 'ERR_INVALID_ARG_VALUE', }); - await dnsPromises.lookup('', { order: 'verbatim' }); })().then(common.mustCall()); { diff --git a/test/js/node/test/parallel/test-domain-implicit-binding.js b/test/js/node/test/parallel/test-domain-implicit-binding.js new file mode 100644 index 000000000000..9f119a420368 --- /dev/null +++ b/test/js/node/test/parallel/test-domain-implicit-binding.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const domain = require('domain'); +const fs = require('fs'); +const isEnumerable = Function.call.bind(Object.prototype.propertyIsEnumerable); + +process.on('warning', common.mustNotCall()); + +{ + const d = new domain.Domain(); + + d.on('error', common.mustCall((err) => { + assert.strictEqual(err.message, 'foobar'); + assert.strictEqual(err.domain, d); + assert.strictEqual(isEnumerable(err, 'domain'), false); + assert.strictEqual(err.domainEmitter, undefined); + assert.strictEqual(err.domainBound, undefined); + assert.strictEqual(err.domainThrown, true); + })); + + d.run(common.mustCall(() => { + process.nextTick(common.mustCall(() => { + const i = setInterval(common.mustCall(() => { + clearInterval(i); + setTimeout(common.mustCall(() => { + fs.stat('this file does not exist', common.mustCall((er, stat) => { + throw new Error('foobar'); + })); + }), 1); + }), 1); + })); + })); +} diff --git a/test/js/node/test/parallel/test-domain-implicit-fs.js b/test/js/node/test/parallel/test-domain-implicit-fs.js new file mode 100644 index 000000000000..abb4e89f085d --- /dev/null +++ b/test/js/node/test/parallel/test-domain-implicit-fs.js @@ -0,0 +1,63 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +// Simple tests of most basic domain functionality. + +const common = require('../common'); +const assert = require('assert'); +const domain = require('domain'); + +process.on('warning', common.mustNotCall()); + +const d = new domain.Domain(); + +d.on('error', common.mustCall(function(er) { + console.error('caught', er); + + assert.strictEqual(er.domain, d); + assert.strictEqual(er.domainThrown, true); + assert.ok(!er.domainEmitter); + assert.strictEqual(er.actual.code, 'ENOENT'); + assert.match(er.actual.path, /\bthis file does not exist\b/i); + assert.strictEqual(typeof er.actual.errno, 'number'); +})); + + +// Implicit handling of thrown errors while in a domain, via the +// single entry points of ReqWrap and MakeCallback. Even if +// we try very hard to escape, there should be no way to, even if +// we go many levels deep through timeouts and multiple IO calls. +// Everything that happens between the domain.enter() and domain.exit() +// calls will be bound to the domain, even if multiple levels of +// handles are created. +d.run(common.mustCall(() => { + setTimeout(common.mustCall(() => { + const fs = require('fs'); + fs.readdir(__dirname, common.mustCall(() => { + // eslint-disable-next-line node-core/prefer-common-mustsucceed + fs.open('this file does not exist', 'r', common.mustCall((er) => { + assert.ifError(er); + throw new Error('should not get here!'); + })); + })); + }), 100); +})); diff --git a/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-5.js b/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-5.js new file mode 100644 index 000000000000..ade72147e148 --- /dev/null +++ b/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-5.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +const domain = require('domain'); + +function test() { + const d = domain.create(); + + d.run(function() { + const fs = require('fs'); + fs.exists('/non/existing/file', function onExists() { + throw new Error('boom!'); + }); + }); +} + +if (process.argv[2] === 'child') { + test(); +} else { + common.childShouldThrowAndAbort(); +} diff --git a/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-9.js b/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-9.js new file mode 100644 index 000000000000..ae30a1dea68b --- /dev/null +++ b/test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-9.js @@ -0,0 +1,27 @@ +'use strict'; + +const common = require('../common'); +const domain = require('domain'); + +function test() { + const d = domain.create(); + const d2 = domain.create(); + + d.on('error', function errorHandler() { + }); + + d.run(() => { + d2.run(() => { + const fs = require('fs'); + fs.exists('/non/existing/file', function onExists() { + throw new Error('boom!'); + }); + }); + }); +} + +if (process.argv[2] === 'child') { + test(); +} else { + common.childShouldThrowAndAbort(); +} diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 9d018598ce7e..c9373613f449 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -1666,3 +1666,82 @@ test("fs.watch wrapper reference survives GC across event, abort and close paths expect(stdout.trim()).toBe("OK"); expect(exitCode).toBe(0); }, 30_000); + +test("fs.watch callback throw is a fatal uncaught exception", async () => { + using dir = tempDir("watch-throw", {}); + const fixture = ` + const fs = require("node:fs"); + const dir = ${JSON.stringify(String(dir))}; + fs.watch(dir, () => { + throw new Error("watch-boom"); + }); + // Rewrite until the watcher fires; the fatal exit ends the process. + setInterval(() => fs.writeFileSync(dir + "/x", String(Date.now())), 20); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("watch-boom"), + exitCode: 1, + }); +}, 15_000); + +test("fs.promises.watch throwing ignore matcher is a fatal uncaught exception", async () => { + using dir = tempDir("pwatch-throw", {}); + const fixture = ` + const fs = require("node:fs"); + const dir = ${JSON.stringify(String(dir))}; + (async () => { + for await (const e of fs.promises.watch(dir, { ignore: () => { throw new Error("ignore-boom"); } })) { + } + })(); + setInterval(() => fs.writeFileSync(dir + "/x", String(Date.now())), 20); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("ignore-boom"), + exitCode: 1, + }); +}, 15_000); + +test("fs.watch callback throw reaches an uncaughtException handler", async () => { + using dir = tempDir("watch-caught", {}); + const fixture = ` + const fs = require("node:fs"); + const dir = ${JSON.stringify(String(dir))}; + process.on("uncaughtException", err => { + console.log("CAUGHT:" + err.message); + watcher.close(); + clearInterval(timer); + }); + const watcher = fs.watch(dir, () => { + throw new Error("watch-boom"); + }); + const timer = setInterval(() => fs.writeFileSync(dir + "/x", String(Date.now())), 20); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: expect.stringContaining("CAUGHT:watch-boom"), + stderr: "", + exitCode: 0, + }); +}, 15_000); diff --git a/test/js/node/watch/fs.watchFile.test.ts b/test/js/node/watch/fs.watchFile.test.ts index 60f4320e61de..60d2967a1721 100644 --- a/test/js/node/watch/fs.watchFile.test.ts +++ b/test/js/node/watch/fs.watchFile.test.ts @@ -496,3 +496,28 @@ describe("fs.watchFile", () => { }); }, 30_000); }); + +test("fs.watchFile listener throw is a fatal uncaught exception", async () => { + using dir = tempDir("watchfile-throw", { "target.txt": "0" }); + const fixture = ` + const fs = require("node:fs"); + const file = ${JSON.stringify(path.join(String(dir), "target.txt"))}; + fs.watchFile(file, { interval: 20 }, () => { + throw new Error("watchfile-boom"); + }); + // Grow the file until a poll observes a change; the fatal exit ends the process. + setInterval(() => fs.appendFileSync(file, "x"), 20); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("watchfile-boom"), + exitCode: 1, + }); +}, 15_000); diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index e8274d7429de..975c035c0c94 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -727,7 +727,10 @@ describe("HTMLRewriter", () => { cmd: [ bunExe(), "-e", - `const r = new HTMLRewriter() + `process.on("unhandledRejection", err => { + console.error("UNHANDLED:" + err.message); + }); + const r = new HTMLRewriter() .on("p", { async element(e) { (async () => { throw new Error("detached"); })(); await Bun.sleep(5); @@ -741,12 +744,12 @@ describe("HTMLRewriter", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // The rewrite itself succeeds; the detached rejection is reported and - // takes the process down, rather than being captured by transform(). - expect({ stdout: stdout.trim(), reported: stderr.includes("detached"), exitCode }).toEqual({ + // The handled rejection leaves the transform to complete: both the + // rewrite's success and the rejection's routing are pinned. + expect({ stdout: stdout.trim(), reported: stderr.includes("UNHANDLED:detached"), exitCode }).toEqual({ stdout: "BODY:

ok

", reported: true, - exitCode: 1, + exitCode: 0, }); }); diff --git a/test/napi/napi-app/tsfn-throw-fixture.js b/test/napi/napi-app/tsfn-throw-fixture.js new file mode 100644 index 000000000000..d0e0a6f127b2 --- /dev/null +++ b/test/napi/napi-app/tsfn-throw-fixture.js @@ -0,0 +1,12 @@ +// A throw from a threadsafe function's JS callback must be a fatal uncaught +// exception (node 26 default policy). No uncaughtException handler here on +// purpose: main.js installs one, which would mask keep-alive vs fatal. +const native = require("./build/Debug/napitests.node"); +let n = 0; +native.test_napi_threadsafe_function_microtask_order(null, () => { + n++; + if (n === 1) throw new Error("tsfn-boom"); + console.log("callback", n); +}); +// Holds the loop open so a keep-alive (non-fatal) report would hang here. +setInterval(() => {}, 100); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9bede65a492e..9de7a6bbb278 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -653,6 +653,26 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(result).toContain("done 3"); }); + // Node dispatches the callback via CallbackIntoModule, so a throw is a + // fatal uncaught exception (enforced by default since node 26). A + // keep-alive report would leave the fixture's interval ticking forever. + it("a throw from the JS callback is a fatal uncaught exception", async () => { + await using proc = spawn({ + cmd: [bunExe(), join(__dirname, "napi-app/tsfn-throw-fixture.js")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(stderr).toContain("tsfn-boom"); + expect(stdout).not.toContain("callback 2"); + expect(exitCode).toBe(1); + }); + // An addon's own threads outlive the worker that created the threadsafe // function (next-swc's tokio pool does this): the last call and the last // release land after the worker's VM, and its event loop, are gone.