From f3ca8678477774c8be903c15907042dcb2dd4b07 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Sat, 18 Jul 2026 13:46:04 -0700 Subject: [PATCH 01/82] domain: route fs callback throws to uncaughtException, unblocking four tests Carries the callback-dispatch change from the standalone PR (fs, dns and crypto.pbkdf2 callbacks are dispatched from a promise reaction, so a throw inside one surfaced as an unhandledRejection instead of an uncaughtException), because these four domain tests need it and node:domain together. Neither half converts them alone: on main node:domain is a stub, so they die before reaching any fs call; on this branch domain works but the throw never escapes the promise. Two of the four are byte-for-byte the already-vendored abort-on-uncaught 4 and 8 with process.nextTick swapped for fs.exists, so the fs call is the entire difference. Tests copied verbatim from Node v26.3.0. --- src/js/internal/shared.ts | 30 ++++++ src/js/node/crypto.ts | 7 +- src/js/node/dns.ts | 31 +++++-- src/js/node/fs.ts | 92 +++++++++++-------- src/jsc/bindings/BunProcess.h | 5 + test/js/node/fs/fs.test.ts | 92 +++++++++++++++++++ .../parallel/test-domain-implicit-binding.js | 35 +++++++ .../test/parallel/test-domain-implicit-fs.js | 63 +++++++++++++ ...in-no-error-handler-abort-on-uncaught-5.js | 21 +++++ ...in-no-error-handler-abort-on-uncaught-9.js | 27 ++++++ 10 files changed, 351 insertions(+), 52 deletions(-) create mode 100644 test/js/node/test/parallel/test-domain-implicit-binding.js create mode 100644 test/js/node/test/parallel/test-domain-implicit-fs.js create mode 100644 test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-5.js create mode 100644 test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-9.js diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index c4535f8b2d07..b07d7adfce83 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -140,6 +140,35 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); +// Node invokes fs/dns callbacks off the libuv completion, so a throw inside one +// escapes as an uncaughtException. Bun runs them from a promise reaction, where +// an unguarded throw would only reject that promise (an unhandledRejection). +const reportUncaughtException = $newCppFunction("BunProcess.cpp", "jsFunctionReportUncaughtException", 1); + +// Wrap a node-style callback so a throw inside it takes the uncaught path. The +// callback keeps its place in the event loop; only the throw is rerouted. The +// arity switch avoids materializing `arguments` for the shapes fs and dns use. +function guardCallback(callback) { + return function guarded(a, b, c) { + try { + switch (arguments.length) { + case 0: + return callback(); + case 1: + return callback(a); + case 2: + return callback(a, b); + case 3: + return callback(a, b, c); + default: + return callback.$apply(undefined, arguments); + } + } catch (e) { + reportUncaughtException(e); + } + }; +} + function getLazy(initializer: () => T) { let value: T; let initialized = false; @@ -317,6 +346,7 @@ export default { ErrnoException, once, getLazy, + guardCallback, hasObserver, startPerf, diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index b19e01b79071..29d4cb1f2327 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -1,6 +1,7 @@ // Hardcoded module "node:crypto" const StringDecoder = require("node:string_decoder").StringDecoder; const LazyTransform = require("internal/streams/lazy_transform"); +const { guardCallback } = require("internal/shared"); const { defineCustomPromisifyArgs } = require("internal/promisify"); const Writable = require("internal/streams/writable"); const { CryptoHasher } = Bun; @@ -164,9 +165,11 @@ function pbkdf2(password, salt, iterations, keylen, digest, callback) { const promise = _pbkdf2(password, salt, iterations, keylen, digest, callback); if (callback) { + // Guarded so a throw inside the callback is an uncaughtException, as in node. + const cb = guardCallback(callback); promise.then( - result => callback(null, result), - err => callback(err), + result => cb(null, result), + err => cb(err), ); return; } diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index b4b2500845da..213c02da5417 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -2,6 +2,7 @@ const dns = Bun.dns; const utilPromisifyCustomSymbol = Symbol.for("nodejs.util.promisify.custom"); const { isIP } = require("internal/net/isIP"); +const { guardCallback } = require("internal/shared"); const { validateFunction, validateArray, @@ -204,6 +205,8 @@ function validateOrderOption(options) { } } +// Validates and returns the callback wrapped by guardCallback. +// Callers must use the return value, not the argument. function validateResolve(hostname, callback) { if (typeof hostname !== "string") { throw $ERR_INVALID_ARG_TYPE("hostname", "string", hostname); @@ -212,6 +215,8 @@ function validateResolve(hostname, callback) { if (typeof callback !== "function") { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + + return guardCallback(callback); } function validateLocalAddresses(first, second) { @@ -306,6 +311,7 @@ function lookup(hostname, options, callback) { return; } + callback = guardCallback(callback); dns .lookup(hostname, options) .then(res => { @@ -348,6 +354,7 @@ function lookupService(address, port, callback) { validateString(address); + callback = guardCallback(callback); dns.lookupService(address, port).then( results => { callback(null, ...results); @@ -409,7 +416,7 @@ var InternalResolver = class Resolver { throw $ERR_INVALID_ARG_TYPE("rrtype", "string", rrtype); } - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolve(hostname, rrtype) @@ -437,7 +444,7 @@ var InternalResolver = class Resolver { options = null; } - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolve(hostname, "A") @@ -457,7 +464,7 @@ var InternalResolver = class Resolver { options = null; } - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolve(hostname, "AAAA") @@ -472,7 +479,7 @@ var InternalResolver = class Resolver { } resolveAny(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveAny(hostname) @@ -487,7 +494,7 @@ var InternalResolver = class Resolver { } resolveCname(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveCname(hostname) @@ -502,7 +509,7 @@ var InternalResolver = class Resolver { } resolveMx(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveMx(hostname) @@ -517,7 +524,7 @@ var InternalResolver = class Resolver { } resolveNaptr(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveNaptr(hostname) @@ -532,7 +539,7 @@ var InternalResolver = class Resolver { } resolveNs(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveNs(hostname) @@ -547,7 +554,7 @@ var InternalResolver = class Resolver { } resolvePtr(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolvePtr(hostname) @@ -562,7 +569,7 @@ var InternalResolver = class Resolver { } resolveSrv(hostname, callback) { - validateResolve(hostname, callback); + callback = validateResolve(hostname, callback); Resolver.#getResolver(this) .resolveSrv(hostname) @@ -580,6 +587,7 @@ var InternalResolver = class Resolver { if (typeof callback !== "function") { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + callback = guardCallback(callback); Resolver.#getResolver(this) .resolveCaa(hostname) @@ -597,6 +605,7 @@ var InternalResolver = class Resolver { if (typeof callback !== "function") { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + callback = guardCallback(callback); Resolver.#getResolver(this) .resolveTxt(hostname) @@ -613,6 +622,7 @@ var InternalResolver = class Resolver { if (typeof callback !== "function") { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + callback = guardCallback(callback); Resolver.#getResolver(this) .resolveSoa(hostname) @@ -630,6 +640,7 @@ var InternalResolver = class Resolver { if (typeof callback !== "function") { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + callback = guardCallback(callback); Resolver.#getResolver(this) .reverse(ip) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index d19a1d0d40d4..9e2102612a59 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -23,12 +23,16 @@ function lazyGlob() { return (_lazyGlob ??= require("internal/fs/glob")); } +const { guardCallback } = require("internal/shared"); + +// Validates and returns the callback wrapped by guardCallback. +// Callers must use the return value, not the argument. function ensureCallback(callback) { if (!$isCallable(callback)) { throw $ERR_INVALID_ARG_TYPE("cb", "function", callback); } - return callback; + return guardCallback(callback); } // Micro-optimization: avoid creating a new function for every call @@ -52,7 +56,7 @@ var access = function access(path, mode, callback) { mode = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.access(path, mode).then(callback, callback); }, appendFile = function appendFile(path, data, options, callback) { @@ -61,12 +65,13 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.appendFile(path, data, options).then(nullcallback(callback), callback); }, close = function close(fd, callback) { if ($isCallable(callback)) { + callback = guardCallback(callback); fs.close(fd).then(() => callback(null), callback); } else if (callback === undefined) { fs.close(fd).then(() => {}); @@ -80,7 +85,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); // route through promises.rm for the JS-side ERR_FS_EISDIR validation promises.rm(path, options).then(nullcallback(callback), callback); }, @@ -103,12 +108,12 @@ var access = function access(path, mode, callback) { mode = 0; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.copyFile(src, dest, mode).then(nullcallback(callback), callback); }, exists = function exists(path, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); try { fs.exists.$apply(fs, [path]).then( @@ -120,22 +125,22 @@ var access = function access(path, mode, callback) { } }, chown = function chown(path, uid, gid, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.chown(path, uid, gid).then(nullcallback(callback), callback); }, chmod = function chmod(path, mode, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.chmod(path, mode).then(nullcallback(callback), callback); }, fchmod = function fchmod(fd, mode, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.fchmod(fd, mode).then(nullcallback(callback), callback); }, fchown = function fchown(fd, uid, gid, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.fchown(fd, uid, gid).then(nullcallback(callback), callback); }, @@ -145,12 +150,13 @@ var access = function access(path, mode, callback) { options = undefined; } + callback = guardCallback(callback); fs.fstat(fd, options).then(function (stats) { callback(null, stats); }, callback); }, fsync = function fsync(fd, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.fsync(fd).then(nullcallback(callback), callback); }, @@ -160,30 +166,30 @@ var access = function access(path, mode, callback) { len = 0; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.ftruncate(fd, len).then(nullcallback(callback), callback); }, futimes = function futimes(fd, atime, mtime, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.futimes(fd, atime, mtime).then(nullcallback(callback), callback); }, lchmod = constants.O_SYMLINK !== undefined ? function lchmod(path, mode, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.lchmod(path, mode).then(nullcallback(callback), callback); } : undefined, // lchmod is only available on macOS lchown = function lchown(path, uid, gid, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.lchown(path, uid, gid).then(nullcallback(callback), callback); }, link = function link(existingPath, newPath, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.link(existingPath, newPath).then(nullcallback(callback), callback); }, @@ -193,7 +199,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.mkdir(path, options).then(nullcallback(callback), callback); }, @@ -203,7 +209,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.mkdtemp(prefix, options).then(function (folder) { callback(null, folder); @@ -217,14 +223,14 @@ var access = function access(path, mode, callback) { mode = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.open(path, flags, mode).then(function (fd) { callback(null, fd); }, callback); }, fdatasync = function fdatasync(fd, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.fdatasync(fd).then(nullcallback(callback), callback); }, @@ -267,6 +273,7 @@ var access = function access(path, mode, callback) { if (!callback) { throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); } + callback = guardCallback(callback); fs.read(fd, buffer, offset, length, position).then( bytesRead => void callback(null, bytesRead, buffer), err => callback(err), @@ -281,7 +288,7 @@ var access = function access(path, mode, callback) { // to the string signature. Use Node's predicate, like writeSync below. if (types.isArrayBufferView(buffer)) { callback ||= position || length || offsetOrOptions; - ensureCallback(callback); + callback = ensureCallback(callback); if (typeof offsetOrOptions === "object") { ({ @@ -312,7 +319,7 @@ var access = function access(path, mode, callback) { // Node validates the encoding (synchronously) before the callback. validateEncoding(buffer, length); callback = position; - ensureCallback(callback); + callback = ensureCallback(callback); fs.write(fd, buffer, offsetOrOptions, length).then(wrapper, callback); }, @@ -322,7 +329,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.readdir(path, options).then(function (files) { callback(null, files); @@ -330,7 +337,7 @@ var access = function access(path, mode, callback) { }, readFile = function readFile(path, options, callback) { callback ||= options; - ensureCallback(callback); + callback = ensureCallback(callback); fs.readFile(path, options).then(function (data) { callback(null, data); @@ -338,7 +345,7 @@ var access = function access(path, mode, callback) { }, writeFile = function writeFile(path, data, options, callback) { callback ||= options; - ensureCallback(callback); + callback = ensureCallback(callback); fs.writeFile(path, data, options).then(nullcallback(callback), callback); }, @@ -348,14 +355,14 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.readlink(path, options).then(function (linkString) { callback(null, linkString); }, callback); }, rename = function rename(oldPath, newPath, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.rename(oldPath, newPath).then(nullcallback(callback), callback); }, @@ -365,7 +372,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.lstat(path, options).then(function (stats) { callback(null, stats); @@ -377,7 +384,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); const signal = options?.signal; if (signal?.aborted) { @@ -395,7 +402,7 @@ var access = function access(path, mode, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.statfs(path, options).then(function (stats) { callback(null, stats); @@ -403,9 +410,12 @@ var access = function access(path, mode, callback) { }, symlink = function symlink(target, path, type, callback) { if (callback === undefined) { - callback = type; - ensureCallback(callback); + callback = ensureCallback(type); type = undefined; + } else if ($isCallable(callback)) { + // Not ensureCallback: node does not validate the 4-argument overload's + // callback, and a non-callable one must stay an ignored `.then` handler. + callback = guardCallback(callback); } fs.symlink(target, path, type).then(callback, callback); @@ -424,21 +434,21 @@ var access = function access(path, mode, callback) { len = 0; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.truncate(path, len).then(nullcallback(callback), callback); }, unlink = function unlink(path, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.unlink(path).then(nullcallback(callback), callback); }, utimes = function utimes(path, atime, mtime, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.utimes(path, atime, mtime).then(nullcallback(callback), callback); }, lutimes = function lutimes(path, atime, mtime, callback) { - ensureCallback(callback); + callback = ensureCallback(callback); fs.lutimes(path, atime, mtime).then(nullcallback(callback), callback); }, @@ -781,7 +791,7 @@ const realpath: typeof import("node:fs").realpath = callback = options; options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.realpath(p, options, false).then(function (resolvedPath) { callback(null, resolvedPath); @@ -792,7 +802,7 @@ const realpath: typeof import("node:fs").realpath = callback = options; options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); let encoding; if (options) { if (typeof options === "string") encoding = options; @@ -932,7 +942,7 @@ realpath.native = function realpath(p, options, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); fs.realpathNative(p, options).then(function (resolvedPath) { callback(null, resolvedPath); @@ -966,7 +976,7 @@ function cp(src, dest, options, callback) { options = undefined; } - ensureCallback(callback); + callback = ensureCallback(callback); // node's callback form throws synchronously on invalid options/paths const { validateCpOptions } = require("internal/fs/cp-sync"); @@ -1134,6 +1144,7 @@ class Dir { read(cb?: (err: Error | null, entry: DirentType) => void): any { if (!$isUndefinedOrNull(cb)) { validateFunction(cb, "callback"); + cb = guardCallback(cb); // node's callback overload returns undefined (like close(cb) above) this.read().then(callOnceWithNullThen.bind(null, cb), cb); return; @@ -1175,6 +1186,7 @@ class Dir { close(cb?: (err?: Error) => void) { if (!$isUndefinedOrNull(cb)) { validateFunction(cb, "callback"); + cb = guardCallback(cb); this.close().then(callOnceWithNull.bind(null, cb), cb); return; } diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index fa87223aad14..cd0ab79f95d5 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -164,4 +164,9 @@ bool isSignalName(WTF::String input); JSC_DECLARE_HOST_FUNCTION(Process_functionDlopen); JSC_DECLARE_HOST_FUNCTION(jsFunctionSetDomainErrorHandler); +// Routes its argument onto the uncaught-exception path. Used by the +// process.nextTick drain and, via $newCppFunction, by the node-style +// callback shims in src/js. +JSC_DECLARE_HOST_FUNCTION(jsFunctionReportUncaughtException); + } // namespace Bun diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 7770856c2eb0..875fd30a7114 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5536,3 +5536,95 @@ describe("fs.close on stdio descriptors", () => { expect(exitCode).toBe(0); }); }); + +// A throw inside a node-style async callback must surface as an +// uncaughtException, as in node, where these callbacks run off the libuv +// request completion rather than from a promise reaction. +describe("a throw from a node-style callback is an uncaughtException", () => { + const dir = tempDirWithFiles("callback-throw-uncaught", { "file.txt": "hello" }); + const file = JSON.stringify(join(dir, "file.txt")); + const dirLit = JSON.stringify(dir); + + async function runScript(source: string) { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", source], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + return { stdout: stdout.trim(), exitCode }; + } + + const cases: Array<[string, string]> = [ + ["fs.exists", `require("fs").exists("/definitely/not/here", () => { throw new Error("boom"); })`], + ["fs.stat", `require("fs").stat("/definitely/not/here", () => { throw new Error("boom"); })`], + ["fs.stat (success)", `require("fs").stat(${file}, () => { throw new Error("boom"); })`], + ["fs.readFile", `require("fs").readFile(${file}, () => { throw new Error("boom"); })`], + ["fs.readdir", `require("fs").readdir(${dirLit}, () => { throw new Error("boom"); })`], + ["fs.open", `require("fs").open("/definitely/not/here", "r", () => { throw new Error("boom"); })`], + ["fs.access", `require("fs").access(${file}, () => { throw new Error("boom"); })`], + ["fs.realpath", `require("fs").realpath(${file}, () => { throw new Error("boom"); })`], + [ + "fs.close", + `const fs = require("fs"); fs.open(${file}, "r", (e, fd) => fs.close(fd, () => { throw new Error("boom"); }))`, + ], + [ + "fs.read", + `const fs = require("fs"); fs.open(${file}, "r", (e, fd) => fs.read(fd, Buffer.alloc(4), 0, 4, 0, () => { throw new Error("boom"); }))`, + ], + // Both symlink overloads: the 4-argument form takes a different path. + ["fs.symlink (3-arg)", `require("fs").symlink(${file}, ${dirLit} + "/l3", () => { throw new Error("boom"); })`], + [ + "fs.symlink (4-arg)", + `require("fs").symlink(${file}, ${dirLit} + "/l4", "file", () => { throw new Error("boom"); })`, + ], + ["dns.lookup", `require("dns").lookup("localhost", () => { throw new Error("boom"); })`], + ["dns.reverse", `require("dns").reverse("127.0.0.1", () => { throw new Error("boom"); })`], + ["crypto.pbkdf2", `require("crypto").pbkdf2("pw", "salt", 10, 16, "sha256", () => { throw new Error("boom"); })`], + ]; + + it.each(cases)("%s", async (_name, snippet) => { + const { stdout, exitCode } = await runScript(` + process.on("uncaughtException", e => { console.log("UNCAUGHT:" + e.message); process.exit(0); }); + process.on("unhandledRejection", e => { console.log("REJECTED:" + (e && e.message)); process.exit(0); }); + setTimeout(() => { console.log("NOTHING"); process.exit(0); }, 5000); + ${snippet}; + `); + expect(stdout).toBe("UNCAUGHT:boom"); + expect(exitCode).toBe(0); + }); + + it("keeps a non-throwing callback in the same place in the event loop", async () => { + const { stdout, exitCode } = await runScript(` + const fs = require("fs"); + const log = []; + fs.stat(${file}, (err, st) => { + log.push("fs-cb:" + (err === null) + ":" + st.isFile()); + process.nextTick(() => log.push("tick-from-fs-cb")); + }); + setImmediate(() => log.push("setImmediate")); + process.on("exit", () => console.log(log.join(","))); + `); + expect(stdout).toBe("fs-cb:true:true,tick-from-fs-cb,setImmediate"); + expect(exitCode).toBe(0); + }); + + it("is transparent to fs.Dir callbacks", async () => { + const { stdout, exitCode } = await runScript(` + require("fs").opendir(${dirLit}, (err, dir) => { + if (err) throw err; + dir.read((e, ent) => { + console.log(ent && ent.name); + dir.close(() => console.log("closed")); + }); + }); + `); + expect(stdout.split("\n")).toEqual(["file.txt", "closed"]); + expect(exitCode).toBe(0); + }); + + it("leaves a non-callable symlink callback as an ignored handler, like node", async () => { + const { stdout, exitCode } = await runScript(` + require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); + setTimeout(() => console.log("quiet"), 50); + `); + expect(stdout).toBe("quiet"); + expect(exitCode).toBe(0); + }); +}); 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(); +} From e1cde1154e1a2c9730ce9e2175cd7aec7c7bc281 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 20 Jul 2026 21:50:20 +0000 Subject: [PATCH 02/82] test: give the fs.Dir callback test its own directory The opendir case read the first entry of the shared describe-level fixture dir, which the sibling symlink cases populate with l3/l4/lnc links. readdir order is filesystem-dependent, so dir.read() could return a symlink instead of file.txt. Use a private mkdtemp dir containing only file.txt. --- test/js/node/fs/fs.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 875fd30a7114..84e1dc5ae0c9 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5607,7 +5607,9 @@ describe("a throw from a node-style callback is an uncaughtException", () => { it("is transparent to fs.Dir callbacks", async () => { const { stdout, exitCode } = await runScript(` - require("fs").opendir(${dirLit}, (err, dir) => { + const odir = require("fs").mkdtempSync(require("os").tmpdir() + "/cb-throw-opendir-"); + require("fs").writeFileSync(odir + "/file.txt", "x"); + require("fs").opendir(odir, (err, dir) => { if (err) throw err; dir.read((e, ent) => { console.log(ent && ent.name); From ddcdedfa6fc869ce269af20843ee2863540bd6dd Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 20 Jul 2026 22:12:37 +0000 Subject: [PATCH 03/82] test: quarantine worker-terminate ASAN crashes, matching main Same two expectations.txt entries main added in #34686 (tracked in #34095 and #34690); this branch predates that commit so CI still runs the tests. --- test/expectations.txt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/expectations.txt b/test/expectations.txt index b62e77445493..e93d1865c8fc 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -78,6 +78,24 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # Tests failed due to ASAN: SEGV on unknown address [ ASAN ] test/integration/next-pages/test/dev-server.test.ts [ CRASH ] +# worker.terminate() lands while a process.* lazy PropertyCallback builder +# (stdout/stderr/stdin/nextTick/mainModule, via setupWorkerStdio) is in JS; +# tryClearException() refuses to clear the TerminationException, so the +# builder returns with it pending and reifyStaticProperty reports the slot +# found, tripping JSC's "ASSERTION FAILED: !scope.exception() || !result" +# in getOwnPropertyDescriptor / JSValue::get. Tracked in #34095; fix PRs +# #33966 and #33418. x64-asan only (e.g. builds 75570, 75601); release +# lanes are unaffected. Remove once either fix PR lands. +[ ASAN ] test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js [ CRASH ] # #34095: JSC assertion when terminate() interrupts a lazy PropertyCallback builder +# The stress test is the bun-owned 8×10-worker amplification of the above, +# but on CI it only ever hits JSC::ExceptionScope::assertNoException at +# ExceptionScope.h:61 (6/6: builds 75493/75495/75514/75597/75604/75606), +# which #33966 reports still reproducing at ~1/4000 workers AFTER its +# lazy-builder fix ("termination landing later in the bootstrap, after the +# stdio builders have completed"). Tracked separately in #34690; this entry +# is NOT removable with the one above. +[ ASAN ] test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts [ CRASH ] # #34690: ExceptionScope::assertNoException during worker terminate bootstrap + # Tests failed due to ASAN: use-after-poison [ ASAN ] test/js/node/test/parallel/test-worker-unref-from-message-during-exit.js [ CRASH ] [ ASAN ] test/napi/napi.test.ts [ CRASH ] # can throw an exception from an async_complete_callback From e3ddf797fcb43711be1be91b30d25486fbdf57a0 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 20 Jul 2026 22:34:20 +0000 Subject: [PATCH 04/82] test: quarantine http2 reset-flood ASAN crash Pre-existing assertNoException SIGABRT tracked in #34846; hits unrelated branches including dependency-bot bumps. --- test/expectations.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/expectations.txt b/test/expectations.txt index e93d1865c8fc..b7acffde8e1a 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -170,3 +170,9 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # node:http servers cannot listen on Windows named pipes yet (ENOENT from # uv_pipe bind), so common.PIPE-based http tests cannot run there. + +# SIGABRT: ExceptionScope::assertNoException (ExceptionScope.h:61) during the +# http2 reset-flood loop; same assertion family as #34690 but with no worker +# involvement. Hits unrelated branches (incl. a deps-bot bump, build 75670) +# and ~1 in 3 asan runs on this stack. Tracked in #34846. +[ ASAN ] test/js/node/test/parallel/test-http2-reset-flood.js [ CRASH ] # #34846: assertNoException during reset flood From 180265a839323be443a5e6afca344945674009f3 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 21 Jul 2026 19:33:11 +0000 Subject: [PATCH 05/82] ci: allow binary size growth for the node-v26 compat stack [allow size] The package-binary-size gate compares against main's canary; this branch carries a full node-v26 feature stack, so the growth is expected and reviewed as part of the stack, not a regression of this PR. From 636e9495c4686ee1b2df7f56706e7ff76be92493 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 22 Jul 2026 20:46:40 +0000 Subject: [PATCH 06/82] fs: validate fstat/read callbacks; tighten the callback-throw tests - fstat and read now go through ensureCallback like their siblings, so a missing or non-callable callback throws ERR_INVALID_ARG_TYPE synchronously (node's makeStatsCallback behavior) instead of routing a TypeError through reportUncaughtException. - reword the symlink 4-arg comment and test title: the ignored non-callable callback is a preserved Bun divergence, not node parity. - runScript drains stderr and returns it so failing cases show the child's stack trace. - the setImmediate ordering marker is registered inside the fs callback so the assertion is deterministic, not a threadpool race. - the opendir fixture dir is created by the parent via tempDirWithFiles so CI runners don't accumulate orphaned temp dirs. [allow size] --- src/js/node/fs.ts | 12 +++++------- test/js/node/fs/fs.test.ts | 21 ++++++++++++++------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 9e2102612a59..1054cdd2b588 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -150,7 +150,7 @@ var access = function access(path, mode, callback) { options = undefined; } - callback = guardCallback(callback); + callback = ensureCallback(callback); fs.fstat(fd, options).then(function (stats) { callback(null, stats); }, callback); @@ -270,10 +270,7 @@ var access = function access(path, mode, callback) { } ({ offset = 0, length = buffer?.byteLength - offset, position = null } = params ?? {}); } - if (!callback) { - throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); - } - callback = guardCallback(callback); + callback = ensureCallback(callback); fs.read(fd, buffer, offset, length, position).then( bytesRead => void callback(null, bytesRead, buffer), err => callback(err), @@ -413,8 +410,9 @@ var access = function access(path, mode, callback) { callback = ensureCallback(type); type = undefined; } else if ($isCallable(callback)) { - // Not ensureCallback: node does not validate the 4-argument overload's - // callback, and a non-callable one must stay an ignored `.then` handler. + // Not ensureCallback: this preserves Bun's existing behavior where a + // non-callable 4th argument stays an ignored `.then` handler. (Node + // validates it and throws ERR_INVALID_ARG_TYPE synchronously.) callback = guardCallback(callback); } diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index be8bc569bb1f..13cce10550e6 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5613,8 +5613,14 @@ describe("a throw from a node-style callback is an uncaughtException", () => { async function runScript(source: string) { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", source], env: bunEnv, stdout: "pipe", stderr: "pipe" }); - const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); - return { stdout: stdout.trim(), exitCode }; + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + // stderr is returned (not asserted) so a failing case can show the + // child's stack trace; debug builds emit benign startup noise there. + return { stdout: stdout.trim(), stderr, exitCode }; } const cases: Array<[string, string]> = [ @@ -5663,8 +5669,10 @@ describe("a throw from a node-style callback is an uncaughtException", () => { fs.stat(${file}, (err, st) => { log.push("fs-cb:" + (err === null) + ":" + st.isFile()); process.nextTick(() => log.push("tick-from-fs-cb")); + // Registered inside the fs callback so nextTick-before-setImmediate + // is the deterministic ordering being asserted, not a threadpool race. + setImmediate(() => log.push("setImmediate")); }); - setImmediate(() => log.push("setImmediate")); process.on("exit", () => console.log(log.join(","))); `); expect(stdout).toBe("fs-cb:true:true,tick-from-fs-cb,setImmediate"); @@ -5672,10 +5680,9 @@ describe("a throw from a node-style callback is an uncaughtException", () => { }); it("is transparent to fs.Dir callbacks", async () => { + const odir = JSON.stringify(tempDirWithFiles("cb-throw-opendir", { "file.txt": "x" })); const { stdout, exitCode } = await runScript(` - const odir = require("fs").mkdtempSync(require("os").tmpdir() + "/cb-throw-opendir-"); - require("fs").writeFileSync(odir + "/file.txt", "x"); - require("fs").opendir(odir, (err, dir) => { + require("fs").opendir(${odir}, (err, dir) => { if (err) throw err; dir.read((e, ent) => { console.log(ent && ent.name); @@ -5687,7 +5694,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it("leaves a non-callable symlink callback as an ignored handler, like node", async () => { + it("keeps a non-callable symlink callback as an ignored handler (Bun divergence: node throws)", async () => { const { stdout, exitCode } = await runScript(` require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); setTimeout(() => console.log("quiet"), 50); From cd39d40f3b63e286205d805a959691304af746c0 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 22 Jul 2026 21:21:22 +0000 Subject: [PATCH 07/82] test: gate the dns.reverse callback-throw case off Windows No 127.0.0.1 PTR entry in the Windows hosts file, so the call falls through to a real query there; same gate as the vendored test-c-ares.js. [allow size] --- test/js/node/fs/fs.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 13cce10550e6..11cdc36982e9 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5647,7 +5647,12 @@ describe("a throw from a node-style callback is an uncaughtException", () => { `require("fs").symlink(${file}, ${dirLit} + "/l4", "file", () => { throw new Error("boom"); })`, ], ["dns.lookup", `require("dns").lookup("localhost", () => { throw new Error("boom"); })`], - ["dns.reverse", `require("dns").reverse("127.0.0.1", () => { throw new Error("boom"); })`], + // Windows has no 127.0.0.1 PTR entry in its hosts file, so reverse() + // there falls through to a real query (the vendored test-c-ares.js + // gates the identical call the same way). + ...(isWindows + ? [] + : [["dns.reverse", `require("dns").reverse("127.0.0.1", () => { throw new Error("boom"); })`] as [string, string]]), ["crypto.pbkdf2", `require("crypto").pbkdf2("pw", "salt", 10, 16, "sha256", () => { throw new Error("boom"); })`], ]; From 44846a9681c920ab5f0de7a5bb258935b1dba9f1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:23:45 +0000 Subject: [PATCH 08/82] [autofix.ci] apply automated fixes --- test/js/node/fs/fs.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 11cdc36982e9..a5f23b7206de 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5652,7 +5652,12 @@ describe("a throw from a node-style callback is an uncaughtException", () => { // gates the identical call the same way). ...(isWindows ? [] - : [["dns.reverse", `require("dns").reverse("127.0.0.1", () => { throw new Error("boom"); })`] as [string, string]]), + : [ + ["dns.reverse", `require("dns").reverse("127.0.0.1", () => { throw new Error("boom"); })`] as [ + string, + string, + ], + ]), ["crypto.pbkdf2", `require("crypto").pbkdf2("pw", "salt", 10, 16, "sha256", () => { throw new Error("boom"); })`], ]; From c59bf9c04f1fa84ef263c68d0a08f52c9e5439a0 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 22 Jul 2026 21:56:40 +0000 Subject: [PATCH 09/82] ci: keep the binary size allowance on the stack tip [allow size] From e51f042490a00b27271579c029d56b8757b520ad Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 00:42:11 +0000 Subject: [PATCH 10/82] style: hoist pbkdf2 then-handlers, cite node's callback dispatch, run the callback-throw suite concurrently The pbkdf2 completion handlers are hoisted named functions bound to the guarded callback; guardCallback's comment names the Node v26.3.0 mechanism it mirrors; the independent subprocess cases use it.concurrent per the test conventions. [allow size] --- src/js/internal/shared.ts | 3 ++- src/js/node/crypto.ts | 13 +++++++++---- test/js/node/fs/fs.test.ts | 8 ++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 3e6e6d01759f..52c122bd2b7b 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -144,7 +144,8 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); -// Node invokes fs/dns callbacks off the libuv completion, so a throw inside one +// Node v26.3.0 invokes fs/dns callbacks off the libuv completion via +// MakeCallback (src/node_file.cc FSReqCallback, src/cares_wrap.cc), so a throw inside one // escapes as an uncaughtException. Bun runs them from a promise reaction, where // an unguarded throw would only reject that promise (an unhandledRejection). const reportUncaughtException = $newCppFunction("BunProcess.cpp", "jsFunctionReportUncaughtException", 1); diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 29d4cb1f2327..078f59a52c55 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -167,16 +167,21 @@ function pbkdf2(password, salt, iterations, keylen, digest, callback) { if (callback) { // Guarded so a throw inside the callback is an uncaughtException, as in node. const cb = guardCallback(callback); - promise.then( - result => cb(null, result), - err => cb(err), - ); + promise.then(onPbkdf2Resolved.bind(cb), onPbkdf2Rejected.bind(cb)); return; } promise.then(() => {}); } +// Hoisted `.then` handlers for pbkdf2; `this` is the guarded callback. +function onPbkdf2Resolved(result) { + this(null, result); +} +function onPbkdf2Rejected(err) { + this(err); +} + crypto_exports.pbkdf2 = pbkdf2; crypto_exports.pbkdf2Sync = pbkdf2Sync; diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index a5f23b7206de..20eca29a535f 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5661,7 +5661,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { ["crypto.pbkdf2", `require("crypto").pbkdf2("pw", "salt", 10, 16, "sha256", () => { throw new Error("boom"); })`], ]; - it.each(cases)("%s", async (_name, snippet) => { + it.concurrent.each(cases)("%s", async (_name, snippet) => { const { stdout, exitCode } = await runScript(` process.on("uncaughtException", e => { console.log("UNCAUGHT:" + e.message); process.exit(0); }); process.on("unhandledRejection", e => { console.log("REJECTED:" + (e && e.message)); process.exit(0); }); @@ -5672,7 +5672,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it("keeps a non-throwing callback in the same place in the event loop", async () => { + it.concurrent("keeps a non-throwing callback in the same place in the event loop", async () => { const { stdout, exitCode } = await runScript(` const fs = require("fs"); const log = []; @@ -5689,7 +5689,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it("is transparent to fs.Dir callbacks", async () => { + it.concurrent("is transparent to fs.Dir callbacks", async () => { const odir = JSON.stringify(tempDirWithFiles("cb-throw-opendir", { "file.txt": "x" })); const { stdout, exitCode } = await runScript(` require("fs").opendir(${odir}, (err, dir) => { @@ -5704,7 +5704,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it("keeps a non-callable symlink callback as an ignored handler (Bun divergence: node throws)", async () => { + it.concurrent("keeps a non-callable symlink callback as an ignored handler (Bun divergence: node throws)", async () => { const { stdout, exitCode } = await runScript(` require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); setTimeout(() => console.log("quiet"), 50); From 1d959c260d26784dc4edbef324ddf68a5b5300a1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:44:18 +0000 Subject: [PATCH 11/82] [autofix.ci] apply automated fixes --- test/js/node/fs/fs.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 20eca29a535f..77a8eb62416e 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5704,12 +5704,15 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it.concurrent("keeps a non-callable symlink callback as an ignored handler (Bun divergence: node throws)", async () => { - const { stdout, exitCode } = await runScript(` + it.concurrent( + "keeps a non-callable symlink callback as an ignored handler (Bun divergence: node throws)", + async () => { + const { stdout, exitCode } = await runScript(` require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); setTimeout(() => console.log("quiet"), 50); `); - expect(stdout).toBe("quiet"); - expect(exitCode).toBe(0); - }); + expect(stdout).toBe("quiet"); + expect(exitCode).toBe(0); + }, + ); }); From 7747e40041d74f00a11fc74f31007b5c06ad7682 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 18:39:13 +0000 Subject: [PATCH 12/82] ci: keep the binary size allowance on the stack tip [allow size] From df7437e415c27f594a684424f2b881eba56551aa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:59:50 +0000 Subject: [PATCH 13/82] review: drop http2-reset-flood quarantine, link the node MakeCallback sources --- src/js/internal/shared.ts | 8 +++++--- test/expectations.txt | 6 ------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 52c122bd2b7b..c270a43f466d 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -145,9 +145,11 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); // Node v26.3.0 invokes fs/dns callbacks off the libuv completion via -// MakeCallback (src/node_file.cc FSReqCallback, src/cares_wrap.cc), so a throw inside one -// escapes as an uncaughtException. Bun runs them from a promise reaction, where -// an unguarded throw would only reject that promise (an unhandledRejection). +// MakeCallback, so a throw inside one escapes as an uncaughtException: +// https://github.com/nodejs/node/blob/v26.3.0/src/node_file.cc#L724-L741 (FSReqCallback::Reject/Resolve) +// https://github.com/nodejs/node/blob/v26.3.0/src/cares_wrap.cc#L1881 +// Bun runs them from a promise reaction, where an unguarded throw would only +// reject that promise (an unhandledRejection). const reportUncaughtException = $newCppFunction("BunProcess.cpp", "jsFunctionReportUncaughtException", 1); // Wrap a node-style callback so a throw inside it takes the uncaught path. The diff --git a/test/expectations.txt b/test/expectations.txt index fbcf6398ac85..187ad6c14c1e 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -178,9 +178,3 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # node:http servers cannot listen on Windows named pipes yet (ENOENT from # uv_pipe bind), so common.PIPE-based http tests cannot run there. - -# SIGABRT: ExceptionScope::assertNoException (ExceptionScope.h:61) during the -# http2 reset-flood loop; same assertion family as #34690 but with no worker -# involvement. Hits unrelated branches (incl. a deps-bot bump, build 75670) -# and ~1 in 3 asan runs on this stack. Tracked in #34846. -[ ASAN ] test/js/node/test/parallel/test-http2-reset-flood.js [ CRASH ] # #34846: assertNoException during reset flood From 95d08e5d27543cfb38a0d206430f8b11ab10cc34 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 19:01:16 +0000 Subject: [PATCH 14/82] test: sync expectations.txt byte-identical with main [allow size] --- test/expectations.txt | 64 +------------------------------------------ 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/test/expectations.txt b/test/expectations.txt index 187ad6c14c1e..0e2990c51be7 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -62,54 +62,19 @@ test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs interna # test-http-max-http-headers.js is vendored. test/js/node/test/parallel/test-set-http-max-http-headers.js [ FAIL ] # spawns test-http-max-http-headers.js which is not vendored -# Pre-existing fs.watch leak unmasked by this PR's eval-entry exception fix: -# the child's thrown leak error ("fs.watch(dir) leaked N MB") used to be -# swallowed by the silent-exit-0 eval bug (uncaught throw in a CJS -e script -# exited 0 with empty stderr), so this test false-passed everywhere - the -# same ~14KB-per-watch growth (~70MB over 5000 iterations) reproduces on -# unmodified main once the error actually surfaces. Needs a PathWatcher -# investigation; the #29854 resolved_path fix covers only the ~path-length -# portion. -[ DARWIN ] test/js/node/watch/fs.watch.test.ts [ FAIL ] # pre-existing leak, false-positive pass before the eval exception fix - # Tests that are flaky test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # Tests failed due to ASAN: attempting free on address which was not malloc()-ed -[ ASAN ] test/js/node/worker_threads/worker_threads.test.ts [ CRASH ] # After: threadId module and worker property is consistent -[ ASAN ] test/js/node/worker_threads/worker_destruction.test.ts [ CRASH ] # After: bun closes cleanly when Bun.connect is used in a Worker that is terminating [ ASAN ] test/integration/next-pages/test/dev-server-ssr-100.test.ts [ CRASH ] [ ASAN ] test/integration/next-pages/test/next-build.test.ts [ CRASH ] [ ASAN ] test/js/third_party/next-auth/next-auth.test.ts [ CRASH ] -[ ASAN ] test/js/node/watch/fs.watch.test.ts [ CRASH ] # Tests failed due to ASAN: SEGV on unknown address [ ASAN ] test/integration/next-pages/test/dev-server.test.ts [ CRASH ] -# worker.terminate() lands while a process.* lazy PropertyCallback builder -# (stdout/stderr/stdin/nextTick/mainModule, via setupWorkerStdio) is in JS; -# tryClearException() refuses to clear the TerminationException, so the -# builder returns with it pending and reifyStaticProperty reports the slot -# found, tripping JSC's "ASSERTION FAILED: !scope.exception() || !result" -# in getOwnPropertyDescriptor / JSValue::get. Tracked in #34095; fix PRs -# #33966 and #33418. x64-asan only (e.g. builds 75570, 75601); release -# lanes are unaffected. Remove once either fix PR lands. -[ ASAN ] test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js [ CRASH ] # #34095: JSC assertion when terminate() interrupts a lazy PropertyCallback builder -# The stress test is the bun-owned 8×10-worker amplification of the above, -# but on CI it only ever hits JSC::ExceptionScope::assertNoException at -# ExceptionScope.h:61 (6/6: builds 75493/75495/75514/75597/75604/75606), -# which #33966 reports still reproducing at ~1/4000 workers AFTER its -# lazy-builder fix ("termination landing later in the bootstrap, after the -# stdio builders have completed"). Tracked separately in #34690; this entry -# is NOT removable with the one above. -[ ASAN ] test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts [ CRASH ] # #34690: ExceptionScope::assertNoException during worker terminate bootstrap - # Tests failed due to ASAN: use-after-poison -[ ASAN ] test/js/node/test/parallel/test-worker-unref-from-message-during-exit.js [ CRASH ] [ ASAN ] test/napi/napi.test.ts [ CRASH ] # can throw an exception from an async_complete_callback -[ ASAN ] test/js/node/test/parallel/test-fs-watch.js [ CRASH ] -[ ASAN ] test/js/node/test/parallel/test-fs-watch-recursive-watch-file.js [ CRASH ] -[ ASAN ] test/js/node/test/parallel/test-fs-promises-watch.js [ CRASH ] # Tests failed due to ASAN: unknown-crash [ ASAN ] test/js/sql/tls-sql.test.ts [ CRASH ] # After: Throws on illegal transactions @@ -117,16 +82,12 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # Tests timed out due to ASAN [ ASAN ] test/js/bun/spawn/spawn.test.ts [ TIMEOUT ] [ ASAN ] test/cli/inspect/inspect.test.ts [ TIMEOUT ] -[ ASAN ] test/cli/test/parallel.test.ts [ TIMEOUT ] # 24 spawns × N workers each × LSAN exit overhead -[ ASAN ] test/cli/test/isolation.test.ts [ TIMEOUT ] # 14 spawns × LSAN exit overhead -[ ASAN ] test/js/bun/typescript/type-export.test.ts [ TIMEOUT ] # bun build --compile is slow under ASAN # Tests failed due to memory leaks [ ASAN ] test/js/node/url/pathToFileURL.test.ts [ LEAK ] # pathToFileURL doesn't leak memory [ ASAN ] test/js/node/fs/abort-signal-leak-read-write-file.test.ts [ LEAK ] # should not leak memory with already aborted signals [ ASAN ] test/js/web/streams/streams-leak.test.ts [ LEAK ] # Absolute memory usage remains relatively constant when reading and writing to a pipe [ ASAN ] test/cli/run/require-cache.test.ts [ LEAK ] # files transpiled and loaded don't leak file paths > via require() -[ ASAN ] test/js/bun/io/bun-write-leak.test.ts [ LEAK ] # Bun.write should not leak the output data # Windows-only gaps in named-pipe / socket teardown for ported Node net tests # (these pass on Linux and macOS): half-close (FIN) handling on named pipes, @@ -143,10 +104,6 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # server.close() (via `await using`) waits forever. The assertion itself passes; # only the teardown hangs. Passes on Linux and macOS. -# The 10 MB socket write in this test is an order of magnitude slower under -# AddressSanitizer and exceeds the per-test timeout; it passes on regular builds. -[ ASAN ] test/js/node/test/parallel/test-net-error-twice.js [ SKIP ] # ASAN-instrumented 10 MB write exceeds the timeout - # The localAddress/localPort bind-before-connect and the SO_ERROR read on a # connecting socket that was reset during establishment are implemented in the # POSIX (kqueue/epoll + BSD socket) connect path; Windows connects through @@ -158,23 +115,4 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # primary distributing accepted connections; the upstream test's expectations # only hold on the distributing model. Passes on macOS, Windows and FreeBSD. [ LINUX ] test/js/node/test/sequential/test-net-listen-shared-ports.js [ FAIL ] # SO_REUSEPORT shared-listener semantics on Linux - -# Pre-existing on main (flagged [pre-existing] by ci:errors, e.g. build 61989 on -# darwin-14-aarch64 and build 62204 on darwin-26-aarch64, both at the -# `expect(e.name).toBe("TimeoutError")` assertions). The two connect-race cases -# assume connect() to TEST-NET-1 (192.0.2.1) stays in EINPROGRESS so the -# AbortSignal.timeout wins; that only holds when the host has a default route that -# silently blackholes the SYN. The darwin CI agents have no route to 192.0.2.1 and -# return an immediate ENETUNREACH/EHOSTUNREACH, so the connect error wins the race -# and fetch rejects with a generic "Error" instead of "TimeoutError" (verified: a -# fast-failing connect to 127.0.0.1:1 reproduces the same connect-error-beats-timeout -# outcome locally — correct behavior, not a bun abort bug). Consistently fails on -# darwin-26 (no route), intermittently on darwin-14 (routing varies per agent). -# Quarantined on darwin; still runs on Linux/Windows where the route blackholes -# reliably. Follow-up: rewrite without the external-IP dependence (repo rule forbids -# contacting external hosts) using a deterministic local hanging connect. -[ DARWIN ] test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts [ FLAKY ] # connect-during-abort race depends on host routing of 192.0.2.1; darwin CI agents return an immediate routing error - - -# node:http servers cannot listen on Windows named pipes yet (ENOENT from -# uv_pipe bind), so common.PIPE-based http tests cannot run there. +[ DARWIN ] test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts [ FLAKY ] # connect-during-abort race depends on host routing of 192.0.2.1; darwin CI agents return an immediate routing error \ No newline at end of file From 19d86bb62e2c60386f54fb0cf0d19dea2a9cbadc Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 19:03:49 +0000 Subject: [PATCH 15/82] test: explain the settle window in the ignored-symlink-callback check [allow size] --- test/js/node/fs/fs.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 77a8eb62416e..753abcb3efff 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5709,6 +5709,9 @@ describe("a throw from a node-style callback is an uncaughtException", () => { async () => { const { stdout, exitCode } = await runScript(` require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); + // Negative assertion with no observable signal: the ignored handler + // staying silent has nothing to await, so hold the process open past + // the symlink settlement before printing the sentinel. setTimeout(() => console.log("quiet"), 50); `); expect(stdout).toBe("quiet"); From 75b8465aed53166cb9a67db30a266fe57bb5ac25 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:27:47 +0000 Subject: [PATCH 16/82] test: match expectations.txt to the PR base, not main This PR is stacked on #31828, so zero-diff for expectations.txt means matching claude/port-node-domain-tests. 95d08e5 synced to main instead, which un-quarantined the base branch's ASAN entries (fs.watch, worker-message-port-transfer-terminate) and turned build 78795 red on x64-asan for failures this PR's four files do not touch. test-http2-reset-flood.js stays out; the base branch never had it and it is the known flake the review asked to drop (#34846). [allow size] --- test/expectations.txt | 64 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/test/expectations.txt b/test/expectations.txt index 0e2990c51be7..187ad6c14c1e 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -62,19 +62,54 @@ test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs interna # test-http-max-http-headers.js is vendored. test/js/node/test/parallel/test-set-http-max-http-headers.js [ FAIL ] # spawns test-http-max-http-headers.js which is not vendored +# Pre-existing fs.watch leak unmasked by this PR's eval-entry exception fix: +# the child's thrown leak error ("fs.watch(dir) leaked N MB") used to be +# swallowed by the silent-exit-0 eval bug (uncaught throw in a CJS -e script +# exited 0 with empty stderr), so this test false-passed everywhere - the +# same ~14KB-per-watch growth (~70MB over 5000 iterations) reproduces on +# unmodified main once the error actually surfaces. Needs a PathWatcher +# investigation; the #29854 resolved_path fix covers only the ~path-length +# portion. +[ DARWIN ] test/js/node/watch/fs.watch.test.ts [ FAIL ] # pre-existing leak, false-positive pass before the eval exception fix + # Tests that are flaky test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # Tests failed due to ASAN: attempting free on address which was not malloc()-ed +[ ASAN ] test/js/node/worker_threads/worker_threads.test.ts [ CRASH ] # After: threadId module and worker property is consistent +[ ASAN ] test/js/node/worker_threads/worker_destruction.test.ts [ CRASH ] # After: bun closes cleanly when Bun.connect is used in a Worker that is terminating [ ASAN ] test/integration/next-pages/test/dev-server-ssr-100.test.ts [ CRASH ] [ ASAN ] test/integration/next-pages/test/next-build.test.ts [ CRASH ] [ ASAN ] test/js/third_party/next-auth/next-auth.test.ts [ CRASH ] +[ ASAN ] test/js/node/watch/fs.watch.test.ts [ CRASH ] # Tests failed due to ASAN: SEGV on unknown address [ ASAN ] test/integration/next-pages/test/dev-server.test.ts [ CRASH ] +# worker.terminate() lands while a process.* lazy PropertyCallback builder +# (stdout/stderr/stdin/nextTick/mainModule, via setupWorkerStdio) is in JS; +# tryClearException() refuses to clear the TerminationException, so the +# builder returns with it pending and reifyStaticProperty reports the slot +# found, tripping JSC's "ASSERTION FAILED: !scope.exception() || !result" +# in getOwnPropertyDescriptor / JSValue::get. Tracked in #34095; fix PRs +# #33966 and #33418. x64-asan only (e.g. builds 75570, 75601); release +# lanes are unaffected. Remove once either fix PR lands. +[ ASAN ] test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js [ CRASH ] # #34095: JSC assertion when terminate() interrupts a lazy PropertyCallback builder +# The stress test is the bun-owned 8×10-worker amplification of the above, +# but on CI it only ever hits JSC::ExceptionScope::assertNoException at +# ExceptionScope.h:61 (6/6: builds 75493/75495/75514/75597/75604/75606), +# which #33966 reports still reproducing at ~1/4000 workers AFTER its +# lazy-builder fix ("termination landing later in the bootstrap, after the +# stdio builders have completed"). Tracked separately in #34690; this entry +# is NOT removable with the one above. +[ ASAN ] test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts [ CRASH ] # #34690: ExceptionScope::assertNoException during worker terminate bootstrap + # Tests failed due to ASAN: use-after-poison +[ ASAN ] test/js/node/test/parallel/test-worker-unref-from-message-during-exit.js [ CRASH ] [ ASAN ] test/napi/napi.test.ts [ CRASH ] # can throw an exception from an async_complete_callback +[ ASAN ] test/js/node/test/parallel/test-fs-watch.js [ CRASH ] +[ ASAN ] test/js/node/test/parallel/test-fs-watch-recursive-watch-file.js [ CRASH ] +[ ASAN ] test/js/node/test/parallel/test-fs-promises-watch.js [ CRASH ] # Tests failed due to ASAN: unknown-crash [ ASAN ] test/js/sql/tls-sql.test.ts [ CRASH ] # After: Throws on illegal transactions @@ -82,12 +117,16 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # Tests timed out due to ASAN [ ASAN ] test/js/bun/spawn/spawn.test.ts [ TIMEOUT ] [ ASAN ] test/cli/inspect/inspect.test.ts [ TIMEOUT ] +[ ASAN ] test/cli/test/parallel.test.ts [ TIMEOUT ] # 24 spawns × N workers each × LSAN exit overhead +[ ASAN ] test/cli/test/isolation.test.ts [ TIMEOUT ] # 14 spawns × LSAN exit overhead +[ ASAN ] test/js/bun/typescript/type-export.test.ts [ TIMEOUT ] # bun build --compile is slow under ASAN # Tests failed due to memory leaks [ ASAN ] test/js/node/url/pathToFileURL.test.ts [ LEAK ] # pathToFileURL doesn't leak memory [ ASAN ] test/js/node/fs/abort-signal-leak-read-write-file.test.ts [ LEAK ] # should not leak memory with already aborted signals [ ASAN ] test/js/web/streams/streams-leak.test.ts [ LEAK ] # Absolute memory usage remains relatively constant when reading and writing to a pipe [ ASAN ] test/cli/run/require-cache.test.ts [ LEAK ] # files transpiled and loaded don't leak file paths > via require() +[ ASAN ] test/js/bun/io/bun-write-leak.test.ts [ LEAK ] # Bun.write should not leak the output data # Windows-only gaps in named-pipe / socket teardown for ported Node net tests # (these pass on Linux and macOS): half-close (FIN) handling on named pipes, @@ -104,6 +143,10 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # server.close() (via `await using`) waits forever. The assertion itself passes; # only the teardown hangs. Passes on Linux and macOS. +# The 10 MB socket write in this test is an order of magnitude slower under +# AddressSanitizer and exceeds the per-test timeout; it passes on regular builds. +[ ASAN ] test/js/node/test/parallel/test-net-error-twice.js [ SKIP ] # ASAN-instrumented 10 MB write exceeds the timeout + # The localAddress/localPort bind-before-connect and the SO_ERROR read on a # connecting socket that was reset during establishment are implemented in the # POSIX (kqueue/epoll + BSD socket) connect path; Windows connects through @@ -115,4 +158,23 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # primary distributing accepted connections; the upstream test's expectations # only hold on the distributing model. Passes on macOS, Windows and FreeBSD. [ LINUX ] test/js/node/test/sequential/test-net-listen-shared-ports.js [ FAIL ] # SO_REUSEPORT shared-listener semantics on Linux -[ DARWIN ] test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts [ FLAKY ] # connect-during-abort race depends on host routing of 192.0.2.1; darwin CI agents return an immediate routing error \ No newline at end of file + +# Pre-existing on main (flagged [pre-existing] by ci:errors, e.g. build 61989 on +# darwin-14-aarch64 and build 62204 on darwin-26-aarch64, both at the +# `expect(e.name).toBe("TimeoutError")` assertions). The two connect-race cases +# assume connect() to TEST-NET-1 (192.0.2.1) stays in EINPROGRESS so the +# AbortSignal.timeout wins; that only holds when the host has a default route that +# silently blackholes the SYN. The darwin CI agents have no route to 192.0.2.1 and +# return an immediate ENETUNREACH/EHOSTUNREACH, so the connect error wins the race +# and fetch rejects with a generic "Error" instead of "TimeoutError" (verified: a +# fast-failing connect to 127.0.0.1:1 reproduces the same connect-error-beats-timeout +# outcome locally — correct behavior, not a bun abort bug). Consistently fails on +# darwin-26 (no route), intermittently on darwin-14 (routing varies per agent). +# Quarantined on darwin; still runs on Linux/Windows where the route blackholes +# reliably. Follow-up: rewrite without the external-IP dependence (repo rule forbids +# contacting external hosts) using a deterministic local hanging connect. +[ DARWIN ] test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts [ FLAKY ] # connect-during-abort race depends on host routing of 192.0.2.1; darwin CI agents return an immediate routing error + + +# node:http servers cannot listen on Windows named pipes yet (ENOENT from +# uv_pipe bind), so common.PIPE-based http tests cannot run there. From bc8b131e1506f93e4ba4639ab1ac8a0bdc5181b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:26:24 +0000 Subject: [PATCH 17/82] ci: keep the binary size allowance on the stack tip [allow size] From 95e093389019fceccc9b095ffb0468908b63fa09 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 21:24:17 +0000 Subject: [PATCH 18/82] dns: throw for falsy hostnames like node Node v26.3.0 removed the DEP0118 warn-and-succeed path: dns.lookup throws ERR_INVALID_ARG_VALUE synchronously without invoking the callback, and the promises API rejects with the same error. The old branch invoked the user callback synchronously and unguarded, so a throwing callback escaped out of lookup() itself. Re-vendor test-dns-lookup.js from v26.3.0 (the copy predated this behavior) and delete the orphaned invalidHostname helper. [allow size] --- src/js/node/dns.ts | 35 ++++--------------- test/js/node/dns/node-dns.test.js | 31 +++++++++++----- test/js/node/test/parallel/test-dns-lookup.js | 33 ++++++++--------- 3 files changed, 43 insertions(+), 56 deletions(-) diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index 7699b1d2b56c..1641512414ea 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 }; @@ -300,13 +287,9 @@ function lookup(hostname, options, callback) { validateLookupOptions(options); if (!hostname) { - invalidHostname(hostname); - if (options.all) { - callback(null, []); - } else { - callback(null, null, 4); - } - return; + // Node v26.3.0 throws synchronously without invoking the callback + // (lib/dns.js lookup); the old warn-and-succeed branch predates that. + throw $ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string"); } const family = isIP(hostname); @@ -751,15 +734,9 @@ const promises = { validateLookupOptions(options); if (!hostname) { - invalidHostname(hostname); - return Promise.$resolve( - options.all - ? [] - : { - address: null, - family: 4, - }, - ); + // Node v26.3.0's promises lookup is an async function, so the same + // ERR_INVALID_ARG_VALUE surfaces as a rejection here. + return Promise.$reject($ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string")); } const family = isIP(hostname); diff --git a/test/js/node/dns/node-dns.test.js b/test/js/node/dns/node-dns.test.js index 132ad99eb470..cb6202479dc3 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 { isWindows } from "harness"; import * as dns from "node:dns"; import * as dns_promises from "node:dns/promises"; @@ -505,13 +505,28 @@ 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); +// Node v26.3.0 removed the DEP0118 warn-and-succeed path: every falsy +// hostname throws synchronously without invoking the callback, and the +// promises API rejects with the same error. +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/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()); From c7d613cf805fc1a09bc08f5f43296ae03a82c4cb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:26:10 +0000 Subject: [PATCH 19/82] [autofix.ci] apply automated fixes --- test/js/node/dns/node-dns.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/node/dns/node-dns.test.js b/test/js/node/dns/node-dns.test.js index cb6202479dc3..39ac09655643 100644 --- a/test/js/node/dns/node-dns.test.js +++ b/test/js/node/dns/node-dns.test.js @@ -524,7 +524,12 @@ describe("lookup rejects falsy hostnames", () => { 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({ + 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 ''", }); From 7df12435cbc6e7fa32136b07fc77e8f2f6cff5f9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:50:09 +0000 Subject: [PATCH 20/82] test: sync test-c-ares.js and test-dns.js falsy-hostname cases to v26.3.0 [allow size] 95e0933 changed dns.lookup(falsy) to throw ERR_INVALID_ARG_VALUE to match Node v26.3.0, and re-vendored test-dns-lookup.js. These two vendored tests still expected the pre-DEP0118-removal warn-and-succeed path, so build 78883 went red on 4 lanes. Bring just the falsy-hostname sections in line with nodejs/node@v26.3.0; the Bun-specific message regex adaptations and the commented-out resolveAny case stay. --- test/js/node/test/parallel/test-c-ares.js | 13 +-- test/js/node/test/parallel/test-dns.js | 135 +++++++++++++++------- 2 files changed, 98 insertions(+), 50 deletions(-) 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.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()); { From 9d0cafa03facb2ec5e6767c88e8e198fb38dc8ac Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 21:55:56 +0000 Subject: [PATCH 21/82] test: drop the stale falsey-values comment upstream removed [allow size] --- test/js/node/test/parallel/test-dns.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/node/test/parallel/test-dns.js b/test/js/node/test/parallel/test-dns.js index efd6232c2d07..f38995342949 100644 --- a/test/js/node/test/parallel/test-dns.js +++ b/test/js/node/test/parallel/test-dns.js @@ -189,7 +189,6 @@ assert.deepStrictEqual(dns.getServers(), []); assert.throws(() => dnsPromises.lookup(common.mustNotCall()), errorReg); } -// dns.lookup should accept falsey values { ['', null, undefined, 0, NaN].forEach(async (value) => { await assert.rejects(dnsPromises.lookup(value), { From d1d83518b2e5b62eef5b23004c7962e2fbfb0d8e Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 21:56:25 +0000 Subject: [PATCH 22/82] test: restore the falsey-values comment; it is present upstream [allow size] --- test/js/node/test/parallel/test-dns.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/js/node/test/parallel/test-dns.js b/test/js/node/test/parallel/test-dns.js index f38995342949..efd6232c2d07 100644 --- a/test/js/node/test/parallel/test-dns.js +++ b/test/js/node/test/parallel/test-dns.js @@ -189,6 +189,7 @@ assert.deepStrictEqual(dns.getServers(), []); assert.throws(() => dnsPromises.lookup(common.mustNotCall()), errorReg); } +// dns.lookup should accept falsey values { ['', null, undefined, 0, NaN].forEach(async (value) => { await assert.rejects(dnsPromises.lookup(value), { From a952f2f749187670322edf8b942329467f08ef60 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 23 Jul 2026 22:11:34 +0000 Subject: [PATCH 23/82] process: exit on a fatal uncaught exception without another loop turn Node's fatal path prints the error, runs 'exit' listeners, and exits 1; already-queued I/O completions, timers, immediates, later ticks, and beforeExit never run. Bun returned to the event loop and drained it to natural completion first. Take the existing hard-exit branch on the main thread whenever watch/hot mode is off, instead of only during the beforeExit/exit wind-down: handled uncaughtException, domains, workers (process_exit returns there), and watch-mode reload keep their paths. [allow size] --- src/jsc/VirtualMachine.rs | 16 +++++---- test/js/node/process/process.test.js | 52 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 65a27aa1fb4f..d812ed9e6035 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1456,12 +1456,16 @@ impl VirtualMachine { substitute }; if !handled { - // `beforeExit` has already been dispatched, so the run is winding - // down and there is no loop turn left to defer to: print the error - // and exit, like node's fatal-exception path. Main thread only: - // process_exit() RETURNS on a worker, so the panic would fire; a - // worker falls through and exits 1 below (e.g. a beforeExit throw). - if self.exit_on_uncaught_exception && self.is_main_thread() { + // Node's fatal path exits without another loop turn: already + // queued I/O completions, timers, immediates, and later ticks + // never run — only 'exit' listeners do (via process_exit). + // Steady-state main-thread throws take it directly; the + // wind-down flag covers the beforeExit/exit phases. Watch/hot + // mode keeps the process alive for reload instead (same policy + // as the entry-point rejection path in run_command), and + // process_exit() RETURNS on a worker so a worker falls through + // and routes the error to its parent below. + if (self.exit_on_uncaught_exception || self.hot_reload == 0) && self.is_main_thread() { self.run_error_handler(err, None); // `process_exit` emits `exit`, re-entering here if a listener // throws. No handler is running, so drop the recursion guard or diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 76f33adac605..fe1393c4b4a3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1932,3 +1932,55 @@ it("proxy env vars assigned at runtime propagate to spawned children via {...pro const got = JSON.parse(child.stdout.toString().trim()); expect(got).toEqual({ HTTP_PROXY: "http://x:8080", HTTPS_PROXY: "http://y:8080", NO_PROXY: "z" }); }); + +it("a fatal uncaught exception exits before already-queued work runs", async () => { + // Node's fatal path: print the error, run 'exit' listeners, exit 1 - + // already-queued I/O completions, timers, immediates, later ticks, and + // beforeExit never run. + 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); +}); From 5f70edc6ec9a389a307f72fbc597101773290a78 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:27:09 +0000 Subject: [PATCH 24/82] test: use proc.stdout.text() directly in runScript, matching file convention [allow size] --- test/js/node/fs/fs.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 753abcb3efff..7b6fe911998f 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5613,11 +5613,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { async function runScript(source: string) { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", source], 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, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // stderr is returned (not asserted) so a failing case can show the // child's stack trace; debug builds emit benign startup noise there. return { stdout: stdout.trim(), stderr, exitCode }; From 1b89dfdb7176bae7a083d786dc3176139d8e1ebf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:55:06 +0000 Subject: [PATCH 25/82] Revert "process: exit on a fatal uncaught exception without another loop turn" [allow size] This reverts commit a952f2f749187670322edf8b942329467f08ef60. reportError() (the Web API) routes through vm.uncaught_exception via Bun__reportError, so the widened hard-exit condition made the first reportError() call terminate the process. The same early exit also cut off the error printer before its trailing diagnostics (the "note: missing sourcemaps" line, the final stack frame, the version footer), turning build 78942 red on five unrelated test files: test/js/bun/util/reportError.test.ts test/js/bun/test/stack.test.ts test/bundler/bundler_bun.test.ts test/js/bun/typescript/type-export.test.ts test/js/node/test/parallel/test-util-callbackify.js The node-matching fatal-exit semantics are sound for a real uncaught throw, but the implementation needs to distinguish that from reportError and from run_error_handler output that follows the error line; that is separate work from this PR's callback-throw routing. --- src/jsc/VirtualMachine.rs | 16 ++++----- test/js/node/process/process.test.js | 52 ---------------------------- 2 files changed, 6 insertions(+), 62 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d812ed9e6035..65a27aa1fb4f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1456,16 +1456,12 @@ impl VirtualMachine { substitute }; if !handled { - // Node's fatal path exits without another loop turn: already - // queued I/O completions, timers, immediates, and later ticks - // never run — only 'exit' listeners do (via process_exit). - // Steady-state main-thread throws take it directly; the - // wind-down flag covers the beforeExit/exit phases. Watch/hot - // mode keeps the process alive for reload instead (same policy - // as the entry-point rejection path in run_command), and - // process_exit() RETURNS on a worker so a worker falls through - // and routes the error to its parent below. - if (self.exit_on_uncaught_exception || self.hot_reload == 0) && self.is_main_thread() { + // `beforeExit` has already been dispatched, so the run is winding + // down and there is no loop turn left to defer to: print the error + // and exit, like node's fatal-exception path. Main thread only: + // process_exit() RETURNS on a worker, so the panic would fire; a + // worker falls through and exits 1 below (e.g. a beforeExit throw). + if self.exit_on_uncaught_exception && self.is_main_thread() { self.run_error_handler(err, None); // `process_exit` emits `exit`, re-entering here if a listener // throws. No handler is running, so drop the recursion guard or diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index fe1393c4b4a3..76f33adac605 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1932,55 +1932,3 @@ it("proxy env vars assigned at runtime propagate to spawned children via {...pro const got = JSON.parse(child.stdout.toString().trim()); expect(got).toEqual({ HTTP_PROXY: "http://x:8080", HTTPS_PROXY: "http://y:8080", NO_PROXY: "z" }); }); - -it("a fatal uncaught exception exits before already-queued work runs", async () => { - // Node's fatal path: print the error, run 'exit' listeners, exit 1 - - // already-queued I/O completions, timers, immediates, later ticks, and - // beforeExit never run. - 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); -}); From 4b66a479d52caa645ae4b14d1217d8e72c7d9fab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:17:28 +0000 Subject: [PATCH 26/82] ci: retrigger [allow size] From 3fb0d77261164bd017576bf8e271c6882ec435f2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 00:02:06 +0000 Subject: [PATCH 27/82] process: exit on a fatal uncaught exception without another loop turn Re-land with the regression fixed: the hard exit now applies only to steady-state main-thread throws. Entry-point rejections keep their run_command reporter (exit_with_unhandled_note prints the pinned renderings and the missing-sourcemaps note), watch/hot mode keeps the process alive for reload, workers route the error to their parent, and the beforeExit/exit wind-down branch is unchanged. Steady-state fatals print through the same reporter the drain path used, then run 'exit' listeners and exit 1 - already-queued I/O completions, timers, immediates, and later ticks never run, like node. [allow size] --- src/jsc/VirtualMachine.rs | 22 ++++++++++++ test/js/node/process/process.test.js | 52 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 65a27aa1fb4f..509af898cfee 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1471,6 +1471,28 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } + // Node's fatal path exits without another loop turn: already + // queued I/O completions, timers, immediates, and later ticks + // never run — only 'exit' listeners do (via process_exit). Print + // through the same reporter the drain path used, then exit. + // Entry-point rejections keep their run_command owner (it already + // exits promptly via exit_with_unhandled_note), watch/hot mode + // keeps the process alive for reload, and a worker falls through + // to route the error to its parent. + if self.is_main_thread() + && self.hot_reload == 0 + && origin != UncaughtExceptionOrigin::EntryPointRejection + { + self.unhandled_error_counter += 1; + self.exit_handler.exit_code = 1; + (self.on_unhandled_rejection)(self, global_object, err); + // See the recursion-guard note above: drop it before + // process_exit emits 'exit'. + self.is_handling_uncaught_exception = false; + // SAFETY: see above. + unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; + panic!("made it past process.exit()"); + } // TODO maybe we want a separate code path for uncaught exceptions // NOTE: --abort-on-uncaught-exception is handled inside // Bun__handleUncaughtException (before any monitor/listeners diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 76f33adac605..fe1393c4b4a3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1932,3 +1932,55 @@ it("proxy env vars assigned at runtime propagate to spawned children via {...pro const got = JSON.parse(child.stdout.toString().trim()); expect(got).toEqual({ HTTP_PROXY: "http://x:8080", HTTPS_PROXY: "http://y:8080", NO_PROXY: "z" }); }); + +it("a fatal uncaught exception exits before already-queued work runs", async () => { + // Node's fatal path: print the error, run 'exit' listeners, exit 1 - + // already-queued I/O completions, timers, immediates, later ticks, and + // beforeExit never run. + 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); +}); From 72e6cb9eab9efc5178f07a793701e72a2bfc4f9b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:35:53 +0000 Subject: [PATCH 28/82] Revert "process: exit on a fatal uncaught exception without another loop turn" (re-land) [allow size] This reverts commit 3fb0d77261164bd017576bf8e271c6882ec435f2. The re-land gated off EntryPointRejection but reportError() and the version footer are still broken (build 79040): reportError.test.ts: Bun__reportError passes Origin::Exception, which the new branch does not exclude, so the first reportError() call still hard-exits; the fixture expects all 18 calls to print. test-util-callbackify.js: stderr is 9 lines instead of 10. The callbackify1.js throw is Origin::Exception (not EntryPointRejection) so process_exit runs and the unhandled_error_bun_version_string footer from exit_with_unhandled_note never prints. The review bot also flagged bun repl (no hot_reload, no uncaughtException listener, so any async throw terminates the session) and Bun.serve({websocket})/Bun.connect/Bun.listen (handler throws with no error: handler now take down the process while the HTTP fetch path keeps serving). Fixing this properly means distinguishing reportError() from a real uncaught throw (probably a new UncaughtExceptionOrigin variant synced to the C++ enum and its origin-string switch at BunProcess.cpp:1346), printing the sourcemap note + version footer on the hard-exit path, and deciding whether Bun-native server/repl contexts should crash or print-and-continue. That is its own PR; #34661 is the fs/dns/crypto callback-throw routing. --- src/jsc/VirtualMachine.rs | 22 ------------ test/js/node/process/process.test.js | 52 ---------------------------- 2 files changed, 74 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 509af898cfee..65a27aa1fb4f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1471,28 +1471,6 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } - // Node's fatal path exits without another loop turn: already - // queued I/O completions, timers, immediates, and later ticks - // never run — only 'exit' listeners do (via process_exit). Print - // through the same reporter the drain path used, then exit. - // Entry-point rejections keep their run_command owner (it already - // exits promptly via exit_with_unhandled_note), watch/hot mode - // keeps the process alive for reload, and a worker falls through - // to route the error to its parent. - if self.is_main_thread() - && self.hot_reload == 0 - && origin != UncaughtExceptionOrigin::EntryPointRejection - { - self.unhandled_error_counter += 1; - self.exit_handler.exit_code = 1; - (self.on_unhandled_rejection)(self, global_object, err); - // See the recursion-guard note above: drop it before - // process_exit emits 'exit'. - self.is_handling_uncaught_exception = false; - // SAFETY: see above. - unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; - panic!("made it past process.exit()"); - } // TODO maybe we want a separate code path for uncaught exceptions // NOTE: --abort-on-uncaught-exception is handled inside // Bun__handleUncaughtException (before any monitor/listeners diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index fe1393c4b4a3..76f33adac605 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1932,55 +1932,3 @@ it("proxy env vars assigned at runtime propagate to spawned children via {...pro const got = JSON.parse(child.stdout.toString().trim()); expect(got).toEqual({ HTTP_PROXY: "http://x:8080", HTTPS_PROXY: "http://y:8080", NO_PROXY: "z" }); }); - -it("a fatal uncaught exception exits before already-queued work runs", async () => { - // Node's fatal path: print the error, run 'exit' listeners, exit 1 - - // already-queued I/O completions, timers, immediates, later ticks, and - // beforeExit never run. - 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); -}); From 75b166f1408d95b441f2fb11c08fe5e51b6e4e74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:15:31 +0000 Subject: [PATCH 29/82] crypto: keep the hoisted pbkdf2 handlers with .bind, per review [allow size] --- src/js/node/crypto.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 29d4cb1f2327..078f59a52c55 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -167,16 +167,21 @@ function pbkdf2(password, salt, iterations, keylen, digest, callback) { if (callback) { // Guarded so a throw inside the callback is an uncaughtException, as in node. const cb = guardCallback(callback); - promise.then( - result => cb(null, result), - err => cb(err), - ); + promise.then(onPbkdf2Resolved.bind(cb), onPbkdf2Rejected.bind(cb)); return; } promise.then(() => {}); } +// Hoisted `.then` handlers for pbkdf2; `this` is the guarded callback. +function onPbkdf2Resolved(result) { + this(null, result); +} +function onPbkdf2Rejected(err) { + this(err); +} + crypto_exports.pbkdf2 = pbkdf2; crypto_exports.pbkdf2Sync = pbkdf2Sync; From 3c6ba7646259b78c696b49151e6c88aa9ff75ea1 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 01:28:26 +0000 Subject: [PATCH 30/82] process: exit on a fatal uncaught exception without another loop turn Third landing, with both prior regressions fixed and pinned by tests: entry-point rejections keep the run_command reporter (stack renderings and the missing-sourcemaps note, build 78942), and Bun__reportError keeps the process alive (reportError fixture, build 79040) via a fatal_exit split on uncaught_exception. Steady-state main-thread throws print through the drain path's reporter, run 'exit' listeners, and exit 1 - queued I/O, timers, immediates, and later ticks never run, like node. Watch/hot, workers, handled listeners, domains, and the beforeExit wind-down keep their paths. [allow size] --- src/jsc/VirtualMachine.rs | 40 +++++++++++++++++++++ src/runtime/api/BunObject.rs | 6 +--- test/js/node/process/process.test.js | 52 ++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 65a27aa1fb4f..d373b41f36a1 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1410,6 +1410,23 @@ impl VirtualMachine { global_object: &JSGlobalObject, err: JSValue, origin: UncaughtExceptionOrigin, + ) -> bool { + self.uncaught_exception_impl(global_object, err, origin, true) + } + + /// Web `reportError()` (and the debug/bootstrap logging that shares it): + /// print like an uncaught exception — listeners and exit code included — + /// but keep the process running. + pub fn report_error_keep_alive(&mut self, global_object: &JSGlobalObject, err: JSValue) -> bool { + self.uncaught_exception_impl(global_object, err, UncaughtExceptionOrigin::Exception, false) + } + + fn uncaught_exception_impl( + &mut self, + global_object: &JSGlobalObject, + err: JSValue, + origin: UncaughtExceptionOrigin, + fatal_exit: bool, ) -> bool { if self.is_shutting_down() { return true; @@ -1471,6 +1488,29 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } + // Node's fatal path exits without another loop turn: already + // queued I/O completions, timers, immediates, and later ticks + // never run — only 'exit' listeners do (via process_exit). Print + // through the same reporter the drain path used, then exit. + // Entry-point rejections keep their run_command owner (it already + // exits promptly via exit_with_unhandled_note), watch/hot mode + // keeps the process alive for reload, and a worker falls through + // to route the error to its parent. + if fatal_exit + && self.is_main_thread() + && self.hot_reload == 0 + && origin != UncaughtExceptionOrigin::EntryPointRejection + { + self.unhandled_error_counter += 1; + self.exit_handler.exit_code = 1; + (self.on_unhandled_rejection)(self, global_object, err); + // See the recursion-guard note above: drop it before + // process_exit emits 'exit'. + self.is_handling_uncaught_exception = false; + // SAFETY: see above. + unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; + panic!("made it past process.exit()"); + } // TODO maybe we want a separate code path for uncaught exceptions // NOTE: --abort-on-uncaught-exception is handled inside // Bun__handleUncaughtException (before any monitor/listeners diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 058b9ed0a9f5..7e9625ebf7ff 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2327,11 +2327,7 @@ pub mod environment_variables { pub(crate) extern "C" fn Bun__reportError(global_object: &JSGlobalObject, err: JSValue) { // SAFETY: VirtualMachine::get() returns the thread-local VM raw pointer. let vm = jsc::virtual_machine::VirtualMachine::get().as_mut(); - let _ = vm.uncaught_exception( - global_object, - err, - bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, - ); + let _ = vm.report_error_keep_alive(global_object, err); } /// Shared argument prefix for `Bun.{gzip,gunzip,deflate,inflate}Sync` and diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 76f33adac605..fe1393c4b4a3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1932,3 +1932,55 @@ it("proxy env vars assigned at runtime propagate to spawned children via {...pro const got = JSON.parse(child.stdout.toString().trim()); expect(got).toEqual({ HTTP_PROXY: "http://x:8080", HTTPS_PROXY: "http://y:8080", NO_PROXY: "z" }); }); + +it("a fatal uncaught exception exits before already-queued work runs", async () => { + // Node's fatal path: print the error, run 'exit' listeners, exit 1 - + // already-queued I/O completions, timers, immediates, later ticks, and + // beforeExit never run. + 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); +}); From 1d5cc9da37564a458becc693d793e833c7b9913d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:31:23 +0000 Subject: [PATCH 31/82] [autofix.ci] apply automated fixes --- src/jsc/VirtualMachine.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d373b41f36a1..0130198a1090 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1417,8 +1417,17 @@ impl VirtualMachine { /// Web `reportError()` (and the debug/bootstrap logging that shares it): /// print like an uncaught exception — listeners and exit code included — /// but keep the process running. - pub fn report_error_keep_alive(&mut self, global_object: &JSGlobalObject, err: JSValue) -> bool { - self.uncaught_exception_impl(global_object, err, UncaughtExceptionOrigin::Exception, false) + pub fn report_error_keep_alive( + &mut self, + global_object: &JSGlobalObject, + err: JSValue, + ) -> bool { + self.uncaught_exception_impl( + global_object, + err, + UncaughtExceptionOrigin::Exception, + false, + ) } fn uncaught_exception_impl( From 87d36b5dc7a2cc7ba725d3cbd24403b9858f4dfd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:27:40 +0000 Subject: [PATCH 32/82] runtime: keep Bun-native handler errors print-and-continue [allow size] Route Bun.serve({websocket}), Bun.connect/Bun.listen, Bun.udpSocket, Bun.Cron, and the Bun.serve error:/rejected-fetch path through report_error_keep_alive so the fatal-exit change only applies to Node-compat paths (nextTick drain, fs/dns/crypto callbacks, N-API, node:http). This matches the Bun.serve HTTP fetch path, which already reports via on_unhandled_rejection and keeps serving, and keeps the pre-existing contract for these Bun-native APIs. report_error_keep_alive now takes the origin so server/mod.rs can keep the Rejection/Exception distinction listeners observe. Pins the behavior with a spawned-child Bun.listen test: two connections, the data handler throws with no error: handler and no uncaughtException listener, and both connections reach the handler before the process exits 1. --- src/jsc/VirtualMachine.rs | 18 ++++---- src/runtime/api/BunObject.rs | 6 ++- src/runtime/api/cron.rs | 2 +- src/runtime/server/WebSocketServerContext.rs | 2 +- src/runtime/server/mod.rs | 2 +- src/runtime/socket/Handlers.rs | 2 +- src/runtime/socket/udp_socket.rs | 2 +- test/js/node/process/process.test.js | 46 ++++++++++++++++++++ 8 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5505d4f3167d..12cef82e2a1c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1378,20 +1378,20 @@ impl VirtualMachine { self.uncaught_exception_impl(global_object, err, origin, true) } - /// Web `reportError()` (and the debug/bootstrap logging that shares it): - /// print like an uncaught exception — listeners and exit code included — - /// but keep the process running. + /// Print like an uncaught exception — listeners and exit code included — + /// but keep the process running. For Web `reportError()` and Bun-native + /// long-running handlers (`Bun.serve` websocket, `Bun.connect`/`Bun.listen`, + /// `Bun.udpSocket`, `Bun.Cron`) whose pre-existing contract is print-and- + /// continue; matches the `Bun.serve` HTTP fetch path, which calls + /// `on_unhandled_rejection` directly. Node-compat paths (nextTick drain, + /// N-API, `node:http`) stay on `uncaught_exception` and hard-exit. pub fn report_error_keep_alive( &mut self, global_object: &JSGlobalObject, err: JSValue, + origin: UncaughtExceptionOrigin, ) -> bool { - self.uncaught_exception_impl( - global_object, - err, - UncaughtExceptionOrigin::Exception, - false, - ) + self.uncaught_exception_impl(global_object, err, origin, false) } fn uncaught_exception_impl( diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index cf9cb8474272..1fa1fbc74a14 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2255,7 +2255,11 @@ pub mod environment_variables { pub(crate) extern "C" fn Bun__reportError(global_object: &JSGlobalObject, err: JSValue) { // SAFETY: VirtualMachine::get() returns the thread-local VM raw pointer. let vm = jsc::virtual_machine::VirtualMachine::get().as_mut(); - let _ = vm.report_error_keep_alive(global_object, err); + let _ = vm.report_error_keep_alive( + global_object, + err, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); } /// Shared argument prefix for `Bun.{gzip,gunzip,deflate,inflate}Sync` and diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 7935a2e4b7e3..1e312fe0992e 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1774,7 +1774,7 @@ impl CronJob { let global_ref = vm.global(); // SAFETY: single JS thread; `&mut` derived via the thread-local // raw pointer (avoids `&T` → `&mut T` provenance laundering). - let _ = VirtualMachine::get().as_mut().uncaught_exception( + let _ = VirtualMachine::get().as_mut().report_error_keep_alive( global_ref, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index 0497ca3bb058..c8605317a633 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -103,7 +103,7 @@ impl Handler { let mut vm_ref = self.vm; // SAFETY: process-lifetime singleton; sole `&mut` on the JS thread. let vm_mut = unsafe { vm_ref.get_mut() }; - let _ = vm_mut.uncaught_exception( + let _ = vm_mut.report_error_keep_alive( global_object, error_value, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 944ac90a27ee..c6b90e841bbe 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1365,7 +1365,7 @@ impl NewServer { // --abort-on-uncaught-exception this aborts before // domain/capture like Node's triggerUncaughtException // binding — deliberate: Bun.serve has no Node equivalent. - let _ = unsafe { &mut *vm }.uncaught_exception( + let _ = unsafe { &mut *vm }.report_error_keep_alive( global, *err, if matches!(http_result, HttpResult::Rejection(_)) { diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index f3b559659446..0378251823dc 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -291,7 +291,7 @@ impl Handlers { if on_error.is_empty() { // SAFETY: `bun_vm()` is non-null for a Bun-owned global; single JS thread. - let _ = global_object.bun_vm().as_mut().uncaught_exception( + let _ = global_object.bun_vm().as_mut().report_error_keep_alive( &global_object, args[1], bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 5789ae2e6053..8ccf041c653b 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -801,7 +801,7 @@ impl UDPSocket { return; } if callback.is_empty_or_undefined_or_null() { - let _ = vm.uncaught_exception( + let _ = vm.report_error_keep_alive( global_this, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index dff65344c345..3c3c89f362ce 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2030,3 +2030,49 @@ it("a handled uncaughtException keeps the event loop running", async () => { 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 () => { + // Bun-native long-running handlers keep the pre-existing print-and-continue + // contract (matching the Bun.serve HTTP fetch path, which reports via + // on_unhandled_rejection and keeps serving); only Node-compat paths take + // the fatal exit. + 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]); + // Both connections reached the handler: the first throw did not exit. + expect(stdout.trim().split(/\r?\n/)).toEqual(["DATA-HANDLER-RAN:1", "DATA-HANDLER-RAN:2"]); + expect(stderr).toContain("handler-boom"); + // No error: handler and no uncaughtException listener, so the error is + // reported (exit code 1), but only after the loop drained naturally. + expect(exitCode).toBe(1); +}); From 6deeb745ca90bb175d98d35c59ac0d964d679d1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:10:01 +0000 Subject: [PATCH 33/82] runtime: keep-alive for the error-handler-throws branch too [allow size] 87d36b5 only fixed the no-error:-handler branch in Handlers.rs / WebSocketServerContext.rs / udp_socket.rs; the sibling branch where the user's error: handler itself threw still routed through report_active_exception_as_unhandled -> uncaught_exception -> fatal_exit=true. Add report_active_exception_keep_alive on JSGlobalObject and route those three branches through it. Pinned with a second Bun.listen test where the error: handler throws and both connections still reach data(). --- src/jsc/JSGlobalObject.rs | 16 ++++++++ src/runtime/server/WebSocketServerContext.rs | 2 +- src/runtime/socket/Handlers.rs | 2 +- src/runtime/socket/udp_socket.rs | 2 +- test/js/node/process/process.test.js | 42 ++++++++++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 41e08142ccda..21a26ead6383 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1069,6 +1069,22 @@ impl JSGlobalObject { } } + /// Same reporting as `report_active_exception_as_unhandled`, but routes + /// through `report_error_keep_alive` so the process is not hard-exited. + /// For Bun-native long-running handlers (`Bun.serve` websocket, + /// `Bun.connect`/`Bun.listen`, `Bun.udpSocket`) whose `error:` handler + /// itself threw: the pre-existing contract is print-and-continue. + pub fn report_active_exception_keep_alive(&self, err: JsError) { + let exception = self.take_exception(err); + if !exception.is_termination_exception() { + let _ = self.bun_vm().as_mut().report_error_keep_alive( + self, + exception, + crate::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + } + } + pub fn vm(&self) -> &VM { // JSC guarantees the VM outlives the global object; `VM` is an opaque // ZST handle so the deref is the centralised `opaque_ref` proof. diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index c8605317a633..cddcfe89c6de 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -90,7 +90,7 @@ impl Handler { if !on_error.is_empty_or_undefined_or_null() { let _ = on_error .call(global_object, JSValue::UNDEFINED, &[error_value]) - .map_err(|err| self.global_object.report_active_exception_as_unhandled(err)); + .map_err(|err| self.global_object.report_active_exception_keep_alive(err)); return; } diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 0378251823dc..646c2fbe2484 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -300,7 +300,7 @@ impl Handlers { } if let Err(e) = on_error.call(&global_object, this_value, args) { - global_object.report_active_exception_as_unhandled(e); + global_object.report_active_exception_keep_alive(e); } true diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 8ccf041c653b..b13f024df99b 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -813,7 +813,7 @@ impl UDPSocket { event_loop.enter(); let result = callback.call(global_this, this_value, &[err.to_error().unwrap_or(err)]); if let Err(e) = result { - global_this.report_active_exception_as_unhandled(e); + global_this.report_active_exception_keep_alive(e); } event_loop.exit(); } diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 3c3c89f362ce..97c2a06c135b 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2076,3 +2076,45 @@ it("a throwing Bun.listen data handler with no error: handler keeps the server a // reported (exit code 1), but only after the loop drained naturally. expect(exitCode).toBe(1); }); + +it("a Bun.listen error: handler that itself throws keeps the server alive", async () => { + // The sibling branch of the case above: the user supplied an error: handler + // and that handler threw. Same print-and-continue contract. + 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); +}); From 834fc20774492e64d367ab5e8e0bb20da9f9f6aa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:29:41 +0000 Subject: [PATCH 34/82] [autofix.ci] apply automated fixes --- src/runtime/server/server_body.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 831f33e12a01..560b52bc0269 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1985,9 +1985,11 @@ where // A request that does not name "websocket" in its |Upgrade| token list, // or whose |Sec-WebSocket-Key| is not base64 of 16 bytes, is not a // WebSocket handshake; fall through so the caller's fetch() can respond. - if !upgrade_header.slice().split(|&c| c == b',').any(|t| { - strings::eql_case_insensitive_ascii(t.trim_ascii(), b"websocket", true) - }) { + if !upgrade_header + .slice() + .split(|&c| c == b',') + .any(|t| strings::eql_case_insensitive_ascii(t.trim_ascii(), b"websocket", true)) + { return Ok(JSValue::FALSE); } if !is_valid_sec_websocket_key(sec_websocket_key_str.slice()) { From 9c199eac05f2faf5448bb1f1a2520d6f8f4af3d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:55:16 +0000 Subject: [PATCH 35/82] runtime: invert fatal-exit to opt-in at Bun__reportUnhandledError only [allow size] The site-by-site keep-alive exemptions were open-ended: 17 direct uncaught_exception callers + 33 report_active_exception_as_unhandled + 35 EventLoop::run_callback callers all went from print-and-continue to hard-exit, and each round of review found more (Bun.spawn ipc, Bun.sql/redis onclose, bun repl, run_callback's Bun-native callers). Invert the default instead: - uncaught_exception() is the pre-existing print-and-continue contract every caller was written against; fatal_exit=false. - uncaught_exception_fatal() is the opt-in Node fatal path. - report_unhandled_error (Bun__reportUnhandledError) is the one opt-in site: it backs the nextTick drain, setTimeout/setInterval callbacks (NodeTimerObject), jsFunctionReportUncaughtException (guardCallback's route for fs/dns/crypto callback throws), napi_fatal_exception, node:events error with no listener, and JSC's reportUncaughtExceptionAtEventLoop; all Node-compat uncaught throws where the caller's task is dead. This drops report_error_keep_alive / report_active_exception_keep_alive and the per-site changes in WebSocketServerContext / Handlers / udp_socket / cron / server/mod / BunObject (all back to pre-PR code). Fixes the mod.rs:1368 node:http sync/async inconsistency (both arms now keep-alive, as pre-PR). Also emits 'exit' before the sourcemap note + version footer so the footer stays the last stderr line, matching exit_with_unhandled_note. The process.test.js fatal-exit cases and both Bun.listen keep-alive cases still pass; reportError / callbackify / bundler / stack / type-export / the callback-throw suite / the four domain tests all verified. --- src/jsc/JSGlobalObject.rs | 16 ------- src/jsc/VirtualMachine.rs | 44 ++++++++++++-------- src/jsc/virtual_machine_exports.rs | 8 +++- src/runtime/api/BunObject.rs | 2 +- src/runtime/api/cron.rs | 2 +- src/runtime/server/WebSocketServerContext.rs | 4 +- src/runtime/server/mod.rs | 2 +- src/runtime/socket/Handlers.rs | 4 +- src/runtime/socket/udp_socket.rs | 4 +- 9 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 21a26ead6383..41e08142ccda 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1069,22 +1069,6 @@ impl JSGlobalObject { } } - /// Same reporting as `report_active_exception_as_unhandled`, but routes - /// through `report_error_keep_alive` so the process is not hard-exited. - /// For Bun-native long-running handlers (`Bun.serve` websocket, - /// `Bun.connect`/`Bun.listen`, `Bun.udpSocket`) whose `error:` handler - /// itself threw: the pre-existing contract is print-and-continue. - pub fn report_active_exception_keep_alive(&self, err: JsError) { - let exception = self.take_exception(err); - if !exception.is_termination_exception() { - let _ = self.bun_vm().as_mut().report_error_keep_alive( - self, - exception, - crate::virtual_machine::UncaughtExceptionOrigin::Exception, - ); - } - } - pub fn vm(&self) -> &VM { // JSC guarantees the VM outlives the global object; `VM` is an opaque // ZST handle so the deref is the centralised `opaque_ref` proof. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 028e472eceb0..b40d67f9d9b1 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1369,29 +1369,38 @@ impl VirtualMachine { bun_core::env_var::feature_flag::BUN_DESTRUCT_VM_ON_EXIT::get().unwrap_or(false) } + /// Fire `uncaughtException` listeners (via `Bun__handleUncaughtException`); + /// if none claim the error, print it and set `exit_code = 1`, then return. + /// The process keeps running — this is the pre-existing contract every + /// Bun-native call site was written against (`Bun.serve`, `Bun.listen`, + /// `Bun.spawn` ipc/onExit, `Bun.sql`/`Bun.redis` onclose, the shell, + /// `EventLoop::run_callback`, `reportError()`, ...). pub fn uncaught_exception( &mut self, global_object: &JSGlobalObject, err: JSValue, origin: UncaughtExceptionOrigin, ) -> bool { - self.uncaught_exception_impl(global_object, err, origin, true) + self.uncaught_exception_impl(global_object, err, origin, false) } - /// Print like an uncaught exception — listeners and exit code included — - /// but keep the process running. For Web `reportError()` and Bun-native - /// long-running handlers (`Bun.serve` websocket, `Bun.connect`/`Bun.listen`, - /// `Bun.udpSocket`, `Bun.Cron`) whose pre-existing contract is print-and- - /// continue; matches the `Bun.serve` HTTP fetch path, which calls - /// `on_unhandled_rejection` directly. Node-compat paths (nextTick drain, - /// N-API, `node:http`) stay on `uncaught_exception` and hard-exit. - pub fn report_error_keep_alive( + /// Node's fatal path: if no listener/domain/capture callback claims the + /// error, print it, emit `'exit'`, and `process.exit(1)` without another + /// loop turn (queued I/O, timers, immediates, later ticks never run). + /// Only for true Node-compat uncaught throws that reach + /// `Bun__reportUnhandledError` — the nextTick drain, setTimeout/ + /// setInterval callbacks, `jsFunctionReportUncaughtException` (what this + /// PR's `guardCallback` routes fs/dns/crypto callback throws to), N-API + /// `napi_fatal_exception`, `node:events` error with no listener, and JSC's + /// `reportUncaughtExceptionAtEventLoop`. Everything else stays on + /// `uncaught_exception` above. + pub fn uncaught_exception_fatal( &mut self, global_object: &JSGlobalObject, err: JSValue, origin: UncaughtExceptionOrigin, ) -> bool { - self.uncaught_exception_impl(global_object, err, origin, false) + self.uncaught_exception_impl(global_object, err, origin, true) } fn uncaught_exception_impl( @@ -1477,18 +1486,19 @@ impl VirtualMachine { self.unhandled_error_counter += 1; self.exit_handler.exit_code = 1; (self.on_unhandled_rejection)(self, global_object, err); - // Match run_command's exit_with_unhandled_note: the drain path - // would have printed the missing-sourcemaps note and the - // version footer after the error; process_exit below bypasses - // that owner, so emit them here. + // See the recursion-guard note above: drop it before on_exit + // emits 'exit' (a throwing 'exit' listener re-enters here). + self.is_handling_uncaught_exception = false; + // Mirror run_command's exit_with_unhandled_note order: emit + // 'exit' first (Process::m_isExiting makes the process_exit + // below skip its own emit), then print the sourcemap note and + // version footer so they stay the last stderr lines. + self.on_exit(); bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); bun_core::pretty_errorln!( "\n{}", bun_core::Global::unhandled_error_bun_version_string, ); - // See the recursion-guard note above: drop it before - // process_exit emits 'exit'. - self.is_handling_uncaught_exception = false; // SAFETY: see above. unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index d70e0fa36d22..4eb35efdfed4 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -118,7 +118,13 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValu crate::mark_binding!(); if !value.is_termination_exception() { - let _ = global.bun_vm().as_mut().uncaught_exception( + // This is the one place that opts into Node's fatal path. Callers are + // the nextTick drain, setTimeout/setInterval (NodeTimerObject.cpp), + // jsFunctionReportUncaughtException (guardCallback's routing for + // fs/dns/crypto callback throws), napi_fatal_exception, node:events + // error with no listener, and JSC's reportUncaughtExceptionAtEventLoop + // — all Node-compat uncaught throws where the caller's task is dead. + let _ = global.bun_vm().as_mut().uncaught_exception_fatal( global, value, crate::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 38861295513a..f60c69a01975 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2253,7 +2253,7 @@ pub mod environment_variables { pub(crate) extern "C" fn Bun__reportError(global_object: &JSGlobalObject, err: JSValue) { // SAFETY: VirtualMachine::get() returns the thread-local VM raw pointer. let vm = jsc::virtual_machine::VirtualMachine::get().as_mut(); - let _ = vm.report_error_keep_alive( + let _ = vm.uncaught_exception( global_object, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 1e312fe0992e..7935a2e4b7e3 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1774,7 +1774,7 @@ impl CronJob { let global_ref = vm.global(); // SAFETY: single JS thread; `&mut` derived via the thread-local // raw pointer (avoids `&T` → `&mut T` provenance laundering). - let _ = VirtualMachine::get().as_mut().report_error_keep_alive( + let _ = VirtualMachine::get().as_mut().uncaught_exception( global_ref, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index cddcfe89c6de..0497ca3bb058 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -90,7 +90,7 @@ impl Handler { if !on_error.is_empty_or_undefined_or_null() { let _ = on_error .call(global_object, JSValue::UNDEFINED, &[error_value]) - .map_err(|err| self.global_object.report_active_exception_keep_alive(err)); + .map_err(|err| self.global_object.report_active_exception_as_unhandled(err)); return; } @@ -103,7 +103,7 @@ impl Handler { let mut vm_ref = self.vm; // SAFETY: process-lifetime singleton; sole `&mut` on the JS thread. let vm_mut = unsafe { vm_ref.get_mut() }; - let _ = vm_mut.report_error_keep_alive( + let _ = vm_mut.uncaught_exception( global_object, error_value, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 85134fb16c1f..42589836b8e7 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1365,7 +1365,7 @@ impl NewServer { // --abort-on-uncaught-exception this aborts before // domain/capture like Node's triggerUncaughtException // binding — deliberate: Bun.serve has no Node equivalent. - let _ = unsafe { &mut *vm }.report_error_keep_alive( + let _ = unsafe { &mut *vm }.uncaught_exception( global, *err, if matches!(http_result, HttpResult::Rejection(_)) { diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 646c2fbe2484..f3b559659446 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -291,7 +291,7 @@ impl Handlers { if on_error.is_empty() { // SAFETY: `bun_vm()` is non-null for a Bun-owned global; single JS thread. - let _ = global_object.bun_vm().as_mut().report_error_keep_alive( + let _ = global_object.bun_vm().as_mut().uncaught_exception( &global_object, args[1], bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, @@ -300,7 +300,7 @@ impl Handlers { } if let Err(e) = on_error.call(&global_object, this_value, args) { - global_object.report_active_exception_keep_alive(e); + global_object.report_active_exception_as_unhandled(e); } true diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index fa8be7f98c7c..d588f93f01d2 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -798,7 +798,7 @@ impl UDPSocket { return; } if callback.is_empty_or_undefined_or_null() { - let _ = vm.report_error_keep_alive( + let _ = vm.uncaught_exception( global_this, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, @@ -810,7 +810,7 @@ impl UDPSocket { event_loop.enter(); let result = callback.call(global_this, this_value, &[err.to_error().unwrap_or(err)]); if let Err(e) = result { - global_this.report_active_exception_keep_alive(e); + global_this.report_active_exception_as_unhandled(e); } event_loop.exit(); } From 12686ccd338673cd0ed035641b782fba6f3053d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:16:00 +0000 Subject: [PATCH 36/82] runtime: drop the explicit on_exit() before process_exit in the fatal path [allow size] process_exit already runs on_exit() internally; calling it here first double-ran the cleanup-hook loop and set is_shutting_down before process_exit's own on_exit pass. Keep the footer before process_exit and document the ordering divergence from exit_with_unhandled_note (cosmetic only) instead of claiming a match. --- src/jsc/VirtualMachine.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index b40d67f9d9b1..c8bb2a4c462d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1486,19 +1486,22 @@ impl VirtualMachine { self.unhandled_error_counter += 1; self.exit_handler.exit_code = 1; (self.on_unhandled_rejection)(self, global_object, err); - // See the recursion-guard note above: drop it before on_exit - // emits 'exit' (a throwing 'exit' listener re-enters here). - self.is_handling_uncaught_exception = false; - // Mirror run_command's exit_with_unhandled_note order: emit - // 'exit' first (Process::m_isExiting makes the process_exit - // below skip its own emit), then print the sourcemap note and - // version footer so they stay the last stderr lines. - self.on_exit(); + // The drain path would have printed the sourcemap note and + // version footer via run_command's exit_with_unhandled_note; + // process_exit below bypasses that owner, so emit them here. + // Ordering note: process_exit emits 'exit' after this, so an + // exit listener writing to stderr lands after the footer + // (exit_with_unhandled_note puts the footer last). Cosmetic + // only; re-emitting 'exit' here first would double-run + // on_exit's cleanup-hook loop. bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); bun_core::pretty_errorln!( "\n{}", bun_core::Global::unhandled_error_bun_version_string, ); + // See the recursion-guard note above: drop it before + // process_exit emits 'exit'. + self.is_handling_uncaught_exception = false; // SAFETY: see above. unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); From cd4394cee56c531c9488ec7f31a5b3c8a20f8297 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:57:40 +0000 Subject: [PATCH 37/82] repl: suppress the fatal uncaught path so async throws keep the session [allow size] bun repl's nextTick/timer drain routes through Bun__reportUnhandledError, which opts into the fatal exit under bun run. Add VirtualMachine::suppress_fatal_uncaught, set it in repl_command.rs, and gate the fatal-exit block on it so the prompt redraws (Node's REPL wraps evaluation in a domain for the same effect). Pinned in test/js/bun/repl/repl.test.ts: the session reaches 1+1 after both a nextTick and a setTimeout throw. --- src/jsc/VirtualMachine.rs | 7 +++++++ src/runtime/cli/repl_command.rs | 4 ++++ test/js/bun/repl/repl.test.ts | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index c8bb2a4c462d..0b7fcae9786c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -301,6 +301,12 @@ pub struct VirtualMachine { pub on_print_error_zig_exception_ctx: *mut c_void, pub is_handling_uncaught_exception: bool, pub exit_on_uncaught_exception: bool, + /// Set by `bun repl`: a Node-compat uncaught throw (nextTick/timer drain) + /// would otherwise take the fatal path and terminate the interactive + /// session. Node's own REPL wraps evaluation in a domain for the same + /// reason; here the flag keeps the `uncaught_exception_fatal` branch at + /// print-and-continue so the prompt redraws. + pub suppress_fatal_uncaught: bool, pub modules: crate::async_module::Queue, pub aggressive_garbage_collection: GCLevel, @@ -1479,6 +1485,7 @@ impl VirtualMachine { // keeps the process alive for reload, and a worker falls through // to route the error to its parent. if fatal_exit + && !self.suppress_fatal_uncaught && self.is_main_thread() && self.hot_reload == 0 && origin != UncaughtExceptionOrigin::EntryPointRejection diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index faf25548c8fd..dfb12e2a68ab 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -135,6 +135,10 @@ impl ReplCommand { .load_extra_env_and_source_code_printer(); VirtualMachine::get().as_mut().is_main_thread = true; + // An async throw at the prompt (nextTick/timer drain) would otherwise + // take the fatal path and terminate the session; keep the REPL at + // print-and-continue like Node's domain-wrapped REPL. + VirtualMachine::get().as_mut().suppress_fatal_uncaught = true; bun_jsc::virtual_machine::IS_MAIN_THREAD_VM.set(true); // Store VM reference in REPL (safe - no JS allocation) diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 0d8e2ce79af9..7df7ef8cd86c 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -344,6 +344,27 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("an async throw from nextTick/setTimeout keeps the session alive", async () => { + // The nextTick/timer drain routes through Bun__reportUnhandledError, which + // opts into Node's fatal exit under `bun run`. The REPL sets + // suppress_fatal_uncaught so the prompt redraws (Node's REPL wraps + // evaluation in a domain for the same effect). + const { stdout, stderr, exitCode } = await runRepl([ + "process.nextTick(() => { throw new Error('from-tick') })", + "setTimeout(() => { throw new Error('from-timer') }, 0)", + "1 + 1", + ".exit", + ]); + const allOutput = stripAnsi(stdout + stderr); + expect(allOutput).toContain("from-tick"); + expect(allOutput).toContain("from-timer"); + // The session reached `1 + 1` after both throws: it did not hard-exit. + expect(allOutput).toContain("2"); + // The unhandled error was reported, so the eventual `.exit` leaves + // with code 1 (pre-existing behavior). + 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); From 2eafe1f73bc9bed55ef1b7f2bd2964963f53a99c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 05:31:35 +0000 Subject: [PATCH 38/82] test: pin the Bun.spawn ipc keep-alive contract A throwing ipc callback reports and the parent keeps running, like the Bun.listen handlers; reaches the default through EventLoop::run_callback instead of the socket error path. [allow size] --- test/js/node/process/process.test.js | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 97c2a06c135b..a747188d1321 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2118,3 +2118,38 @@ it("a Bun.listen error: handler that itself throws keeps the server alive", asyn expect(stderr).toContain("from-error-handler"); expect(exitCode).toBe(1); }); + +it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { + // Same keep-alive default as the Bun.listen handlers above, reached + // through EventLoop::run_callback instead of the socket error path. + 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); + }, + }); + await child.exited; + `, + "child.js": ` + process.send("boom"); + process.send("second"); + setTimeout(() => process.exit(0), 200); + `, + }); + 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]); + // The second ipc message still ran: the first throw did not exit. + expect(stdout).toContain("got:second"); + expect(stderr).toContain("ipc-boom"); + // Reported error arms exit 1 for the natural end of the run. + expect(exitCode).toBe(1); +}); From bda24849173ddb4e02f2fa36fb4232b452a73e89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:34:33 +0000 Subject: [PATCH 39/82] webview: keep the frame-loop error reporter on the keep-alive path [allow size] WebKitBackend.cpp:517 and ChromeBackend.cpp:397/557 called reportUncaughtExceptionAtEventLoop (-> Bun__reportUnhandledError -> uncaught_exception_fatal) from their per-frame catchScopes, so a throwing onConsole() would hard-exit. Route them through Bun__reportError (keep-alive) instead, matching the surrounding loop's own comment ("Report + clear so one bad frame doesn't poison the rest of the batch"). Narrow the virtual_machine_exports.rs comment to name the VM hook specifically and point Bun-native frame loops at Bun__reportError. --- src/jsc/virtual_machine_exports.rs | 5 ++++- src/runtime/webview/ChromeBackend.cpp | 4 ++-- src/runtime/webview/WebKitBackend.cpp | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 4eb35efdfed4..ce1cdc06df5c 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -123,7 +123,10 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValu // jsFunctionReportUncaughtException (guardCallback's routing for // fs/dns/crypto callback throws), napi_fatal_exception, node:events // error with no listener, and JSC's reportUncaughtExceptionAtEventLoop - // — all Node-compat uncaught throws where the caller's task is dead. + // VM hook (microtask/promise reaction escaped with nothing to catch + // it) — all Node-compat uncaught throws where the caller's task is + // dead. Bun-native frame loops that just want to print and continue + // call Bun__reportError instead, which stays on the keep-alive path. let _ = global.bun_vm().as_mut().uncaught_exception_fatal( global, value, diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 16a82f9e7514..d162c97a86e8 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -394,7 +394,7 @@ static void wsOnMessage(void* ctx, std::span utf8) t.handleMessage(utf8); if (auto* ex = catchScope.exception()) [[unlikely]] { catchScope.clearExceptionExceptTermination(); - t.m_global->reportUncaughtExceptionAtEventLoop(t.m_global, ex); + 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 eeb408562f0a..d5d6bfe22754 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); From 04a67938bd2556764cba331816320cac511e6f7c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 06:02:58 +0000 Subject: [PATCH 40/82] runtime: a reported error no longer kills event-loop liveness is_event_loop_alive returned false whenever unhandled_error_counter was nonzero, so any reported-but-unhandled error wound the process down at the next check - an idle Bun.serve/listen server died after one throwing handler even though its listen socket was live. Fatal node-compat throws exit inside uncaught_exception_fatal and never reach this check; keep-alive reports now leave servers serving. The counter still arms exit code 1 and skips beforeExit on natural drain. Pinned with a websocket reconnect test; this also makes the existing Bun.listen keep-alive tests deterministic instead of racing the liveness check. [allow size] --- src/jsc/VirtualMachine.rs | 16 +++++--- test/js/node/process/process.test.js | 61 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0b7fcae9786c..0aa565406a2b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1066,12 +1066,16 @@ 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.has_pending_refs() as usize) - > 0) + // A reported-but-unhandled error no longer kills liveness: fatal + // (node-compat) throws exit inside uncaught_exception_fatal, and + // keep-alive reports (Bun.serve/listen handlers, reportError) leave + // servers serving. The counter still arms exit code 1 and skips + // beforeExit at the natural end of the run. + (active as usize) + + self.active_tasks + + el.tasks.readable_length() + + (el.has_pending_refs() as usize) + > 0 } pub fn is_event_loop_alive(&self) -> bool { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index a747188d1321..017562a5a0ae 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2153,3 +2153,64 @@ it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { // Reported error arms exit 1 for the natural end of the run. expect(exitCode).toBe(1); }); + +it("a throwing Bun.serve websocket message handler keeps the server serving", async () => { + // The error is reported, but the listen socket keeps the loop alive: a + // second connection is served afterwards (previously any unhandled + // report made is_event_loop_alive false and an idle server exited). + 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()); + + // First connection: the handler throws; the socket stays open, so don't + // wait on a close event that never comes. + 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"); + + // Second connection is served normally; its echo stops the server. + 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(); + + // The server would keep serving forever; tear it down ourselves. stdout + // was consumed by the port reader above. + proc.kill(); + const [stderr] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("ws-boom"); +}); From c6b8e2a54a91d262e76f3f0839584018521586ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:10:19 +0000 Subject: [PATCH 41/82] runtime: three fatal-exit nit fixes [allow size] - Arm exit_on_uncaught_exception before process_exit in the fatal block so a throwing 'exit' listener re-enters via the run_error_handler path and the version footer prints once. - Scope suppress_fatal_uncaught to the interactive REPL branch only; bun repl -e / -p now take the fatal path like bun -e. - Strengthen the repl keep-alive test with a unique REPL-SURVIVED:42 marker (a bare '2' appears in stack-trace column numbers). --- src/jsc/VirtualMachine.rs | 4 ++++ src/runtime/cli/repl_command.rs | 11 ++++++----- test/js/bun/repl/repl.test.ts | 8 +++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0aa565406a2b..262e9901dc8c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1513,6 +1513,10 @@ impl VirtualMachine { // See the recursion-guard note above: drop it before // process_exit emits 'exit'. self.is_handling_uncaught_exception = false; + // Arm the wind-down flag so a throwing 'exit' listener + // re-enters via the block above (run_error_handler, no + // repeated footer) instead of this one. + self.exit_on_uncaught_exception = true; // SAFETY: see above. unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index dfb12e2a68ab..9fd998a35dd7 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -135,10 +135,6 @@ impl ReplCommand { .load_extra_env_and_source_code_printer(); VirtualMachine::get().as_mut().is_main_thread = true; - // An async throw at the prompt (nextTick/timer drain) would otherwise - // take the fatal path and terminate the session; keep the REPL at - // print-and-continue like Node's domain-wrapped REPL. - VirtualMachine::get().as_mut().suppress_fatal_uncaught = true; bun_jsc::virtual_machine::IS_MAIN_THREAD_VM.set(true); // Store VM reference in REPL (safe - no JS allocation) @@ -249,7 +245,12 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.on_before_exit(); } } else { - // Interactive: run the REPL loop + // Interactive: run the REPL loop. An async throw at the prompt + // (nextTick/timer drain) would otherwise take the fatal path and + // terminate the session; keep the interactive REPL at + // print-and-continue like Node's domain-wrapped REPL. `-e`/`-p` + // take the fatal path like `bun -e`. + 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/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 7df7ef8cd86c..2c5c6be89208 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -352,14 +352,16 @@ describe.concurrent("Bun REPL", () => { const { stdout, stderr, exitCode } = await runRepl([ "process.nextTick(() => { throw new Error('from-tick') })", "setTimeout(() => { throw new Error('from-timer') }, 0)", - "1 + 1", + "'REPL-SURVIVED:' + (7 * 6)", ".exit", ]); const allOutput = stripAnsi(stdout + stderr); expect(allOutput).toContain("from-tick"); expect(allOutput).toContain("from-timer"); - // The session reached `1 + 1` after both throws: it did not hard-exit. - expect(allOutput).toContain("2"); + // The session reached the third line after both throws: it did not + // hard-exit. The marker cannot appear in a stack trace or version + // footer, unlike a bare digit. + expect(allOutput).toContain("REPL-SURVIVED:42"); // The unhandled error was reported, so the eventual `.exit` leaves // with code 1 (pre-existing behavior). expect(exitCode).toBe(1); From ecd803fc75c23e374f119743f3cfe4ace4f58622 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:39:22 +0000 Subject: [PATCH 42/82] runtime: fatal-exit for --unhandled-rejections=throw/strict; keep-alive for EventTarget dispatch [allow size] - Mode::Throw and Mode::Strict in unhandled_rejection() are explicit Node-compat opt-ins; route them through uncaught_exception_fatal so an unhandled rejection with pending work exits 1 instead of ticking forever (04a67938 removed the unhandled_error_counter liveness check, which was the only thing winding these down before). - WebCore::reportException (JSDOMExceptionHandling.cpp) is called synchronously from inside innerInvokeEventListeners's per-listener loop; route it through Bun__reportError so later listeners on the same event and code after a synchronous dispatchEvent()/abort() run. - Replace the 200ms setTimeout in the Bun.spawn ipc test with an ack round-trip. Pinned with an AbortSignal dispatch test and an --unhandled-rejections test over both modes. --- src/jsc/VirtualMachine.rs | 4 +- src/jsc/bindings/JSDOMExceptionHandling.cpp | 8 +++- test/js/node/process/process.test.js | 49 ++++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 262e9901dc8c..87b65a85f18f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3474,7 +3474,7 @@ 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, @@ -3493,7 +3493,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, diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index 0109fe8df67d..4155b4383b80 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -73,7 +73,13 @@ void reportException(JSGlobalObject* lexicalGlobalObject, JSC::Exception* except // exceptionSourceURL = callFrame->sourceURL(); // } - Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + // JSEventListener::handleEvent calls this synchronously from inside + // innerInvokeEventListeners's per-listener loop. Bun__reportUnhandledError + // opts into Node's fatal exit, which would process_exit(1) mid-dispatch — + // later listeners on the same event and code after a synchronous + // dispatchEvent()/AbortController.abort() would never run. Keep-alive so + // the dispatch loop completes, matching pre-existing behavior. + Bun__reportError(globalObject, JSC::JSValue::encode(JSC::JSValue(exception))); if (exceptionDetails) { auto errorMessage = retrieveErrorMessage(*lexicalGlobalObject, vm, exception->value(), scope); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 017562a5a0ae..2fb8da037870 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2119,6 +2119,52 @@ it("a Bun.listen error: handler that itself throws keeps the server alive", asyn expect(exitCode).toBe(1); }); +it("a throwing EventTarget listener does not hard-exit mid-dispatch", async () => { + // WebCore::reportException is called synchronously from inside + // innerInvokeEventListeners's per-listener loop; routing it to the fatal + // exit would process_exit before later listeners and code after + // dispatchEvent()/abort() run. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `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(stderr).toContain("from-first"); + expect(exitCode).toBe(1); +}); + +it.each(["throw", "strict"])("--unhandled-rejections=%s fatal-exits with pending work", async mode => { + // Mode::Throw/Strict are explicit Node-compat opt-ins. With no + // unhandledRejection/uncaughtException listener, Node fatal-exits 1; a + // keep-alive report would leave the interval ticking forever. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + `--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 () => { // Same keep-alive default as the Bun.listen handlers above, reached // through EventLoop::run_callback instead of the socket error path. @@ -2129,6 +2175,7 @@ it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { ipc(message) { if (message === "boom") throw new Error("ipc-boom"); console.log("got:" + message); + child.send("ack"); }, }); await child.exited; @@ -2136,7 +2183,7 @@ it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { "child.js": ` process.send("boom"); process.send("second"); - setTimeout(() => process.exit(0), 200); + process.on("message", () => process.exit(0)); `, }); await using proc = Bun.spawn({ From db34801271dcd60d4cbc43bacb8bb237f77a367e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:59:54 +0000 Subject: [PATCH 43/82] runtime: route Bun.cron and node:net onread throws to the fatal path [allow size] 04a67938 removed the unhandled_error_counter liveness check so a Bun.serve/listen server keeps serving after a handler throw. That left two pre-existing tests hanging: - in-process-cron.test.ts "unhandled cron error exits process like setTimeout does": cron.rs:1777 was on keep-alive, so the process ticked the 61s setTimeout instead of exiting. Cron is semantically a scheduled callback like setTimeout; route it through uncaught_exception_fatal. - node-net.test.ts "onread: a callback that throws is an uncaught exception": net.ts's onread deliver loop called reportError() (Bun__reportError, keep-alive). Node's bare onStreamRead call is triggerUncaughtException; route through reportUncaughtException (jsFunctionReportUncaughtException -> Bun__reportUnhandledError -> fatal). The sibling "swallowed throw" test installs an uncaughtException listener so it stays on the handled path. Export reportUncaughtException from internal/shared for net.ts. Update the uncaught_exception/_fatal doc comments. --- src/js/internal/shared.ts | 1 + src/js/node/net.ts | 5 +++-- src/jsc/VirtualMachine.rs | 17 +++++++++-------- src/runtime/api/cron.rs | 4 +++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 6a6f8c16012d..109f7b3e1df3 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -373,6 +373,7 @@ export default { once, getLazy, guardCallback, + reportUncaughtException, resistStopPropagation, hasObserver, diff --git a/src/js/node/net.ts b/src/js/node/net.ts index ebed05641f93..87e8f313d18c 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"; @@ -1709,7 +1710,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()) { @@ -1739,7 +1740,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/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 87b65a85f18f..b209415e44c9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1384,7 +1384,8 @@ impl VirtualMachine { /// The process keeps running — this is the pre-existing contract every /// Bun-native call site was written against (`Bun.serve`, `Bun.listen`, /// `Bun.spawn` ipc/onExit, `Bun.sql`/`Bun.redis` onclose, the shell, - /// `EventLoop::run_callback`, `reportError()`, ...). + /// `EventLoop::run_callback`, `reportError()`, `EventTarget` listener + /// dispatch, ...). pub fn uncaught_exception( &mut self, global_object: &JSGlobalObject, @@ -1397,13 +1398,13 @@ impl VirtualMachine { /// Node's fatal path: if no listener/domain/capture callback claims the /// error, print it, emit `'exit'`, and `process.exit(1)` without another /// loop turn (queued I/O, timers, immediates, later ticks never run). - /// Only for true Node-compat uncaught throws that reach - /// `Bun__reportUnhandledError` — the nextTick drain, setTimeout/ - /// setInterval callbacks, `jsFunctionReportUncaughtException` (what this - /// PR's `guardCallback` routes fs/dns/crypto callback throws to), N-API - /// `napi_fatal_exception`, `node:events` error with no listener, and JSC's - /// `reportUncaughtExceptionAtEventLoop`. Everything else stays on - /// `uncaught_exception` above. + /// For Node-compat uncaught throws where the caller's task is dead: + /// `Bun__reportUnhandledError` (nextTick drain, setTimeout/setInterval, + /// `jsFunctionReportUncaughtException` for `guardCallback`/fs/dns/crypto + /// and `node:net` onread, `napi_fatal_exception`, `node:events` error with + /// no listener, JSC's `reportUncaughtExceptionAtEventLoop` VM hook), + /// `--unhandled-rejections=throw`/`strict`, and `Bun.cron` (matches + /// setTimeout). Everything else stays on `uncaught_exception` above. pub fn uncaught_exception_fatal( &mut self, global_object: &JSGlobalObject, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 7935a2e4b7e3..cc5d418a64dd 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1774,7 +1774,9 @@ impl CronJob { let global_ref = vm.global(); // SAFETY: single JS thread; `&mut` derived via the thread-local // raw pointer (avoids `&T` → `&mut T` provenance laundering). - let _ = VirtualMachine::get().as_mut().uncaught_exception( + // Matches setTimeout (NodeTimerObject): a cron handler throw + // with no uncaughtException listener is fatal. + let _ = VirtualMachine::get().as_mut().uncaught_exception_fatal( global_ref, err, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, From 18c5d905e7341bd153ddc6dbcd88ae465c72b0e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:06:41 +0000 Subject: [PATCH 44/82] runtime: default unhandled-rejections mode takes the fatal path too [allow size] Mode::Bun fell through to counter++/print and previously wound down via the unhandled_error_counter liveness check; with that check removed (04a67938), a bare Promise.reject with pending work ticked forever. Route the Mode::Bun fall-through through uncaught_exception_fatal like Mode::Throw (Node's default since v15); an uncaughtException listener can still claim it. Pinned by adding the default mode to the existing --unhandled-rejections it.each. --- src/jsc/VirtualMachine.rs | 20 ++++++++++++- test/js/node/process/process.test.js | 43 +++++++++++++++------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index b209415e44c9..18c0328ab9b7 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3447,7 +3447,25 @@ impl VirtualMachine { if handle_unhandled() { return; } - // continue to default handler + // Pre-04a67938 this fell through to counter++/print and the + // liveness check wound the process down; that check is gone + // (so Bun.serve keeps serving after a handler error). Take + // the fatal path here like Mode::Throw — Node's default has + // been throw since v15, and an uncaughtException listener can + // still claim it. + 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; + } + if self.event_loop_mut().drain_microtasks().is_err() { + return; + } } Mode::None => { let _ = handle_unhandled(); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 2fb8da037870..578b4145ddf3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2144,26 +2144,29 @@ it("a throwing EventTarget listener does not hard-exit mid-dispatch", async () = expect(exitCode).toBe(1); }); -it.each(["throw", "strict"])("--unhandled-rejections=%s fatal-exits with pending work", async mode => { - // Mode::Throw/Strict are explicit Node-compat opt-ins. With no - // unhandledRejection/uncaughtException listener, Node fatal-exits 1; a - // keep-alive report would leave the interval ticking forever. - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - `--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.each([undefined, "throw", "strict"])( + "an unhandled rejection fatal-exits with pending work (--unhandled-rejections=%s)", + async mode => { + // Node's default is throw since v15. With no unhandledRejection / + // uncaughtException listener, Node fatal-exits 1; a keep-alive report + // would leave the interval ticking forever. + 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 () => { // Same keep-alive default as the Bun.listen handlers above, reached From 7af13d1efca191e35616591149af591e1ec37253 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 07:33:38 +0000 Subject: [PATCH 45/82] runtime: rejection modes skip the uncaught machinery under watch/hot report_exception_in_hot_reloaded_module_if_needed routes every reloaded entry rejection through unhandled_rejection, so the fatal default mode killed the watcher after the first throwing reload (the hot sourcemap pins). The reload driver owns recovery there: gate the default/throw/strict fatal calls on hot_reload == 0 and fall through to the plain counter/print tail, restoring the pre-change reload cycle while non-watch runs keep node's fatal exit. [allow size] --- src/jsc/VirtualMachine.rs | 77 ++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 18c0328ab9b7..a37ccaa5bdc9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3452,19 +3452,27 @@ impl VirtualMachine { // (so Bun.serve keeps serving after a handler error). Take // the fatal path here like Mode::Throw — Node's default has // been throw since v15, and an uncaughtException listener can - // still claim it. - 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; - } - if self.event_loop_mut().drain_microtasks().is_err() { - return; + // still claim it. Watch/hot mode skips the uncaught machinery + // entirely: the reload path (report_exception_in_hot_reloaded + // _module_if_needed) routes every reloaded entry rejection + // through here, and the reload driver owns recovery — the + // plain counter/print tail below keeps the watcher ticking. + if self.hot_reload == 0 { + 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; + } + if self.event_loop_mut().drain_microtasks().is_err() { + return; + } } } Mode::None => { @@ -3491,13 +3499,19 @@ impl VirtualMachine { return; } Mode::Strict => { - let wrapped = - wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); - let _ = self.uncaught_exception_fatal( - global_object, - wrapped, - UncaughtExceptionOrigin::Rejection, - ); + // Watch/hot mode: the reload driver owns recovery (see the + // Mode::Bun comment); skip the uncaught machinery. + if self.hot_reload == 0 { + let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception( + global_object, + reason, + ); + let _ = self.uncaught_exception_fatal( + global_object, + wrapped, + UncaughtExceptionOrigin::Rejection, + ); + } let handled = handle_unhandled(); if !handled { emit_warning(self); @@ -3510,15 +3524,20 @@ impl VirtualMachine { drain(self); return; } - 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; + // Watch/hot mode: see the Mode::Bun comment. + if self.hot_reload == 0 { + 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; + } } // continue to default handler — but RETURN if this drain // errors (the VM is dead; don't bump the counter or invoke the From 7c8b8ec7847e3204604090dd35560549fb477909 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:37:08 +0000 Subject: [PATCH 46/82] events: defer EventTarget listener throws to nextTick, then fatal-exit [allow size] Match Node's lib/internal/event_target.js emitUncaughtException: JSEventListener::handleEvent now queues the exception via process.nextTick(jsFunctionEmitUncaughtException) so the per-listener loop and code after a synchronous dispatchEvent()/abort() complete first, then the process fatal-exits on the next tick. jsFunctionEmitUncaughtException calls Bun__reportUnhandledError (fatal) instead of reportException (keep-alive) since it always runs from a nextTick with no dispatch loop above it. WebCore::reportException stays on Bun__reportError for whatever other callers reach it. Pinned with a sync-throw test (setInterval + throwing AbortSignal listener: SECOND-LISTENER and AFTER-ABORT print, TICK never does, exit 1) and an async-listener rejection test. --- src/jsc/VirtualMachine.rs | 3 +- src/jsc/bindings/webcore/JSEventListener.cpp | 30 ++++++++++----- test/js/node/process/process.test.js | 39 +++++++++++++++++--- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 18c0328ab9b7..1ea7fa143f61 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1384,8 +1384,7 @@ impl VirtualMachine { /// The process keeps running — this is the pre-existing contract every /// Bun-native call site was written against (`Bun.serve`, `Bun.listen`, /// `Bun.spawn` ipc/onExit, `Bun.sql`/`Bun.redis` onclose, the shell, - /// `EventLoop::run_callback`, `reportError()`, `EventTarget` listener - /// dispatch, ...). + /// `EventLoop::run_callback`, `reportError()`, ...). pub fn uncaught_exception( &mut self, global_object: &JSGlobalObject, diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index e1d7f4eb3eb7..0932613b80b5 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 "BeforeUnloadEvent.h" // #include "ContentSecurityPolicy.h" #include "EventNames.h" @@ -127,17 +128,28 @@ 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); + // Reached from a nextTick with no dispatch loop above it; the caller's + // task is dead, so take Node's fatal path. + 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's EventTarget catches a listener throw and defers it via +// process.nextTick(() => { throw err }) (lib/internal/event_target.js +// emitUncaughtException) so innerInvokeEventListeners's per-listener loop and +// code after a synchronous dispatchEvent()/abort() complete first, then the +// process fatal-exits on the next tick. +static void queueUncaughtExceptionNextTick(JSC::JSGlobalObject* lexicalGlobalObject, JSValue exception) { Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); Bun::Process* process = globalObject->processObject(); - auto exception = callFrame->argument(0); auto func = JSFunction::create(globalObject->vm(), globalObject, 1, String(), jsFunctionEmitUncaughtException, JSC::ImplementationVisibility::Private); process->queueNextTick(lexicalGlobalObject, func, exception); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtExceptionNextTick, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + queueUncaughtExceptionNextTick(lexicalGlobalObject, callFrame->argument(0)); return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -208,13 +220,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; } } @@ -250,7 +262,7 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext if (exception) { event.target()->uncaughtExceptionInEventHandler(); - reportException(lexicalGlobalObject, exception); + queueUncaughtExceptionNextTick(lexicalGlobalObject, exception); return true; } return false; @@ -267,7 +279,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()) { @@ -279,7 +291,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/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 578b4145ddf3..7d5e92f0bc21 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2119,16 +2119,17 @@ it("a Bun.listen error: handler that itself throws keeps the server alive", asyn expect(exitCode).toBe(1); }); -it("a throwing EventTarget listener does not hard-exit mid-dispatch", async () => { - // WebCore::reportException is called synchronously from inside - // innerInvokeEventListeners's per-listener loop; routing it to the fatal - // exit would process_exit before later listeners and code after - // dispatchEvent()/abort() run. +it("a throwing EventTarget listener lets dispatch complete, then fatal-exits next tick", async () => { + // Node's EventTarget catches a listener throw and defers it via + // process.nextTick(() => { throw err }) so later listeners and code after + // dispatchEvent()/abort() run, then the process fatal-exits; a keep-alive + // report would leave the interval ticking forever. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `const ac = new AbortController(); + `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(); @@ -2140,10 +2141,36 @@ it("a throwing EventTarget listener does not hard-exit mid-dispatch", async () = }); 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 () => { + // The async-listener rejection is already deferred to nextTick + // (jsFunctionEmitUncaughtExceptionNextTick); that nextTick takes the fatal + // path so pending work never runs. + 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 => { From 9e4018e1b082ef4df9aecd475912398bd121c644 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:00:12 +0000 Subject: [PATCH 47/82] events: clear any exception from queueNextTick in queueUncaughtExceptionNextTick [allow size] process->queueNextTick calls process.nextTick as JS (DECLARE_THROW_SCOPE at BunProcess.cpp:4119); without a scope in the helper, handleEvent's TOP_EXCEPTION_SCOPE at :163 saw an unchecked exception on validateExceptionChecks builds (broadcast-channel-worker-gc, worker_threads, test-eventtarget, and the new EventTarget process.test on x64-asan, build 79492). Declare a TOP_EXCEPTION_SCOPE in the helper and tryClearException after the call. --- src/jsc/bindings/webcore/JSEventListener.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index 0932613b80b5..917db4390c01 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -142,9 +142,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtException, (JSC::JSGlobalObject * 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 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); + // queueNextTick calls process.nextTick as JS; if that throws (e.g. + // termination) while reporting the original error, drop it so the caller's + // scope does not see an unchecked exception. + (void)scope.tryClearException(); } JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtExceptionNextTick, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) From 00ee636c0e1c8cda7fb429074d7b31207d3cecaf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:09:46 +0000 Subject: [PATCH 48/82] crypto: guard native-callback siblings; runtime: don't double-print Mode::Bun/Throw on keep-alive paths [allow size] - crypto.ts: wrap scrypt/randomBytes/randomFill/randomInt/hkdf/ checkPrime/generatePrime/generateKey/generateKeyPair with guardLastCallback so a throwing callback is a fatal uncaughtException like node's AfterThreadPoolWork -> MakeCallback (and consistent with pbkdf2). Added scrypt/randomBytes/randomFill/hkdf to the fs.test.ts callback-throw matrix. - VirtualMachine.rs Mode::Bun/Throw: when the fatal gate is skipped (REPL/worker) uncaught_exception_fatal's keep-alive tail already incremented the counter and printed; return instead of falling through to the post-match counter++/print. Verified a REPL Promise.reject prints once. --- src/js/node/crypto.ts | 32 +++++++++++++++++++++++--------- src/jsc/VirtualMachine.rs | 13 ++++++++++--- test/js/node/fs/fs.test.ts | 4 ++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 078f59a52c55..38f866c9bf1f 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -3,6 +3,20 @@ const StringDecoder = require("node:string_decoder").StringDecoder; const LazyTransform = require("internal/streams/lazy_transform"); const { guardCallback } = require("internal/shared"); const { defineCustomPromisifyArgs } = require("internal/promisify"); + +// The native async crypto jobs invoke their callback via EventLoop::run_callback, +// which routes a throw to the keep-alive uncaught path; wrap the callback so a +// throw is a fatal uncaughtException like node's AfterThreadPoolWork -> +// MakeCallback. Applied only when the trailing arg is already callable so sync +// overloads (randomBytes(size), randomInt(max)) and native validation of a +// non-callable callback are unchanged. +function guardLastCallback(native) { + return function wrapped() { + const last = arguments.length - 1; + if (last >= 0 && $isCallable(arguments[last])) arguments[last] = guardCallback(arguments[last]); + return native.$apply(this, arguments); + }; +} const Writable = require("internal/streams/writable"); const { CryptoHasher } = Bun; @@ -139,10 +153,10 @@ crypto_exports.constants = $processBindingConstants.crypto; crypto_exports.KeyObject = KeyObject; -crypto_exports.generateKey = generateKey; +crypto_exports.generateKey = guardLastCallback(generateKey); crypto_exports.generateKeySync = generateKeySync; defineCustomPromisifyArgs(generateKeyPair, ["publicKey", "privateKey"]); -crypto_exports.generateKeyPair = generateKeyPair; +crypto_exports.generateKeyPair = guardLastCallback(generateKeyPair); crypto_exports.generateKeyPairSync = generateKeyPairSync; crypto_exports.createSecretKey = createSecretKey; @@ -185,7 +199,7 @@ function onPbkdf2Rejected(err) { crypto_exports.pbkdf2 = pbkdf2; crypto_exports.pbkdf2Sync = pbkdf2Sync; -crypto_exports.hkdf = hkdf; +crypto_exports.hkdf = guardLastCallback(hkdf); crypto_exports.hkdfSync = hkdfSync; crypto_exports.getCurves = getCurves; @@ -335,10 +349,10 @@ crypto_exports.createHmac = function createHmac(hmac, key, options) { crypto_exports.getHashes = getHashes; -crypto_exports.randomInt = randomInt; -crypto_exports.randomFill = randomFill; +crypto_exports.randomInt = guardLastCallback(randomInt); +crypto_exports.randomFill = guardLastCallback(randomFill); crypto_exports.randomFillSync = randomFillSync; -crypto_exports.randomBytes = randomBytes; +crypto_exports.randomBytes = guardLastCallback(randomBytes); crypto_exports.randomUUID = randomUUID; crypto_exports.randomUUIDv7 = randomUUIDv7; @@ -352,9 +366,9 @@ crypto_exports.argon2Sync = function argon2Sync(_algorithm, _parameters) { throw $ERR_CRYPTO_ARGON2_NOT_SUPPORTED("Argon2 algorithm not supported"); }; -crypto_exports.checkPrime = checkPrime; +crypto_exports.checkPrime = guardLastCallback(checkPrime); crypto_exports.checkPrimeSync = checkPrimeSync; -crypto_exports.generatePrime = generatePrime; +crypto_exports.generatePrime = guardLastCallback(generatePrime); crypto_exports.generatePrimeSync = generatePrimeSync; crypto_exports.secureHeapUsed = secureHeapUsed; @@ -501,7 +515,7 @@ crypto_exports.createECDH = function createECDH(curve) { crypto_exports.getCiphers = getCiphers; } -crypto_exports.scrypt = scrypt; +crypto_exports.scrypt = guardLastCallback(scrypt); crypto_exports.scryptSync = scryptSync; crypto_exports.publicEncrypt = publicEncrypt; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 120a2cddce13..33d142c3955f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3469,9 +3469,12 @@ impl VirtualMachine { drain(self); return; } - if self.event_loop_mut().drain_microtasks().is_err() { - return; - } + // uncaught_exception_fatal already bumped the counter and + // printed (via the keep-alive tail) when the fatal gate + // was skipped (REPL / worker); don't fall through and + // print again. + let _ = self.event_loop_mut().drain_microtasks(); + return; } } Mode::None => { @@ -3537,6 +3540,10 @@ impl VirtualMachine { drain(self); return; } + // Same as Mode::Bun: the keep-alive tail already printed + // when the fatal gate was skipped (REPL / worker). + let _ = self.event_loop_mut().drain_microtasks(); + return; } // continue to default handler — but RETURN if this drain // errors (the VM is dead; don't bump the counter or invoke the diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 8457b9a58286..e1465c62ee58 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5852,6 +5852,10 @@ describe("a throw from a node-style callback is an uncaughtException", () => { ], ]), ["crypto.pbkdf2", `require("crypto").pbkdf2("pw", "salt", 10, 16, "sha256", () => { throw new Error("boom"); })`], + ["crypto.scrypt", `require("crypto").scrypt("pw", "salt", 16, () => { throw new Error("boom"); })`], + ["crypto.randomBytes", `require("crypto").randomBytes(8, () => { throw new Error("boom"); })`], + ["crypto.randomFill", `require("crypto").randomFill(Buffer.alloc(8), () => { throw new Error("boom"); })`], + ["crypto.hkdf", `require("crypto").hkdf("sha256", "key", "salt", "info", 16, () => { throw new Error("boom"); })`], ]; it.concurrent.each(cases)("%s", async (_name, snippet) => { From 3c946763b4cc5c03b168ad02b75549c566e7afb0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:25:51 +0000 Subject: [PATCH 49/82] crypto: carry the custom-promisify args onto the guarded generateKeyPair wrapper [allow size] defineCustomPromisifyArgs was applied to the native generateKeyPair, then guardLastCallback returned a new function without them; util.promisify(crypto.generateKeyPair) fell back to single-value promisify (test-crypto-keygen-promisify.js, 09469.test.ts). Apply the args to the wrapper that is exported. --- src/js/node/crypto.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 38f866c9bf1f..16433581f1e6 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -155,8 +155,9 @@ crypto_exports.KeyObject = KeyObject; crypto_exports.generateKey = guardLastCallback(generateKey); crypto_exports.generateKeySync = generateKeySync; -defineCustomPromisifyArgs(generateKeyPair, ["publicKey", "privateKey"]); -crypto_exports.generateKeyPair = guardLastCallback(generateKeyPair); +const generateKeyPairGuarded = guardLastCallback(generateKeyPair); +defineCustomPromisifyArgs(generateKeyPairGuarded, ["publicKey", "privateKey"]); +crypto_exports.generateKeyPair = generateKeyPairGuarded; crypto_exports.generateKeyPairSync = generateKeyPairSync; crypto_exports.createSecretKey = createSecretKey; From b82fa1b2e69a276dcbe375e68794602f5db14842 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 08:41:41 +0000 Subject: [PATCH 50/82] crypto: guard sign and verify callbacks like their siblings A throwing sign/verify completion callback printed once and kept the process alive where node fatal-exits; the sibling sweep missed the two of them. Add crypto.sign to the guard matrix and synchronize the websocket keep-alive test on the reported throw before its second connection. [allow size] --- src/js/node/crypto.ts | 4 ++-- test/js/node/fs/fs.test.ts | 4 ++++ test/js/node/process/process.test.js | 25 ++++++++++++++++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 16433581f1e6..31dce47259c7 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -238,7 +238,7 @@ Object.assign(Sign.prototype, { }); crypto_exports.Sign = Sign; -crypto_exports.sign = sign; +crypto_exports.sign = guardLastCallback(sign); function createSign(algorithm, options?) { return new Sign(algorithm, options); @@ -269,7 +269,7 @@ Object.assign(Verify.prototype, { }); crypto_exports.Verify = Verify; -crypto_exports.verify = verify; +crypto_exports.verify = guardLastCallback(verify); function createVerify(algorithm, options?) { return new Verify(algorithm, options); diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index e1465c62ee58..6a8f809070a5 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5856,6 +5856,10 @@ describe("a throw from a node-style callback is an uncaughtException", () => { ["crypto.randomBytes", `require("crypto").randomBytes(8, () => { throw new Error("boom"); })`], ["crypto.randomFill", `require("crypto").randomFill(Buffer.alloc(8), () => { throw new Error("boom"); })`], ["crypto.hkdf", `require("crypto").hkdf("sha256", "key", "salt", "info", 16, () => { throw new Error("boom"); })`], + [ + "crypto.sign", + `const { generateKeyPairSync, sign } = require("crypto"); const { privateKey } = generateKeyPairSync("ed25519"); sign(null, Buffer.from("d"), privateKey, () => { throw new Error("boom"); })`, + ], ]; it.concurrent.each(cases)("%s", async (_name, snippet) => { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 7d5e92f0bc21..cfa4ae864e9f 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2274,7 +2274,18 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as }); first.send("boom"); - // Second connection is served normally; its echo stops the server. + // Wait for the reported throw before connecting again, so the second + // connection provably exercises serve-after-throw. + 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); + } + + // Second connection is served normally, after the throw. const echoed = await new Promise((resolve, reject) => { const ws = new WebSocket("ws://127.0.0.1:" + port); ws.onopen = () => ws.send("after"); @@ -2285,9 +2296,13 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as expect(echoed).toBe("echo:after"); first.close(); - // The server would keep serving forever; tear it down ourselves. stdout - // was consumed by the port reader above. + // The server would keep serving forever; tear it down ourselves, then + // drain the remaining stderr through the same reader. proc.kill(); - const [stderr] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("ws-boom"); + while (true) { + const { done } = await errReader.read(); + if (done) break; + } + await proc.exited; + expect(stderrText).toContain("ws-boom"); }); From 862fa197d8c57dea749391975e3ed2b072a55a74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:51:14 +0000 Subject: [PATCH 51/82] crypto: guard diffieHellman and the DEP0115 randomBytes aliases; runtime: Mode::Strict falls through under hot [allow size] - crypto.ts: wrap diffieHellman with guardLastCallback; point the pseudoRandomBytes/prng/rng aliases at the guarded crypto_exports.randomBytes instead of the raw native. - VirtualMachine.rs Mode::Strict: under hot, fall through to the counter/print tail like Mode::Bun/Throw so the error is still reported by on_unhandled_rejection (7af13d1 gated the uncaught call but kept the unconditional return). --- src/js/node/crypto.ts | 4 ++-- src/jsc/VirtualMachine.rs | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 31dce47259c7..aece91f118a2 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -384,7 +384,7 @@ Object.defineProperty(crypto_exports, "fips", { for (const rng of ["pseudoRandomBytes", "prng", "rng"]) { Object.defineProperty(crypto_exports, rng, { - value: deprecate(randomBytes, `crypto.${rng} is deprecated.`, "DEP0115"), + value: deprecate(crypto_exports.randomBytes, `crypto.${rng} is deprecated.`, "DEP0115"), enumerable: false, configurable: true, }); @@ -398,7 +398,7 @@ crypto_exports.getDiffieHellman = crypto_exports.createDiffieHellmanGroup = Diff crypto_exports.createDiffieHellman = createDiffieHellman; crypto_exports.DiffieHellman = DiffieHellman; -crypto_exports.diffieHellman = diffieHellman; +crypto_exports.diffieHellman = guardLastCallback(diffieHellman); ECDH.prototype.setPublicKey = deprecate(ECDH.prototype.setPublicKey, "ecdh.setPublicKey() is deprecated.", "DEP0031"); crypto_exports.ECDH = ECDH; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 33d142c3955f..7596bd0f7964 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3513,13 +3513,19 @@ impl VirtualMachine { wrapped, UncaughtExceptionOrigin::Rejection, ); + let handled = handle_unhandled(); + if !handled { + emit_warning(self); + } + drain(self); + return; } + // Under hot, fall through to the counter/print tail like + // Mode::Bun/Throw so the error is still reported. let handled = handle_unhandled(); if !handled { emit_warning(self); } - drain(self); - return; } Mode::Throw => { if handle_unhandled() { From 15bd3cddbccd98b68867109423d02996d18769dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:26:44 +0000 Subject: [PATCH 52/82] webcore: route reportException back to the fatal path for its remaining callers [allow size] JSEventListener was moved off reportException to the nextTick defer, so the remaining callers (JSPerformanceObserverCallback, JSAbortAlgorithm, JSErrorHandler, JSDOMPromiseDeferred) are dead-task callbacks that should fatal-exit. Restore reportUncaughtExceptionAtEventLoop and update the comment to name the actual callers. Verified a throwing PerformanceObserver callback with setInterval exits 1. --- src/jsc/bindings/JSDOMExceptionHandling.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index 4155b4383b80..a082bdb31717 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -73,13 +73,11 @@ void reportException(JSGlobalObject* lexicalGlobalObject, JSC::Exception* except // exceptionSourceURL = callFrame->sourceURL(); // } - // JSEventListener::handleEvent calls this synchronously from inside - // innerInvokeEventListeners's per-listener loop. Bun__reportUnhandledError - // opts into Node's fatal exit, which would process_exit(1) mid-dispatch — - // later listeners on the same event and code after a synchronous - // dispatchEvent()/AbortController.abort() would never run. Keep-alive so - // the dispatch loop completes, matching pre-existing behavior. - Bun__reportError(globalObject, JSC::JSValue::encode(JSC::JSValue(exception))); + // Remaining callers (JSPerformanceObserverCallback, JSAbortAlgorithm, + // JSErrorHandler, JSDOMPromiseDeferred) are Node-compat callbacks whose + // task is dead; take the fatal path. JSEventListener defers its listener + // throws to nextTick separately so the dispatch loop completes first. + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); if (exceptionDetails) { auto errorMessage = retrieveErrorMessage(*lexicalGlobalObject, vm, exception->value(), scope); From f497fca43c44a4cccff9568cbfd551a916ff6b77 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:56:36 +0000 Subject: [PATCH 53/82] crypto: forward name/length on guardLastCallback wrappers; test: drop redundant ws-boom assertion [allow size] crypto.randomBytes.name etc. were "wrapped"/0; forward from the native. Drop the tautological stderr check in the websocket keep-alive test (the polling loop's exit condition already established it). --- src/js/node/crypto.ts | 7 +++++-- test/js/node/process/process.test.js | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index aece91f118a2..fcb0c52b5323 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -11,11 +11,14 @@ const { defineCustomPromisifyArgs } = require("internal/promisify"); // overloads (randomBytes(size), randomInt(max)) and native validation of a // non-callable callback are unchanged. function guardLastCallback(native) { - return function wrapped() { + function wrapped() { const last = arguments.length - 1; if (last >= 0 && $isCallable(arguments[last])) arguments[last] = guardCallback(arguments[last]); return native.$apply(this, arguments); - }; + } + Object.$defineProperty(wrapped, "name", { value: native.name, configurable: true }); + Object.$defineProperty(wrapped, "length", { value: native.length, configurable: true }); + return wrapped; } const Writable = require("internal/streams/writable"); const { CryptoHasher } = Bun; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index cfa4ae864e9f..155c5183b4d3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2297,12 +2297,12 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as first.close(); // The server would keep serving forever; tear it down ourselves, then - // drain the remaining stderr through the same reader. + // drain the remaining stderr through the same reader. The polling loop + // above already established "ws-boom" was reported. proc.kill(); while (true) { const { done } = await errReader.read(); if (done) break; } await proc.exited; - expect(stderrText).toContain("ws-boom"); }); From 16392675c3f85c3953f291dce99371a8361cd331 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 10:06:42 +0000 Subject: [PATCH 54/82] inspector: catch the best-effort banner writes Bun.write returns a promise; the seven fire-and-forget stderr banner writes became fatal unhandled rejections under the default rejection mode when one rejects (racing writers on a piped stderr, seen on the Windows lanes), killing the inspectee right after it announced its URL - every websocket case in inspect.test.ts failed at connect. [allow size] --- src/js/internal/debugger.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index a8eee42a3ef5..420c0508594d 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -124,17 +124,17 @@ 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); } } @@ -682,6 +682,11 @@ function reset(): string { return ""; } +// Bun.write returns a promise; the banner writes are best-effort and a +// rejected stderr write (racing writers on a piped stderr, seen on Windows) +// must not become a fatal unhandled rejection. +function kIgnoreWriteError(): void {} + function notify(options): void { Bun.connect({ ...options, From 03d77f92d20aeb8deb945fea65c6504195133076 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:08:53 +0000 Subject: [PATCH 55/82] [autofix.ci] apply automated fixes --- src/js/internal/debugger.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 420c0508594d..be91fac0b567 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -124,17 +124,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").catch(kIgnoreWriteError); + 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`).catch(kIgnoreWriteError); + 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").catch(kIgnoreWriteError); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); } } else { - Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch(kIgnoreWriteError); + 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); + Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n").catch( + kIgnoreWriteError, + ); } } From a2af8535b078519ea3815d32a8aaab788eca3b2e Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 15:43:07 +0000 Subject: [PATCH 56/82] ci: rebuild [allow size] From 64c8f45de3cbb1155b741d15cfea4cd60777e980 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 17:18:55 +0000 Subject: [PATCH 57/82] test: give 20144's SIGKILL backstop headroom over slow-build startup The fixture cc()-compiles C before sending its IPC message, which takes ~1.1s on debug builds and lands right at the 1s spawn timeout on the profile darwin runners - the backstop SIGKILLed the child before 'hej' ever arrived and the SIGINT assertion saw SIGKILL. Raise the backstop to 10s (release sends 'hej' in ~30ms; SIGINT still does the killing) and pin the test timeout above it. [allow size] --- test/regression/issue/20144/20144.test.ts | 46 +++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/test/regression/issue/20144/20144.test.ts b/test/regression/issue/20144/20144.test.ts index 784b2c8169cc..df604fb96114 100644 --- a/test/regression/issue/20144/20144.test.ts +++ b/test/regression/issue/20144/20144.test.ts @@ -2,24 +2,32 @@ import { it } from "bun:test"; import assert from "node:assert"; import { spawn } from "node:child_process"; -it.skipIf(process.platform === "win32")("should not time out", done => { - const child = spawn(process.execPath, ["run", "./20144.fixture.ts"], { - cwd: __dirname, - stdio: [null, "inherit", "inherit", "ipc"], - timeout: 1000, - killSignal: "SIGKILL", - }); +it.skipIf(process.platform === "win32")( + "should not time out", + done => { + const child = spawn(process.execPath, ["run", "./20144.fixture.ts"], { + cwd: __dirname, + stdio: [null, "inherit", "inherit", "ipc"], + // Backstop only: SIGINT (sent on "hej") is what actually kills the + // child. The fixture cc()-compiles C at startup, which takes ~1.1s on + // debug builds and blows a 1s budget before "hej" is ever sent, so the + // backstop must sit well above the slowest build's startup. + timeout: 10_000, + killSignal: "SIGKILL", + }); - child.on("message", message => { - if (message == "hej") { - assert.ok(child.pid); - process.kill(child.pid, "SIGINT"); - } - }); + child.on("message", message => { + if (message == "hej") { + assert.ok(child.pid); + process.kill(child.pid, "SIGINT"); + } + }); - child.on("exit", (code, signal) => { - assert.strictEqual(signal, "SIGINT"); - assert.strictEqual(code, null); - done(); - }); -}); + child.on("exit", (code, signal) => { + assert.strictEqual(signal, "SIGINT"); + assert.strictEqual(code, null); + done(); + }); + }, + 15_000, +); From 59b73db77714afdcac6bc393fd5001299b0a12de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:41:25 +0000 Subject: [PATCH 58/82] runtime: reword the Mode::Strict hot fall-through comment [allow size] The sibling comparison was misleading: Mode::Bun/Throw return early when an unhandledRejection listener fires; Mode::Strict falls through unconditionally by design (strict's listener is informational). Also drop the unused `handled` binding. --- src/jsc/VirtualMachine.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7596bd0f7964..74755a7c299c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3520,10 +3520,11 @@ impl VirtualMachine { drain(self); return; } - // Under hot, fall through to the counter/print tail like - // Mode::Bun/Throw so the error is still reported. - let handled = handle_unhandled(); - if !handled { + // Under hot, fall through to the counter/print tail + // unconditionally: under strict, an unhandledRejection + // listener alone does not suppress the uncaught treatment + // (only emit_warning is gated on it). + if !handle_unhandled() { emit_warning(self); } } From f0c9bd9168cf7ae938683c0a30b6de24cd2ff114 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:09:14 +0000 Subject: [PATCH 59/82] ws: don't arm a second native bridge from super.once(); net: route data-listener throws to the fatal path [allow size] ws-upgrade-events.test.ts (all lanes, build 88265): the shim's once() called #onOrOnce (arming a native addEventListener bridge) then super.once(), and EventEmitter.prototype.once calls this.on(type, wrapped) which is the overridden on() and arms a second bridge. Two bridges -> emit("error", ...) fires twice; the second finds no listener (the once wrapper removed itself) and throws "Unhandled error", which is fatal on this branch. Let super.once() arm the bridge via the on() override and don't do it twice. test-tls-handshake-exception.js (hang on 3 lanes): node:net's Bun.listen/connect data handlers call self.push(buffer) which emits 'data' into user listeners; a throw there reached the Bun.listen keep-alive handler path and the net server kept listening forever. Catch at the three data() handler boundaries and route to reportUncaughtException (fatal) like onread already does. --- src/js/node/net.ts | 25 ++++++++++++++++++++----- src/js/thirdparty/ws.js | 7 ++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index ef18945e5079..352557ac39c6 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -412,8 +412,15 @@ const SocketHandlers: SocketHandler = { self._unrefTimer(); self.bytesRead += buffer.length; - if (!self.push(buffer)) { - socket.pause(); + try { + if (!self.push(buffer)) { + socket.pause(); + } + } catch (e) { + // push -> 'data' listeners -> user code; a throw here is a Node-compat + // uncaughtException (Node's onStreamRead runs via MakeCallback), not a + // Bun.listen handler error. + reportUncaughtException(e); } }, drain(socket) { @@ -729,8 +736,12 @@ const ServerHandlers: SocketHandler = { self._unrefTimer(); self.bytesRead += buffer.length; - if (!self.push(buffer)) { - socket.pause(); + try { + if (!self.push(buffer)) { + socket.pause(); + } + } catch (e) { + reportUncaughtException(e); } }, keylog(socket, line) { @@ -1251,7 +1262,11 @@ const SocketHandlers2: SocketHandler Date: Mon, 3 Aug 2026 21:25:16 +0000 Subject: [PATCH 60/82] net: narrow the tls-handshake fatal catch to the secureConnection emit [allow size] f0c9bd9 wrapped the three data() handler push() calls, which also caught internal http-client parser errors (HPE_INVALID_CHUNK_SIZE from processClientData) that are supposed to propagate and be handled, turning them fatal (test-http-server-capture-rejections, test-http-abort-client, test-http-client-aborted-event on build 88305). Revert that and wrap only server.emit('secureConnection', self): a throw from the user's listener there is a Node-compat uncaughtException (Node's TLSWrap completion runs via MakeCallback); without this it fell through to the Bun-native socket handler keep-alive path and the net server kept listening forever (test-tls-handshake-exception). --- src/js/node/net.ts | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 352557ac39c6..e021238ae92c 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -412,15 +412,8 @@ const SocketHandlers: SocketHandler = { self._unrefTimer(); self.bytesRead += buffer.length; - try { - if (!self.push(buffer)) { - socket.pause(); - } - } catch (e) { - // push -> 'data' listeners -> user code; a throw here is a Node-compat - // uncaughtException (Node's onStreamRead runs via MakeCallback), not a - // Bun.listen handler error. - reportUncaughtException(e); + if (!self.push(buffer)) { + socket.pause(); } }, drain(socket) { @@ -736,12 +729,8 @@ const ServerHandlers: SocketHandler = { self._unrefTimer(); self.bytesRead += buffer.length; - try { - if (!self.push(buffer)) { - socket.pause(); - } - } catch (e) { - reportUncaughtException(e); + if (!self.push(buffer)) { + socket.pause(); } }, keylog(socket, line) { @@ -983,7 +972,15 @@ const ServerHandlers: SocketHandler = { if (typeof connectionListener === "function") { server.prependOnceListener("secureConnection", connectionListener); } - server.emit("secureConnection", self); + try { + server.emit("secureConnection", self); + } catch (e) { + // A throw from the user's secureConnection listener is a + // Node-compat uncaughtException (Node's TLSWrap completion runs + // via MakeCallback); don't let it fall through to the Bun-native + // socket handler keep-alive path. + reportUncaughtException(e); + } } } if (self.destroyed) return; @@ -1262,11 +1259,7 @@ const SocketHandlers2: SocketHandler Date: Mon, 3 Aug 2026 21:41:45 +0000 Subject: [PATCH 61/82] ws: drop the dead `once` parameter from #onOrOnce (now #armAndOn) [allow size] After once() was routed to super.once() -> this.on(), the third parameter is always undefined; collapse the ternaries, drop the addEventListener third arg and the once-only comment, and rename to #armAndOn. --- src/js/thirdparty/ws.js | 113 +++++++++++++++------------------------- 1 file changed, 42 insertions(+), 71 deletions(-) diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index d347b1f2721b..2c7a6a818bfd 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -371,103 +371,74 @@ 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; - } + // Add a persistent native bridge if one isn't already forwarding this + // event. once() reaches here via super.once() -> this.on(), so there is + // no once-only bridge shape any more. + 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); + return this.#armAndOn(event, listener); } once(event, listener) { // EventEmitter.prototype.once wraps `listener` and calls `this.on(...)`, - // which is the overridden `on` above and arms the native bridge. Calling - // #onOrOnce here as well would arm a second bridge, and the second - // `emit("error", ...)` would find no listener (the once wrapper already - // removed itself) and throw "Unhandled error". + // which is the overridden `on` above and arms the native bridge. Arming + // a separate bridge here would mean two bridges for one native event, and + // the second `emit("error", ...)` would find no listener (the once + // wrapper already removed itself) and throw "Unhandled error". return super.once(event, listener); } addListener(event, listener) { - return this.#onOrOnce(event, listener, undefined); + return this.#armAndOn(event, listener); } prependListener(event, listener) { @@ -489,7 +460,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); } From cecb355f3199d5c15b15833975e47e6be40015c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:11:46 +0000 Subject: [PATCH 62/82] net: route the two remaining handshake catches through reportUncaughtException [allow size] onClientHandshakeComplete's catch (around secureConnect/session/secure) and ServerHandlers.handshake's outer catch (around kSecureConnectDone and the standalone-TLSSocket 'secure' emit) still called global reportError (keep-alive); with the unhandled_error_counter liveness gate gone, a listener throw there left the process hung on the open socket. Route both to reportUncaughtException like the inner secureConnection catch and the two onread sites already do. A throwing net.Socket 'data' listener (the class f0c9bd91 named and 49f69049 re-exposed to unbreak http-client parser-error propagation) is deferred per the scope question at PR comment 5067997866; it needs the http-client listener to catch its own parser errors first. --- src/js/node/net.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index e021238ae92c..c3171928147e 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -301,8 +301,9 @@ function onClientHandshakeComplete(self, socket, verifyError) { 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. + // InternalCallbackScope as uncaughtException (TLSWrap completion runs via + // MakeCallback). reportUncaughtException mirrors that without letting the + // throw fall through to Bun.connect's handshake-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 +345,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() { @@ -995,7 +996,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) { From 58d8ab06b322940983e36e6b52115d080b6616b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:20:22 +0000 Subject: [PATCH 63/82] trim comments to <=3 lines, cite spec/node source --- src/js/internal/shared.ts | 8 +- src/js/node/crypto.ts | 9 +-- src/js/node/net.ts | 12 +-- src/js/thirdparty/ws.js | 7 +- src/jsc/VirtualMachine.rs | 85 ++++++-------------- src/jsc/bindings/JSDOMExceptionHandling.cpp | 7 +- src/jsc/bindings/webcore/JSEventListener.cpp | 7 +- src/jsc/virtual_machine_exports.rs | 12 +-- src/runtime/api/cron.rs | 6 +- src/runtime/cli/repl_command.rs | 7 +- 10 files changed, 48 insertions(+), 112 deletions(-) diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 93a50841986e..d33668d940c0 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -147,12 +147,8 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); -// Node v26.3.0 invokes fs/dns callbacks off the libuv completion via -// MakeCallback, so a throw inside one escapes as an uncaughtException: -// https://github.com/nodejs/node/blob/v26.3.0/src/node_file.cc#L724-L741 (FSReqCallback::Reject/Resolve) -// https://github.com/nodejs/node/blob/v26.3.0/src/cares_wrap.cc#L1881 -// Bun runs them from a promise reaction, where an unguarded throw would only -// reject that promise (an unhandledRejection). +// Node invokes fs/dns callbacks via MakeCallback so a throw is an uncaughtException; Bun runs them from +// a promise reaction where it would only be an unhandledRejection. https://github.com/nodejs/node/blob/main/src/node_file.cc const reportUncaughtException = $newCppFunction("BunProcess.cpp", "jsFunctionReportUncaughtException", 1); // Wrap a node-style callback so a throw inside it takes the uncaught path. The diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index fcb0c52b5323..6426720fa1f7 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -4,12 +4,9 @@ const LazyTransform = require("internal/streams/lazy_transform"); const { guardCallback } = require("internal/shared"); const { defineCustomPromisifyArgs } = require("internal/promisify"); -// The native async crypto jobs invoke their callback via EventLoop::run_callback, -// which routes a throw to the keep-alive uncaught path; wrap the callback so a -// throw is a fatal uncaughtException like node's AfterThreadPoolWork -> -// MakeCallback. Applied only when the trailing arg is already callable so sync -// overloads (randomBytes(size), randomInt(max)) and native validation of a -// non-callable callback are unchanged. +// Wrap the trailing callback so a throw is a fatal uncaughtException like Node's AfterThreadPoolWork -> +// MakeCallback (https://github.com/nodejs/node/blob/main/src/node_crypto.cc). Only wraps when already +// callable so sync overloads and native non-callable validation are unchanged. function guardLastCallback(native) { function wrapped() { const last = arguments.length - 1; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index c3171928147e..26f20e2a6081 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -300,10 +300,8 @@ 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 (TLSWrap completion runs via - // MakeCallback). reportUncaughtException mirrors that without letting the - // throw fall through to Bun.connect's handshake-to-error-handler contract. + // Node's TLSWrap completion runs via MakeCallback so a listener throw is an uncaughtException; + // mirror that here instead of falling through to Bun.connect's handshake-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 @@ -976,10 +974,8 @@ const ServerHandlers: SocketHandler = { try { server.emit("secureConnection", self); } catch (e) { - // A throw from the user's secureConnection listener is a - // Node-compat uncaughtException (Node's TLSWrap completion runs - // via MakeCallback); don't let it fall through to the Bun-native - // socket handler keep-alive path. + // Node's TLSWrap completion runs via MakeCallback: a listener throw is an + // uncaughtException, not a fall-through to the Bun-native socket handler keep-alive path. reportUncaughtException(e); } } diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 2c7a6a818bfd..26222b41907e 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -429,11 +429,8 @@ class BunWebSocket extends EventEmitter { } once(event, listener) { - // EventEmitter.prototype.once wraps `listener` and calls `this.on(...)`, - // which is the overridden `on` above and arms the native bridge. Arming - // a separate bridge here would mean two bridges for one native event, and - // the second `emit("error", ...)` would find no listener (the once - // wrapper already removed itself) and throw "Unhandled error". + // super.once() calls this.on() which arms the native bridge; arming a second bridge here would + // re-emit after the once wrapper removed itself and throw "Unhandled error". return super.once(event, listener); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4b3f153477b8..0bbf14cd6729 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -300,11 +300,9 @@ 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`: a Node-compat uncaught throw (nextTick/timer drain) - /// would otherwise take the fatal path and terminate the interactive - /// session. Node's own REPL wraps evaluation in a domain for the same - /// reason; here the flag keeps the `uncaught_exception_fatal` branch at - /// print-and-continue so the prompt redraws. + /// 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, @@ -1053,11 +1051,9 @@ impl VirtualMachine { .platform_loop_opt() .map(|h| h.is_active()) .unwrap_or(false); - // A reported-but-unhandled error no longer kills liveness: fatal - // (node-compat) throws exit inside uncaught_exception_fatal, and - // keep-alive reports (Bun.serve/listen handlers, reportError) leave - // servers serving. The counter still arms exit code 1 and skips - // beforeExit at the natural end of the run. + // unhandled_error_counter no longer kills liveness: fatal throws exit inside + // uncaught_exception_fatal and keep-alive reports leave servers serving. The counter + // still arms exit code 1 and skips beforeExit at the natural end of the run. (active as usize) + self.active_tasks + el.tasks.readable_length() @@ -1366,12 +1362,9 @@ impl VirtualMachine { bun_core::env_var::feature_flag::BUN_DESTRUCT_VM_ON_EXIT::get().unwrap_or(false) } - /// Fire `uncaughtException` listeners (via `Bun__handleUncaughtException`); - /// if none claim the error, print it and set `exit_code = 1`, then return. - /// The process keeps running — this is the pre-existing contract every - /// Bun-native call site was written against (`Bun.serve`, `Bun.listen`, - /// `Bun.spawn` ipc/onExit, `Bun.sql`/`Bun.redis` onclose, the shell, - /// `EventLoop::run_callback`, `reportError()`, ...). + /// Fire `uncaughtException` listeners; if none claim the error, print it, set `exit_code = 1`, + /// and return. The process keeps running — this is the keep-alive contract Bun-native callers + /// rely on (`Bun.serve`/`listen`/`spawn`, `EventLoop::run_callback`, `reportError()`, ...). pub fn uncaught_exception( &mut self, global_object: &JSGlobalObject, @@ -1381,16 +1374,9 @@ impl VirtualMachine { self.uncaught_exception_impl(global_object, err, origin, false) } - /// Node's fatal path: if no listener/domain/capture callback claims the - /// error, print it, emit `'exit'`, and `process.exit(1)` without another - /// loop turn (queued I/O, timers, immediates, later ticks never run). - /// For Node-compat uncaught throws where the caller's task is dead: - /// `Bun__reportUnhandledError` (nextTick drain, setTimeout/setInterval, - /// `jsFunctionReportUncaughtException` for `guardCallback`/fs/dns/crypto - /// and `node:net` onread, `napi_fatal_exception`, `node:events` error with - /// no listener, JSC's `reportUncaughtExceptionAtEventLoop` VM hook), - /// `--unhandled-rejections=throw`/`strict`, and `Bun.cron` (matches - /// setTimeout). Everything else stays on `uncaught_exception` above. + /// Node's fatal path: if no listener/domain claims the error, print it, emit `'exit'`, and + /// `process.exit(1)` without another loop turn. For Node-compat throws where the caller's task + /// is dead (`Bun__reportUnhandledError`, `--unhandled-rejections=throw`/`strict`, `Bun.cron`). pub fn uncaught_exception_fatal( &mut self, global_object: &JSGlobalObject, @@ -1465,14 +1451,9 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } - // Node's fatal path exits without another loop turn: already - // queued I/O completions, timers, immediates, and later ticks - // never run — only 'exit' listeners do (via process_exit). Print - // through the same reporter the drain path used, then exit. - // Entry-point rejections keep their run_command owner (it already - // exits promptly via exit_with_unhandled_note), watch/hot mode - // keeps the process alive for reload, and a worker falls through - // to route the error to its parent. + // Node's fatal path exits without another loop turn — only 'exit' listeners run. + // Entry-point rejections keep their run_command owner, watch/hot mode stays alive for + // reload, and a worker falls through to route the error to its parent. if fatal_exit && !self.suppress_fatal_uncaught && self.is_main_thread() @@ -1482,14 +1463,9 @@ impl VirtualMachine { self.unhandled_error_counter += 1; self.exit_handler.exit_code = 1; (self.on_unhandled_rejection)(self, global_object, err); - // The drain path would have printed the sourcemap note and - // version footer via run_command's exit_with_unhandled_note; - // process_exit below bypasses that owner, so emit them here. - // Ordering note: process_exit emits 'exit' after this, so an - // exit listener writing to stderr lands after the footer - // (exit_with_unhandled_note puts the footer last). Cosmetic - // only; re-emitting 'exit' here first would double-run - // on_exit's cleanup-hook loop. + // process_exit bypasses run_command's exit_with_unhandled_note, so emit the + // sourcemap note and version footer here. 'exit' fires after this (cosmetic only; + // emitting 'exit' first would double-run on_exit's cleanup-hook loop). bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); bun_core::pretty_errorln!( "\n{}", @@ -3369,16 +3345,9 @@ impl VirtualMachine { if handle_unhandled() { return; } - // Pre-04a67938 this fell through to counter++/print and the - // liveness check wound the process down; that check is gone - // (so Bun.serve keeps serving after a handler error). Take - // the fatal path here like Mode::Throw — Node's default has - // been throw since v15, and an uncaughtException listener can - // still claim it. Watch/hot mode skips the uncaught machinery - // entirely: the reload path (report_exception_in_hot_reloaded - // _module_if_needed) routes every reloaded entry rejection - // through here, and the reload driver owns recovery — the - // plain counter/print tail below keeps the watcher ticking. + // Take the fatal path like Mode::Throw (Node's default since v15; an + // uncaughtException listener can still claim it). Watch/hot mode skips this — + // the reload driver owns recovery and the counter/print tail keeps it ticking. if self.hot_reload == 0 { let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception( global_object, @@ -3392,10 +3361,8 @@ impl VirtualMachine { drain(self); return; } - // uncaught_exception_fatal already bumped the counter and - // printed (via the keep-alive tail) when the fatal gate - // was skipped (REPL / worker); don't fall through and - // print again. + // The keep-alive tail already bumped/printed when the fatal gate was + // skipped (REPL / worker); don't fall through and print again. let _ = self.event_loop_mut().drain_microtasks(); return; } @@ -3443,10 +3410,8 @@ impl VirtualMachine { drain(self); return; } - // Under hot, fall through to the counter/print tail - // unconditionally: under strict, an unhandledRejection - // listener alone does not suppress the uncaught treatment - // (only emit_warning is gated on it). + // Under hot, fall through to the counter/print tail unconditionally: under strict + // an unhandledRejection listener alone does not suppress the uncaught treatment. if !handle_unhandled() { emit_warning(self); } diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index 8388bf45d658..787280a185ad 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -73,10 +73,9 @@ void reportException(JSGlobalObject* lexicalGlobalObject, JSC::Exception* except // exceptionSourceURL = callFrame->sourceURL(); // } - // Remaining callers (JSPerformanceObserverCallback, JSAbortAlgorithm, - // JSErrorHandler, JSDOMPromiseDeferred) are Node-compat callbacks whose - // task is dead; take the fatal path. JSEventListener defers its listener - // throws to nextTick separately so the dispatch loop completes first. + // Remaining callers (JSPerformanceObserverCallback, JSAbortAlgorithm, JSErrorHandler, + // JSDOMPromiseDeferred) are Node-compat callbacks whose task is dead; take the fatal path. + // JSEventListener defers to nextTick separately so its dispatch loop completes first. Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); if (exceptionDetails) { diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index a5d5a09d9dc7..558f410f4dbc 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -120,11 +120,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtException, (JSC::JSGlobalObject * return JSValue::encode(JSC::jsUndefined()); } -// Node's EventTarget catches a listener throw and defers it via -// process.nextTick(() => { throw err }) (lib/internal/event_target.js -// emitUncaughtException) so innerInvokeEventListeners's per-listener loop and -// code after a synchronous dispatchEvent()/abort() complete first, then the -// process fatal-exits on the next tick. +// 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); diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 7d13b008288d..3e9fe36fe931 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -88,15 +88,9 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValu crate::mark_binding!(); if !value.is_termination_exception() { - // This is the one place that opts into Node's fatal path. Callers are - // the nextTick drain, setTimeout/setInterval (NodeTimerObject.cpp), - // jsFunctionReportUncaughtException (guardCallback's routing for - // fs/dns/crypto callback throws), napi_fatal_exception, node:events - // error with no listener, and JSC's reportUncaughtExceptionAtEventLoop - // VM hook (microtask/promise reaction escaped with nothing to catch - // it) — all Node-compat uncaught throws where the caller's task is - // dead. Bun-native frame loops that just want to print and continue - // call Bun__reportError instead, which stays on the keep-alive path. + // The one entry to Node's fatal path: nextTick drain, timers, guardCallback (fs/dns/crypto), + // napi_fatal_exception, node:events unhandled 'error', JSC's reportUncaughtExceptionAtEventLoop. + // Bun-native callers that want print-and-continue use Bun__reportError instead. let _ = global.bun_vm().as_mut().uncaught_exception_fatal( global, value, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 3b0e2ef96fa0..8ab25ae517dc 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1829,10 +1829,8 @@ impl CronJob { return; } let global_ref = vm.global(); - // SAFETY: single JS thread; `&mut` derived via the thread-local - // raw pointer (avoids `&T` → `&mut T` provenance laundering). - // Matches setTimeout (NodeTimerObject): a cron handler throw - // with no uncaughtException listener is fatal. + // SAFETY: single JS thread; `&mut` via the thread-local raw pointer. + // Matches setTimeout (NodeTimerObject): a throw with no listener is fatal. let _ = VirtualMachine::get().as_mut().uncaught_exception_fatal( global_ref, err, diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index 5412b3313ff6..4e3daf9fc82f 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -236,11 +236,8 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.on_before_exit(); } } else { - // Interactive: run the REPL loop. An async throw at the prompt - // (nextTick/timer drain) would otherwise take the fatal path and - // terminate the session; keep the interactive REPL at - // print-and-continue like Node's domain-wrapped REPL. `-e`/`-p` - // take the fatal path like `bun -e`. + // 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()); From 9e8c34caaa78913d7bb31d281f16d7b3a30d9071 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:53:24 +0000 Subject: [PATCH 64/82] net: drop the redundant inner secureConnection catch [allow size] cecb355f upgraded the outer handshake catch to reportUncaughtException too, so the inner try/catch around server.emit('secureConnection', ...) added in 49f69049 is now dead scaffolding. Let the throw reach the outer catch, which also restores Node's control flow (a listener throw unwinds past kSecureConnectDone instead of continuing). --- src/js/node/net.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 26f20e2a6081..a9aabb690e87 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -971,13 +971,7 @@ const ServerHandlers: SocketHandler = { if (typeof connectionListener === "function") { server.prependOnceListener("secureConnection", connectionListener); } - try { - server.emit("secureConnection", self); - } catch (e) { - // Node's TLSWrap completion runs via MakeCallback: a listener throw is an - // uncaughtException, not a fall-through to the Bun-native socket handler keep-alive path. - reportUncaughtException(e); - } + server.emit("secureConnection", self); } } if (self.destroyed) return; From bde8355c11ff3d2965c372240de0efd850a900c4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:11:52 +0000 Subject: [PATCH 65/82] diagnostics_channel,webstreams_adapters: route subscriber/callback throws to nextTick instead of reportError [allow size] Same class as the net.ts handshake catches: global reportError is keep-alive, so with the unhandled_error_counter liveness gate gone a throwing subscriber with pending work hung forever. Node's lib/diagnostics_channel.js does process.nextTick(() => { triggerUncaughtException(err) }); rethrowing from nextTick reaches Bun__reportUnhandledError -> fatal without needing an import. Verified channel.subscribe(() => { throw }) with setInterval exits 1; test-diagnostics-channel-bind-store and pub-sub still pass. --- src/js/internal/webstreams_adapters.ts | 4 +++- src/js/node/diagnostics_channel.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 97246f0b7b1b..8b7b28cde6f8 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -133,7 +133,9 @@ class ReadableFromWeb extends Readable { try { callback(error); } catch (error) { - globalThis.reportError(error); + process.nextTick(() => { + throw error; + }); } } } diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 4c26ff1017fe..053e99a5b4b0 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; + }); } } } From 68784f28aaa45bc8dd8ab4a74a0af8e168e4e27a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:14:14 +0000 Subject: [PATCH 66/82] [autofix.ci] apply automated fixes --- src/js/node/diagnostics_channel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 053e99a5b4b0..bb86c689330c 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -147,8 +147,8 @@ class ActiveChannel { onMessage(data, this.name); } catch (err) { process.nextTick(() => { - throw err; - }); + throw err; + }); } } } From 9568cd7a2d82c78148afc530a276f5c693fa9810 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:10:43 +0000 Subject: [PATCH 67/82] test: disable leak detection in the callback-throw matrix children [allow size] crypto.hkdf/crypto.sign exited 134 on x64-asan (build 89287): the child deliberately process.exit(0)s from inside the job callback, which skips native job cleanup, and LSAN aborts on the leaked job context (same class as the node_crypto_binding.rs leak it flags as pre-existing on main). detect_leaks=0 for these children, following the established pattern (bun-server.test.ts, fs.watchFile.test.ts). --- test/js/node/fs/fs.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index e7f154cbbe84..fe523b7d87b6 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -6012,7 +6012,19 @@ describe("a throw from a node-style callback is an uncaughtException", () => { const dirLit = JSON.stringify(dir); async function runScript(source: string) { - await using proc = Bun.spawn({ cmd: [bunExe(), "-e", source], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: { + ...bunEnv, + // Every child exits via process.exit(0) from inside a callback, which + // by design skips native job cleanup (the crypto jobs leak their + // context box when exit happens mid-completion; same class as the + // node_crypto_binding.rs leak LSAN flags on main). + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // stderr is returned (not asserted) so a failing case can show the // child's stack trace; debug builds emit benign startup noise there. From 51e9d7431f0d293ac86c8c682496cb11f4ce3c20 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 5 Aug 2026 12:32:09 -0700 Subject: [PATCH 68/82] html-rewriter: pin node-parity timing for the detached-rejection test With entry-point rejection semantics matching node (verified on node v24.18.0: the process dies at the unhandled rejection before a suspended entry module resumes), the rewrite output is never printed. Install an unhandledRejection listener with an UNHANDLED: marker so the test still discriminates the process-global rejection path from a regression back to transform() capturing the rejection. No-Verification-Needed: test-only diff (html-rewriter.test.js); CI drives the runtime --- test/js/workerd/html-rewriter.test.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 51425aeb8152..96ee664dc27d 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -687,7 +687,11 @@ describe("HTMLRewriter", () => { cmd: [ bunExe(), "-e", - `const r = new HTMLRewriter() + `process.on("unhandledRejection", err => { + console.error("UNHANDLED:" + err.message); + process.exit(1); + }); + const r = new HTMLRewriter() .on("p", { async element(e) { (async () => { throw new Error("detached"); })(); await Bun.sleep(5); @@ -701,10 +705,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({ - stdout: "BODY:

ok

", + // The rejection reaches the process-global unhandledRejection path (the + // UNHANDLED: marker), not transform()'s synchronous throw. As in node + // (v24 dies at the unhandled rejection, before the suspended entry + // module resumes), the rewrite output is never printed. + expect({ stdout: stdout.trim(), reported: stderr.includes("UNHANDLED:detached"), exitCode }).toEqual({ + stdout: "", reported: true, exitCode: 1, }); From ba73787a3cf61d16b618070109d5319a340c8862 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:07:01 +0000 Subject: [PATCH 69/82] test: drop the timer variant from the repl keep-alive test [allow size] After the main merge the piped-stdin repl loop no longer waits for timers between lines, so the 0ms setTimeout throw may never fire before .exit (fired on debug, not on release; build 90257 failed on 7 lanes). The nextTick case alone pins suppress_fatal_uncaught: pre-fix the first throw hard-exits and REPL-SURVIVED:42 never prints. The tick error's position is build-dependent (between lines on debug, at exit drain on release), so assert presence only. --- test/js/bun/repl/repl.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 962c975661ef..03264035fb87 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -344,21 +344,23 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); - test("an async throw from nextTick/setTimeout keeps the session alive", async () => { - // The nextTick/timer drain routes through Bun__reportUnhandledError, which + test("an async throw from nextTick keeps the session alive", async () => { + // The nextTick drain routes through Bun__reportUnhandledError, which // opts into Node's fatal exit under `bun run`. The REPL sets // suppress_fatal_uncaught so the prompt redraws (Node's REPL wraps - // evaluation in a domain for the same effect). + // evaluation in a domain for the same effect). No timer variant: the + // piped-stdin loop does not wait for timers between lines, so a 0ms + // setTimeout may never fire before .exit (build-dependent timing). const { stdout, stderr, exitCode } = await runRepl([ "process.nextTick(() => { throw new Error('from-tick') })", - "setTimeout(() => { throw new Error('from-timer') }, 0)", "'REPL-SURVIVED:' + (7 * 6)", ".exit", ]); const allOutput = stripAnsi(stdout + stderr); + // Reported at some point (between lines on debug, at exit drain on + // release); position is build-dependent, presence is not. expect(allOutput).toContain("from-tick"); - expect(allOutput).toContain("from-timer"); - // The session reached the third line after both throws: it did not + // The session reached the second line after the throw: it did not // hard-exit. The marker cannot appear in a stack trace or version // footer, unlike a bare digit. expect(allOutput).toContain("REPL-SURVIVED:42"); From cc70759c017e66c7c8cff63588c531661c0d5ef6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:25:14 +0000 Subject: [PATCH 70/82] webview: guard wsOnMessage's report on clearExceptionExceptTermination [allow size] The old sink (Bun__reportUnhandledError) filtered termination exceptions downstream; Bun__reportError does not, and wsOnMessage discarded the clear's return value while its two sibling frame loops already guard. Match them: skip the report when the exception is a sticky termination. --- src/runtime/webview/ChromeBackend.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index d162c97a86e8..666915034b01 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -393,7 +393,7 @@ 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(); + if (!catchScope.clearExceptionExceptTermination()) return; Bun__reportError(t.m_global, JSC::JSValue::encode(JSC::JSValue(ex))); } } From 7ce95bb8f8e9a9d65b34ee493aa663b7ea946011 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:03:40 +0000 Subject: [PATCH 71/82] runtime: drop the outer hot gates from Mode::Strict/Throw rejection arms [allow size] The inner fatal gate (hot_reload == 0 inside uncaught_exception_impl) already prevents process_exit under watch/hot and falls to the keep-alive print, so the outer guards' only net effect was skipping Bun__handleUncaughtException: process.on('uncaughtException') listeners stopped receiving unhandled rejections under --hot with --unhandled-rejections=throw/strict (pre-PR both arms dispatched unconditionally). Verified under --hot: the listener fires (CAUGHT:x) and the watcher stays alive with and without a listener; non-hot fatal-exit tests unchanged. --- src/jsc/VirtualMachine.rs | 72 +++++++++++++++------------------------ 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 17217bf4d979..b8197f52a153 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3434,61 +3434,45 @@ impl VirtualMachine { return; } Mode::Strict => { - // Watch/hot mode: the reload driver owns recovery (see the - // Mode::Bun comment); skip the uncaught machinery. - if self.hot_reload == 0 { - let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception( - global_object, - reason, - ); - let _ = self.uncaught_exception_fatal( - global_object, - wrapped, - UncaughtExceptionOrigin::Rejection, - ); - let handled = handle_unhandled(); - if !handled { - emit_warning(self); - } - drain(self); - return; - } - // Under hot, fall through to the counter/print tail unconditionally: under strict - // an unhandledRejection listener alone does not suppress the uncaught treatment. + // Unconditional, including under watch/hot: the fatal gate + // inside uncaught_exception_impl already skips process_exit + // there and falls to the keep-alive print, while the + // uncaughtException listener dispatch still runs (Node routes + // strict-mode rejections to uncaughtException regardless). + let wrapped = + wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); + let _ = self.uncaught_exception_fatal( + global_object, + wrapped, + UncaughtExceptionOrigin::Rejection, + ); if !handle_unhandled() { emit_warning(self); } + drain(self); + return; } Mode::Throw => { if handle_unhandled() { drain(self); return; } - // Watch/hot mode: see the Mode::Bun comment. - if self.hot_reload == 0 { - 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; - } - // Same as Mode::Bun: the keep-alive tail already printed - // when the fatal gate was skipped (REPL / worker). - let _ = self.event_loop_mut().drain_microtasks(); - 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() { + // Unconditional, including under watch/hot (see Mode::Strict). + 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; } + // The keep-alive tail already printed when the fatal gate was + // skipped (REPL / worker / hot); don't fall through and print + // again. + let _ = self.event_loop_mut().drain_microtasks(); + return; } } self.unhandled_error_counter += 1; From 658d499d94302b5efd4b3e99deae2d1717955144 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:47:57 +0000 Subject: [PATCH 72/82] napi: route async_work complete and finalizer throws to the fatal path [allow size] Node runs both through CallbackIntoModule, which calls TriggerUncaughtException on throw: the process prints the error and exits 1 unless an uncaughtException handler intervenes. Our keep-alive report let these throws keep the process running. The complete-callback site that used report_active_exception_as_unhandled now takes the exception and calls uncaught_exception_fatal directly, minus termination exceptions; the shared helper keeps its keep-alive behavior for EventLoop::run_callback and the Bun-native callers. Verified: "can throw an exception from an async_complete_callback" passes. The four napi.test.ts 5000ms timeouts here are debug-ASAN slowness, not this change: the orphan-leak fixture prints exactly the expected output in 10.7s when run directly, "has the right lifetime" fails identically with this change stashed, and the define_class case spawns eight child processes. --- src/runtime/napi/napi_body.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index c6f2f514deb4..200eb6f245e0 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -1865,14 +1865,24 @@ impl napi_async_work { // SAFETY: env is valid for the duration of this call. let env_ref = unsafe { &*env }; + // Node's AfterThreadPoolWork runs the complete callback via + // CallbackIntoModule -> TriggerUncaughtException (fatal); see + // the node_api.cc link above. if let Some(exception) = env_ref.get_and_clear_pending_exception() { - let _ = vm.uncaught_exception( + let _ = vm.uncaught_exception_fatal( global, exception, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, ); } else if global.has_exception() { - global.report_active_exception_as_unhandled(jsc::JsError::Thrown); + let exception = global.take_exception(jsc::JsError::Thrown); + if !exception.is_termination_exception() { + let _ = vm.uncaught_exception_fatal( + global, + exception, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + } } } } @@ -2322,8 +2332,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) }; + // Node runs finalizers via CallbackIntoModule (fatal on throw), like + // the async_work complete path above. if let Some(exception) = env_ref.to_js().try_take_exception() { - let _ = env_ref.to_js().bun_vm().as_mut().uncaught_exception( + let _ = env_ref.to_js().bun_vm().as_mut().uncaught_exception_fatal( env_ref.to_js(), exception, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, @@ -2331,7 +2343,7 @@ impl Finalizer { } if let Some(exception) = env_ref.get_and_clear_pending_exception() { - let _ = env_ref.to_js().bun_vm().as_mut().uncaught_exception( + let _ = env_ref.to_js().bun_vm().as_mut().uncaught_exception_fatal( env_ref.to_js(), exception, bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, From 4b143eb72d10663d2c89f7061b3f3a017bbfcfca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:21:45 +0000 Subject: [PATCH 73/82] fs,dgram: route watcher and socket listener throws to the fatal path [allow size] fs.watch, fs.watchFile and dgram 'message' listeners reached native through unguarded callbacks, so a throw was reported keep-alive; with the liveness counter gone the watcher or bound socket ref then kept the loop alive and the process hung after printing. Node fatal-exits all three (FSEventWrap, StatWatcher and UDPWrap invoke them via MakeCallback), as did bun 1.4.0 via the counter gate. Wrap the bound listeners in guardCallback at the node-compat layer, like the fs/dns one-shot callbacks: watch.ts and watchfile.ts at construction, dgram.ts on the data/drain/error callbacks it hands Bun.udpSocket, leaving the native surfaces' keep-alive contract alone. The close-from-close watch test now installs an "error" listener: the abort path delivers an "error" event whose unhandled throw native previously swallowed with clear_exception; guarded, it reports as uncaught like node. --- src/js/internal/fs/watch.ts | 7 +++- src/js/internal/fs/watchfile.ts | 6 ++- src/js/node/dgram.ts | 19 +++++---- test/js/node/dgram/node-dgram.test.js | 27 +++++++++++++ test/js/node/watch/fs.watch.test.ts | 52 +++++++++++++++++++++++++ test/js/node/watch/fs.watchFile.test.ts | 27 +++++++++++++ 6 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index 0a0f8e3d2cdc..a56ee6cbd8f4 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,11 @@ class FSWatcher extends EventEmitter { this.#ignoreMatcher = createIgnoreMatcher(options?.ignore); this.#listener = listener; try { - this.#watcher = fs.watch(path, options || {}, this.#onEvent.bind(this)); + // guardCallback: a throw from a "change"/"error" listener is a fatal + // uncaught exception, as in node (FSEventWrap invokes onchange via + // MakeCallback). Without it the watcher's ref keeps the loop alive and + // the process hangs after reporting. + 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..92749f27a876 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,10 @@ class StatWatcher extends EventEmitter { constructor(path, options) { super(); - this._handle = fs.watchFile(path, options, this.#onChange.bind(this)); + // guardCallback: a throwing "change" listener is a fatal uncaught + // exception, as in node (StatWatcher invokes onchange via MakeCallback). + // Without it the scheduler keeps polling and the process hangs. + this._handle = fs.watchFile(path, options, guardCallback(this.#onChange.bind(this))); } #onChange(curr, prev) { diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 6ed0fe9b9b56..4afb2ec839dc 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -44,7 +44,7 @@ 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 } = require("internal/shared"); const { validateString, validateNumber, @@ -706,7 +706,12 @@ function startBunSocket(self, state, createOptions) { Bun.udpSocket({ ...createOptions, socket: { - data: (_socket, data, port, address, flags) => { + // guardCallback: a throw from a "message"/"error" listener (or a send + // callback run by drain) is a fatal uncaught exception, as in node + // (UDPWrap invokes these via MakeCallback). Without it the bound + // socket's ref keeps the loop alive and the process hangs after + // reporting. + data: guardCallback((_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"; @@ -719,11 +724,11 @@ function startBunSocket(self, state, createOptions) { 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 @@ -739,7 +744,7 @@ function startBunSocket(self, state, createOptions) { return; } self.emit("error", error); - }, + }), }, }).$then( socket => { diff --git a/test/js/node/dgram/node-dgram.test.js b/test/js/node/dgram/node-dgram.test.js index 39181b8b5192..df812d9fdb9d 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"; } + +// A throw from a "message" listener is a fatal uncaught exception, as in node. +// Previously it was reported but the bound socket's ref kept the event loop +// alive, so the process hung. +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 [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("dgram-boom"); + expect(exitCode).toBe(1); +}, 15_000); diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 6eb90798b6bc..8c07e2e40ec3 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -421,6 +421,9 @@ describe("fs.watch", () => { try { const ac = new AbortController(); const watcher = fs.watch(pathToFileURL(filepath), { signal: ac.signal }); + // The abort delivers an unhandled "error" (AbortError), which is an + // uncaught exception as in node; this test is about close-from-close. + watcher.once("error", () => {}); watcher.once("close", () => { try { @@ -1621,3 +1624,52 @@ test("fs.watch wrapper reference survives GC across event, abort and close paths expect(stdout.trim()).toBe("OK"); expect(exitCode).toBe(0); }, 30_000); + +// A throw from a watch callback (or a "change" listener) is a fatal uncaught +// exception, as in node. Previously it was reported but the watcher's ref kept +// the event loop alive, so the process hung. +test("fs.watch callback throw is a fatal uncaught exception", async () => { + const fixture = ` + const fs = require("node:fs"); + const dir = fs.mkdtempSync(require("node:os").tmpdir() + "/watch-throw-"); + 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 [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("watch-boom"); + expect(exitCode).toBe(1); +}, 15_000); + +test("fs.watch callback throw reaches an uncaughtException handler", async () => { + const fixture = ` + const fs = require("node:fs"); + const dir = fs.mkdtempSync(require("node:os").tmpdir() + "/watch-caught-"); + 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, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain("CAUGHT:watch-boom"); + expect(exitCode).toBe(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..a65b07f9d932 100644 --- a/test/js/node/watch/fs.watchFile.test.ts +++ b/test/js/node/watch/fs.watchFile.test.ts @@ -496,3 +496,30 @@ describe("fs.watchFile", () => { }); }, 30_000); }); + +// A throw from a watchFile listener is a fatal uncaught exception, as in node. +// Previously it was reported but the stat poller kept the event loop alive, so +// the process hung (and kept polling and rethrowing). +test("fs.watchFile listener throw is a fatal uncaught exception", async () => { + const fixture = ` + const fs = require("node:fs"); + const path = require("node:path"); + const dir = fs.mkdtempSync(require("node:os").tmpdir() + "/watchfile-throw-"); + const file = path.join(dir, "target.txt"); + fs.writeFileSync(file, "0"); + 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 [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("watchfile-boom"); + expect(exitCode).toBe(1); +}, 15_000); From fdb7ab5d6f25a6a130eb48a2069938c1e1f6fc0f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:36:28 +0000 Subject: [PATCH 74/82] fs: deliver watcher aborts as "abort" so the lenient error emit survives the guard [allow size] node's fs.watch emits only "close" on abort; the AbortError "error" event is bun's own, and its unhandled ERR_UNHANDLED_ERROR was swallowed natively before the listener was guarded. Guarded, it became a fatal uncaught exception and test-fs-watch-abort-signal.js (which installs no "error" listener, as node allows) exited 1. emit_abort now tags the delivery "abort" and the JS layer emits the "error" event inside a try/catch, so the abort event keeps its old lenient contract while real watcher events and errors stay on the fatal path. fs.promises.watch reads signal.aborted before draining its queue, so the retagged entry stays unreachable there. --- src/js/internal/fs/watch.ts | 9 +++++++++ src/runtime/node/node_fs_watcher.rs | 5 ++++- test/js/node/watch/fs.watch.test.ts | 3 --- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index a56ee6cbd8f4..347f512f64d0 100644 --- a/src/js/internal/fs/watch.ts +++ b/src/js/internal/fs/watch.ts @@ -189,6 +189,15 @@ class FSWatcher extends EventEmitter { this.emit("close", filenameOrError); }); return; + } else if (eventType === "abort") { + // The abort reason arrives as an "error" event (node emits only "close" + // on abort). With no "error" listener the emitter's throw stays + // swallowed, since node never emits this event at all; a real "error" + // event below keeps node's fatal unhandled behavior. + try { + this.emit("error", filenameOrError); + } catch {} + return; } else if (eventType === "error") { // Next.js/watchpack ends up watching paths it does not have access to, // which surfaces here as EACCES errors. Rewriting the code to EPERM diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index 4c6423a47270..4c545ddf59d2 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -793,8 +793,11 @@ impl FSWatcher { if let Some(listener) = js::listener_get_cached(js_this) { listener.ensure_still_alive(); let global_this = self.global_this; + // "abort", not "error": the JS layer emits the abort reason as + // a lenient "error" event (node emits only "close" on abort), + // while a real "error" event's unhandled throw is fatal. let args = [ - EventType::Error.to_js(&global_this), + EventType::Abort.to_js(&global_this), if err.is_empty_or_undefined_or_null() { CommonAbortReason::UserAbort.to_js(&global_this) } else { diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 8c07e2e40ec3..f991e168aeff 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -421,9 +421,6 @@ describe("fs.watch", () => { try { const ac = new AbortController(); const watcher = fs.watch(pathToFileURL(filepath), { signal: ac.signal }); - // The abort delivers an unhandled "error" (AbortError), which is an - // uncaught exception as in node; this test is about close-from-close. - watcher.once("error", () => {}); watcher.once("close", () => { try { From 3d04722c54dbb85b8c5dc76d01d787e87d4f3c07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:46:22 +0000 Subject: [PATCH 75/82] Trim comments to node-source/spec references --- src/js/internal/debugger.ts | 3 -- src/js/internal/fs/watch.ts | 8 ----- src/js/internal/fs/watchfile.ts | 3 -- src/js/node/dgram.ts | 5 --- src/js/node/dns.ts | 4 --- src/js/node/net.ts | 2 -- src/js/thirdparty/ws.js | 5 --- src/jsc/VirtualMachine.rs | 34 ------------------ src/jsc/bindings/JSDOMExceptionHandling.cpp | 3 -- src/jsc/bindings/webcore/JSEventListener.cpp | 5 --- src/jsc/virtual_machine_exports.rs | 3 -- src/runtime/api/cron.rs | 1 - src/runtime/napi/napi_body.rs | 5 --- src/runtime/node/node_fs_watcher.rs | 3 -- test/js/bun/repl/repl.test.ts | 13 ------- test/js/node/dgram/node-dgram.test.js | 3 -- test/js/node/dns/node-dns.test.js | 3 -- test/js/node/process/process.test.js | 37 -------------------- test/js/node/watch/fs.watch.test.ts | 3 -- test/js/node/watch/fs.watchFile.test.ts | 3 -- test/js/workerd/html-rewriter.test.js | 4 --- 21 files changed, 150 deletions(-) diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index c5d19917e0f5..f7b3374737d9 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -892,9 +892,6 @@ function reset(): string { return ""; } -// Bun.write returns a promise; the banner writes are best-effort and a -// rejected stderr write (racing writers on a piped stderr, seen on Windows) -// must not become a fatal unhandled rejection. function kIgnoreWriteError(): void {} function notify(options): void { diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index 347f512f64d0..86454a08c24b 100644 --- a/src/js/internal/fs/watch.ts +++ b/src/js/internal/fs/watch.ts @@ -159,10 +159,6 @@ class FSWatcher extends EventEmitter { this.#ignoreMatcher = createIgnoreMatcher(options?.ignore); this.#listener = listener; try { - // guardCallback: a throw from a "change"/"error" listener is a fatal - // uncaught exception, as in node (FSEventWrap invokes onchange via - // MakeCallback). Without it the watcher's ref keeps the loop alive and - // the process hangs after reporting. this.#watcher = fs.watch(path, options || {}, guardCallback(this.#onEvent.bind(this))); } catch (e: any) { e.path = path; @@ -190,10 +186,6 @@ class FSWatcher extends EventEmitter { }); return; } else if (eventType === "abort") { - // The abort reason arrives as an "error" event (node emits only "close" - // on abort). With no "error" listener the emitter's throw stays - // swallowed, since node never emits this event at all; a real "error" - // event below keeps node's fatal unhandled behavior. try { this.emit("error", filenameOrError); } catch {} diff --git a/src/js/internal/fs/watchfile.ts b/src/js/internal/fs/watchfile.ts index 92749f27a876..9e77a0a1626f 100644 --- a/src/js/internal/fs/watchfile.ts +++ b/src/js/internal/fs/watchfile.ts @@ -23,9 +23,6 @@ class StatWatcher extends EventEmitter { constructor(path, options) { super(); - // guardCallback: a throwing "change" listener is a fatal uncaught - // exception, as in node (StatWatcher invokes onchange via MakeCallback). - // Without it the scheduler keeps polling and the process hangs. this._handle = fs.watchFile(path, options, guardCallback(this.#onChange.bind(this))); } diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 4afb2ec839dc..42c41e2ab46d 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -706,11 +706,6 @@ function startBunSocket(self, state, createOptions) { Bun.udpSocket({ ...createOptions, socket: { - // guardCallback: a throw from a "message"/"error" listener (or a send - // callback run by drain) is a fatal uncaught exception, as in node - // (UDPWrap invokes these via MakeCallback). Without it the bound - // socket's ref keeps the loop alive and the process hangs after - // reporting. data: guardCallback((_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`. diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index 7f6f2f750079..2e4de55eaf9d 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -287,8 +287,6 @@ function lookup(hostname, options, callback) { validateLookupOptions(options); if (!hostname) { - // Node v26.3.0 throws synchronously without invoking the callback - // (lib/dns.js lookup); the old warn-and-succeed branch predates that. throw $ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string"); } @@ -732,8 +730,6 @@ const promises = { validateLookupOptions(options); if (!hostname) { - // Node v26.3.0's promises lookup is an async function, so the same - // ERR_INVALID_ARG_VALUE surfaces as a rejection here. return Promise.$reject($ERR_INVALID_ARG_VALUE("hostname", hostname, "must be a non-empty string")); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 74b1f303db5c..a246803db53f 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -300,8 +300,6 @@ function onClientHandshakeComplete(self, socket, verifyError) { self._secureEstablished = true; self[kVerifyError] = verifyError ?? null; self.alpnProtocol = socket.alpnProtocol; - // Node's TLSWrap completion runs via MakeCallback so a listener throw is an uncaughtException; - // mirror that here instead of falling through to Bun.connect's handshake-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 diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 26222b41907e..19c977a71128 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -380,9 +380,6 @@ class BunWebSocket extends EventEmitter { return super.on(event, listener); } const mask = 1 << eventIds[event]; - // Add a persistent native bridge if one isn't already forwarding this - // event. once() reaches here via super.once() -> this.on(), so there is - // no once-only bridge shape any more. if (mask && (this.#eventId & mask) !== mask) { this.#eventId |= mask; if (event === "open") { @@ -429,8 +426,6 @@ class BunWebSocket extends EventEmitter { } once(event, listener) { - // super.once() calls this.on() which arms the native bridge; arming a second bridge here would - // re-emit after the once wrapper removed itself and throw "Unhandled error". return super.once(event, listener); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index b8197f52a153..7b232aab384c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1090,9 +1090,6 @@ impl VirtualMachine { .platform_loop_opt() .map(|h| h.is_active()) .unwrap_or(false); - // unhandled_error_counter no longer kills liveness: fatal throws exit inside - // uncaught_exception_fatal and keep-alive reports leave servers serving. The counter - // still arms exit code 1 and skips beforeExit at the natural end of the run. (active as usize) + self.active_tasks + el.tasks.readable_length() @@ -1401,9 +1398,6 @@ impl VirtualMachine { bun_core::env_var::feature_flag::BUN_DESTRUCT_VM_ON_EXIT::get().unwrap_or(false) } - /// Fire `uncaughtException` listeners; if none claim the error, print it, set `exit_code = 1`, - /// and return. The process keeps running — this is the keep-alive contract Bun-native callers - /// rely on (`Bun.serve`/`listen`/`spawn`, `EventLoop::run_callback`, `reportError()`, ...). pub fn uncaught_exception( &mut self, global_object: &JSGlobalObject, @@ -1413,9 +1407,6 @@ impl VirtualMachine { self.uncaught_exception_impl(global_object, err, origin, false) } - /// Node's fatal path: if no listener/domain claims the error, print it, emit `'exit'`, and - /// `process.exit(1)` without another loop turn. For Node-compat throws where the caller's task - /// is dead (`Bun__reportUnhandledError`, `--unhandled-rejections=throw`/`strict`, `Bun.cron`). pub fn uncaught_exception_fatal( &mut self, global_object: &JSGlobalObject, @@ -1490,9 +1481,6 @@ impl VirtualMachine { unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; panic!("made it past process.exit()"); } - // Node's fatal path exits without another loop turn — only 'exit' listeners run. - // Entry-point rejections keep their run_command owner, watch/hot mode stays alive for - // reload, and a worker falls through to route the error to its parent. if fatal_exit && !self.suppress_fatal_uncaught && self.is_main_thread() @@ -1502,20 +1490,12 @@ impl VirtualMachine { self.unhandled_error_counter += 1; self.exit_handler.exit_code = 1; (self.on_unhandled_rejection)(self, global_object, err); - // process_exit bypasses run_command's exit_with_unhandled_note, so emit the - // sourcemap note and version footer here. 'exit' fires after this (cosmetic only; - // emitting 'exit' first would double-run on_exit's cleanup-hook loop). bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); bun_core::pretty_errorln!( "\n{}", bun_core::Global::unhandled_error_bun_version_string, ); - // See the recursion-guard note above: drop it before - // process_exit emits 'exit'. self.is_handling_uncaught_exception = false; - // Arm the wind-down flag so a throwing 'exit' listener - // re-enters via the block above (run_error_handler, no - // repeated footer) instead of this one. self.exit_on_uncaught_exception = true; // SAFETY: see above. unsafe { (hooks.process_exit)(global_object.as_ptr(), 1) }; @@ -3388,9 +3368,6 @@ impl VirtualMachine { if handle_unhandled() { return; } - // Take the fatal path like Mode::Throw (Node's default since v15; an - // uncaughtException listener can still claim it). Watch/hot mode skips this — - // the reload driver owns recovery and the counter/print tail keeps it ticking. if self.hot_reload == 0 { let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception( global_object, @@ -3404,8 +3381,6 @@ impl VirtualMachine { drain(self); return; } - // The keep-alive tail already bumped/printed when the fatal gate was - // skipped (REPL / worker); don't fall through and print again. let _ = self.event_loop_mut().drain_microtasks(); return; } @@ -3434,11 +3409,6 @@ impl VirtualMachine { return; } Mode::Strict => { - // Unconditional, including under watch/hot: the fatal gate - // inside uncaught_exception_impl already skips process_exit - // there and falls to the keep-alive print, while the - // uncaughtException listener dispatch still runs (Node routes - // strict-mode rejections to uncaughtException regardless). let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); let _ = self.uncaught_exception_fatal( @@ -3457,7 +3427,6 @@ impl VirtualMachine { drain(self); return; } - // Unconditional, including under watch/hot (see Mode::Strict). let wrapped = wrap_unhandled_rejection_error_for_uncaught_exception(global_object, reason); if self.uncaught_exception_fatal( @@ -3468,9 +3437,6 @@ impl VirtualMachine { drain(self); return; } - // The keep-alive tail already printed when the fatal gate was - // skipped (REPL / worker / hot); don't fall through and print - // again. let _ = self.event_loop_mut().drain_microtasks(); return; } diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index c98d70b61312..9db6f0f283de 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -73,9 +73,6 @@ void reportException(JSGlobalObject* lexicalGlobalObject, JSC::Exception* except // exceptionSourceURL = callFrame->sourceURL(); // } - // Remaining callers (JSPerformanceObserverCallback, JSAbortAlgorithm, JSErrorHandler, - // JSDOMPromiseDeferred) are Node-compat callbacks whose task is dead; take the fatal path. - // JSEventListener defers to nextTick separately so its dispatch loop completes first. Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); if (exceptionDetails) { diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index 386c3c4457e5..1f71e206988d 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -114,8 +114,6 @@ void JSEventListener::visitJSFunction(SlotVisitor& visitor) { visitJSFunctionImp JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtException, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { - // Reached from a nextTick with no dispatch loop above it; the caller's - // task is dead, so take Node's fatal path. Bun__reportUnhandledError(lexicalGlobalObject, JSValue::encode(callFrame->argument(0))); return JSValue::encode(JSC::jsUndefined()); } @@ -130,9 +128,6 @@ static void queueUncaughtExceptionNextTick(JSC::JSGlobalObject* lexicalGlobalObj Bun::Process* process = globalObject->processObject(); auto func = JSFunction::create(vm, globalObject, 1, String(), jsFunctionEmitUncaughtException, JSC::ImplementationVisibility::Private); process->queueNextTick(lexicalGlobalObject, func, exception); - // queueNextTick calls process.nextTick as JS; if that throws (e.g. - // termination) while reporting the original error, drop it so the caller's - // scope does not see an unchecked exception. (void)scope.tryClearException(); } diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 3e9fe36fe931..3f0778c71346 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -88,9 +88,6 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValu crate::mark_binding!(); if !value.is_termination_exception() { - // The one entry to Node's fatal path: nextTick drain, timers, guardCallback (fs/dns/crypto), - // napi_fatal_exception, node:events unhandled 'error', JSC's reportUncaughtExceptionAtEventLoop. - // Bun-native callers that want print-and-continue use Bun__reportError instead. let _ = global.bun_vm().as_mut().uncaught_exception_fatal( global, value, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index e12da53100ab..4f20a9fff48d 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -1831,7 +1831,6 @@ impl CronJob { } let global_ref = vm.global(); // SAFETY: single JS thread; `&mut` via the thread-local raw pointer. - // Matches setTimeout (NodeTimerObject): a throw with no listener is fatal. let _ = VirtualMachine::get().as_mut().uncaught_exception_fatal( global_ref, err, diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 200eb6f245e0..7d616ccfdc76 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -1865,9 +1865,6 @@ impl napi_async_work { // SAFETY: env is valid for the duration of this call. let env_ref = unsafe { &*env }; - // Node's AfterThreadPoolWork runs the complete callback via - // CallbackIntoModule -> TriggerUncaughtException (fatal); see - // the node_api.cc link above. if let Some(exception) = env_ref.get_and_clear_pending_exception() { let _ = vm.uncaught_exception_fatal( global, @@ -2332,8 +2329,6 @@ 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) }; - // Node runs finalizers via CallbackIntoModule (fatal on throw), like - // the async_work complete path above. if let Some(exception) = env_ref.to_js().try_take_exception() { let _ = env_ref.to_js().bun_vm().as_mut().uncaught_exception_fatal( env_ref.to_js(), diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index 4c545ddf59d2..b410b2dc845a 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -793,9 +793,6 @@ impl FSWatcher { if let Some(listener) = js::listener_get_cached(js_this) { listener.ensure_still_alive(); let global_this = self.global_this; - // "abort", not "error": the JS layer emits the abort reason as - // a lenient "error" event (node emits only "close" on abort), - // while a real "error" event's unhandled throw is fatal. let args = [ EventType::Abort.to_js(&global_this), if err.is_empty_or_undefined_or_null() { diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 03264035fb87..c714a4941b07 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -345,27 +345,14 @@ describe.concurrent("Bun REPL", () => { }); test("an async throw from nextTick keeps the session alive", async () => { - // The nextTick drain routes through Bun__reportUnhandledError, which - // opts into Node's fatal exit under `bun run`. The REPL sets - // suppress_fatal_uncaught so the prompt redraws (Node's REPL wraps - // evaluation in a domain for the same effect). No timer variant: the - // piped-stdin loop does not wait for timers between lines, so a 0ms - // setTimeout may never fire before .exit (build-dependent timing). const { stdout, stderr, exitCode } = await runRepl([ "process.nextTick(() => { throw new Error('from-tick') })", "'REPL-SURVIVED:' + (7 * 6)", ".exit", ]); const allOutput = stripAnsi(stdout + stderr); - // Reported at some point (between lines on debug, at exit drain on - // release); position is build-dependent, presence is not. expect(allOutput).toContain("from-tick"); - // The session reached the second line after the throw: it did not - // hard-exit. The marker cannot appear in a stack trace or version - // footer, unlike a bare digit. expect(allOutput).toContain("REPL-SURVIVED:42"); - // The unhandled error was reported, so the eventual `.exit` leaves - // with code 1 (pre-existing behavior). expect(exitCode).toBe(1); }); diff --git a/test/js/node/dgram/node-dgram.test.js b/test/js/node/dgram/node-dgram.test.js index df812d9fdb9d..bddbf1203d96 100644 --- a/test/js/node/dgram/node-dgram.test.js +++ b/test/js/node/dgram/node-dgram.test.js @@ -106,9 +106,6 @@ function getInterface() { return "::%lo"; } -// A throw from a "message" listener is a fatal uncaught exception, as in node. -// Previously it was reported but the bound socket's ref kept the event loop -// alive, so the process hung. test("node:dgram 'message' listener throw is a fatal uncaught exception", async () => { const fixture = ` const dgram = require("node:dgram"); diff --git a/test/js/node/dns/node-dns.test.js b/test/js/node/dns/node-dns.test.js index 2c5f1659417c..f626ee78ac04 100644 --- a/test/js/node/dns/node-dns.test.js +++ b/test/js/node/dns/node-dns.test.js @@ -572,9 +572,6 @@ describe("dns.lookupService", () => { }); }); -// Node v26.3.0 removed the DEP0118 warn-and-succeed path: every falsy -// hostname throws synchronously without invoking the callback, and the -// promises API rejects with the same error. describe("lookup rejects falsy hostnames", () => { it.each([undefined, false, null, NaN, ""])("dns.lookup(%p) throws without calling back", domain => { const callback = jest.fn(); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index f3657aff671e..0bedd53bbbf5 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2608,9 +2608,6 @@ describe("NODE_NO_WARNINGS", () => { }); it("a fatal uncaught exception exits before already-queued work runs", async () => { - // Node's fatal path: print the error, run 'exit' listeners, exit 1 - - // already-queued I/O completions, timers, immediates, later ticks, and - // beforeExit never run. using dir = tempDir("fatal-uncaught-order", { "fatal.js": ` const fs = require("fs"); @@ -2660,10 +2657,6 @@ it("a handled uncaughtException keeps the event loop running", async () => { }); it("a throwing Bun.listen data handler with no error: handler keeps the server alive", async () => { - // Bun-native long-running handlers keep the pre-existing print-and-continue - // contract (matching the Bun.serve HTTP fetch path, which reports via - // on_unhandled_rejection and keeps serving); only Node-compat paths take - // the fatal exit. using dir = tempDir("bun-listen-handler-throw", { "server.js": ` let hits = 0; @@ -2697,17 +2690,12 @@ it("a throwing Bun.listen data handler with no error: handler keeps the server a stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // Both connections reached the handler: the first throw did not exit. expect(stdout.trim().split(/\r?\n/)).toEqual(["DATA-HANDLER-RAN:1", "DATA-HANDLER-RAN:2"]); expect(stderr).toContain("handler-boom"); - // No error: handler and no uncaughtException listener, so the error is - // reported (exit code 1), but only after the loop drained naturally. expect(exitCode).toBe(1); }); it("a Bun.listen error: handler that itself throws keeps the server alive", async () => { - // The sibling branch of the case above: the user supplied an error: handler - // and that handler threw. Same print-and-continue contract. using dir = tempDir("bun-listen-error-handler-throw", { "server.js": ` let hits = 0; @@ -2748,10 +2736,6 @@ it("a Bun.listen error: handler that itself throws keeps the server alive", asyn }); it("a throwing EventTarget listener lets dispatch complete, then fatal-exits next tick", async () => { - // Node's EventTarget catches a listener throw and defers it via - // process.nextTick(() => { throw err }) so later listeners and code after - // dispatchEvent()/abort() run, then the process fatal-exits; a keep-alive - // report would leave the interval ticking forever. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2775,9 +2759,6 @@ it("a throwing EventTarget listener lets dispatch complete, then fatal-exits nex }); it("a rejecting async EventTarget listener fatal-exits next tick", async () => { - // The async-listener rejection is already deferred to nextTick - // (jsFunctionEmitUncaughtExceptionNextTick); that nextTick takes the fatal - // path so pending work never runs. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2802,9 +2783,6 @@ it("a rejecting async EventTarget listener fatal-exits next tick", async () => { it.each([undefined, "throw", "strict"])( "an unhandled rejection fatal-exits with pending work (--unhandled-rejections=%s)", async mode => { - // Node's default is throw since v15. With no unhandledRejection / - // uncaughtException listener, Node fatal-exits 1; a keep-alive report - // would leave the interval ticking forever. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2824,8 +2802,6 @@ it.each([undefined, "throw", "strict"])( ); it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { - // Same keep-alive default as the Bun.listen handlers above, reached - // through EventLoop::run_callback instead of the socket error path. using dir = tempDir("spawn-ipc-throw", { "parent.js": ` const child = Bun.spawn({ @@ -2852,17 +2828,12 @@ it("a throwing Bun.spawn ipc handler keeps the parent alive", async () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // The second ipc message still ran: the first throw did not exit. expect(stdout).toContain("got:second"); expect(stderr).toContain("ipc-boom"); - // Reported error arms exit 1 for the natural end of the run. expect(exitCode).toBe(1); }); it("a throwing Bun.serve websocket message handler keeps the server serving", async () => { - // The error is reported, but the listen socket keeps the loop alive: a - // second connection is served afterwards (previously any unhandled - // report made is_event_loop_alive false and an idle server exited). using dir = tempDir("ws-throw-alive", { "server.js": ` const server = Bun.serve({ @@ -2893,8 +2864,6 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as reader.releaseLock(); const port = parseInt(new TextDecoder().decode(value).trim()); - // First connection: the handler throws; the socket stays open, so don't - // wait on a close event that never comes. const first = new WebSocket("ws://127.0.0.1:" + port); await new Promise((resolve, reject) => { first.onopen = resolve; @@ -2902,8 +2871,6 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as }); first.send("boom"); - // Wait for the reported throw before connecting again, so the second - // connection provably exercises serve-after-throw. let stderrText = ""; const errReader = proc.stderr.getReader(); const errDecoder = new TextDecoder(); @@ -2913,7 +2880,6 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as stderrText += errDecoder.decode(value); } - // Second connection is served normally, after the throw. const echoed = await new Promise((resolve, reject) => { const ws = new WebSocket("ws://127.0.0.1:" + port); ws.onopen = () => ws.send("after"); @@ -2924,9 +2890,6 @@ it("a throwing Bun.serve websocket message handler keeps the server serving", as expect(echoed).toBe("echo:after"); first.close(); - // The server would keep serving forever; tear it down ourselves, then - // drain the remaining stderr through the same reader. The polling loop - // above already established "ws-boom" was reported. proc.kill(); while (true) { const { done } = await errReader.read(); diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index f991e168aeff..4464cce218b1 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -1622,9 +1622,6 @@ test("fs.watch wrapper reference survives GC across event, abort and close paths expect(exitCode).toBe(0); }, 30_000); -// A throw from a watch callback (or a "change" listener) is a fatal uncaught -// exception, as in node. Previously it was reported but the watcher's ref kept -// the event loop alive, so the process hung. test("fs.watch callback throw is a fatal uncaught exception", async () => { const fixture = ` const fs = require("node:fs"); diff --git a/test/js/node/watch/fs.watchFile.test.ts b/test/js/node/watch/fs.watchFile.test.ts index a65b07f9d932..9206556c57eb 100644 --- a/test/js/node/watch/fs.watchFile.test.ts +++ b/test/js/node/watch/fs.watchFile.test.ts @@ -497,9 +497,6 @@ describe("fs.watchFile", () => { }, 30_000); }); -// A throw from a watchFile listener is a fatal uncaught exception, as in node. -// Previously it was reported but the stat poller kept the event loop alive, so -// the process hung (and kept polling and rethrowing). test("fs.watchFile listener throw is a fatal uncaught exception", async () => { const fixture = ` const fs = require("node:fs"); diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 96ee664dc27d..cada02537f81 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -705,10 +705,6 @@ describe("HTMLRewriter", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // The rejection reaches the process-global unhandledRejection path (the - // UNHANDLED: marker), not transform()'s synchronous throw. As in node - // (v24 dies at the unhandled rejection, before the suspended entry - // module resumes), the rewrite output is never printed. expect({ stdout: stdout.trim(), reported: stderr.includes("UNHANDLED:detached"), exitCode }).toEqual({ stdout: "", reported: true, From 06c479b0cf6e4131e0aa92846119250fff40acd8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:01:52 +0000 Subject: [PATCH 76/82] napi,child_process: route threadsafe function and ipc message throws to the fatal path [allow size] A throw from a threadsafe function's JS callback or from a ChildProcess 'message' listener was reported keep-alive; with the liveness counter gone, the TSF's poll ref or the child and channel refs kept the loop alive and the process hung after printing. Node fatal-exits both (TSFN via CallbackIntoModule, enforced by default since node 26; the channel's onread via MakeCallback), as did bun 1.4.0 through the counter gate. The TSFN Js arm takes the exception and calls uncaught_exception_fatal (skipping termination), like the async_work complete path; the C arm now also drains a pending napi exception or a global exception after call_js, which node clears at callback scope close. #emitIpcMessage reroutes through reportUncaughtException at the node:child_process layer, leaving the raw Bun.spawn ipc handler's pinned keep-alive contract alone. The child-side process.on('message') throw already exits 1. --- src/js/node/child_process.ts | 8 +++-- src/runtime/napi/napi_body.rs | 29 +++++++++++++-- .../child_process/child_process_ipc.test.js | 35 ++++++++++++++++++- test/napi/napi-app/tsfn-throw-fixture.js | 12 +++++++ test/napi/napi.test.ts | 20 +++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 test/napi/napi-app/tsfn-throw-fixture.js diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 4af87463bd8f..008d141afb62 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, @@ -1500,7 +1500,11 @@ class ChildProcess extends EventEmitter { } #emitIpcMessage(message, _, handle) { - this.emit("message", message, handle); + try { + this.emit("message", message, handle); + } catch (err) { + reportUncaughtException(err); + } } #send(message, handle, options, callback) { diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 7d616ccfdc76..e17413f5f860 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -2707,9 +2707,16 @@ impl ThreadSafeFunction { return Ok(()); } - let _ = js - .call(global_object, JSValue::UNDEFINED, &[]) - .map_err(|err| global_object.report_active_exception_as_unhandled(err)); + if let Err(err) = js.call(global_object, JSValue::UNDEFINED, &[]) { + let exception = global_object.take_exception(err); + if !exception.is_termination_exception() { + let _ = global_object.bun_vm().as_mut().uncaught_exception_fatal( + global_object, + exception, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + } + } } TsfnCallback::C { js: cb_js, @@ -2724,6 +2731,22 @@ impl ThreadSafeFunction { None => napi_value(0), }; napi_threadsafe_function_call_js(env, js, self.ctx, task); + if let Some(exception) = env_ref.get_and_clear_pending_exception() { + let _ = global_object.bun_vm().as_mut().uncaught_exception_fatal( + global_object, + exception, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + } else if global_object.has_exception() { + let exception = global_object.take_exception(jsc::JsError::Thrown); + if !exception.is_termination_exception() { + let _ = global_object.bun_vm().as_mut().uncaught_exception_fatal( + global_object, + exception, + bun_jsc::virtual_machine::UncaughtExceptionOrigin::Exception, + ); + } + } } } Ok(()) 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..761fb06009b6 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,36 @@ 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 [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("cp-message-boom"); + expect(exitCode).toBe(1); +}); 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 688765c651c1..1970341c3b02 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -596,6 +596,26 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(result).toContain("callback 1\nmicrotask 1\ncallback 2\nmicrotask 2\ncallback 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. From 9298497160bb6111d13ef9d3ffb4ff4c8345f73d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:28:47 +0000 Subject: [PATCH 77/82] test,fs: skip abort entries in promises.watch filter, parent-owned temp dirs, full pipe drains [allow size] fs.promises.watch's event filter now skips "abort" like "close" and "error", so the retagged abort delivery is not run through the ignore matcher before the signal.aborted check discards it. The watch and watchFile throw fixtures create their directories in the parent with tempDir and thread the path in, since the fatal exit leaves child-created mkdtemp dirs behind. The five new subprocess tests drain both pipes and assert the combined object, so a regression shows the child's output instead of just the exit code. --- src/js/node/fs.promises.ts | 8 ++++++- .../child_process/child_process_ipc.test.js | 9 ++++--- test/js/node/dgram/node-dgram.test.js | 9 ++++--- test/js/node/watch/fs.watch.test.ts | 24 ++++++++++++------- test/js/node/watch/fs.watchFile.test.ts | 15 ++++++------ 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index f973f2297264..05a88428ef10 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -98,7 +98,13 @@ function watch( } const watcher = fs.watch(filename, options || {}, (eventType: string, filename: string | Buffer | undefined) => { - if (eventType !== "close" && eventType !== "error" && filename != null && ignoreMatcher?.(filename)) { + if ( + eventType !== "close" && + eventType !== "error" && + eventType !== "abort" && + filename != null && + ignoreMatcher?.(filename) + ) { return; } queue.push({ __proto__: null, eventType, filename }); 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 761fb06009b6..60c064c30dd3 100644 --- a/test/js/node/child_process/child_process_ipc.test.js +++ b/test/js/node/child_process/child_process_ipc.test.js @@ -42,7 +42,10 @@ test("a throwing 'message' listener is a fatal uncaught exception", async () => stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("cp-message-boom"); - expect(exitCode).toBe(1); + 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 bddbf1203d96..0f56c09bd04e 100644 --- a/test/js/node/dgram/node-dgram.test.js +++ b/test/js/node/dgram/node-dgram.test.js @@ -125,7 +125,10 @@ test("node:dgram 'message' listener throw is a fatal uncaught exception", async stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("dgram-boom"); - expect(exitCode).toBe(1); + 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/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 4464cce218b1..ee60d6100dc9 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -1623,9 +1623,10 @@ test("fs.watch wrapper reference survives GC across event, abort and close paths }, 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 = fs.mkdtempSync(require("node:os").tmpdir() + "/watch-throw-"); + const dir = ${JSON.stringify(String(dir))}; fs.watch(dir, () => { throw new Error("watch-boom"); }); @@ -1638,15 +1639,19 @@ test("fs.watch callback throw is a fatal uncaught exception", async () => { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("watch-boom"); - expect(exitCode).toBe(1); + 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.watch callback throw reaches an uncaughtException handler", async () => { + using dir = tempDir("watch-caught", {}); const fixture = ` const fs = require("node:fs"); - const dir = fs.mkdtempSync(require("node:os").tmpdir() + "/watch-caught-"); + const dir = ${JSON.stringify(String(dir))}; process.on("uncaughtException", err => { console.log("CAUGHT:" + err.message); watcher.close(); @@ -1663,7 +1668,10 @@ test("fs.watch callback throw reaches an uncaughtException handler", async () => stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(stdout).toContain("CAUGHT:watch-boom"); - expect(exitCode).toBe(0); + 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 9206556c57eb..60d2967a1721 100644 --- a/test/js/node/watch/fs.watchFile.test.ts +++ b/test/js/node/watch/fs.watchFile.test.ts @@ -498,12 +498,10 @@ describe("fs.watchFile", () => { }); 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 path = require("node:path"); - const dir = fs.mkdtempSync(require("node:os").tmpdir() + "/watchfile-throw-"); - const file = path.join(dir, "target.txt"); - fs.writeFileSync(file, "0"); + const file = ${JSON.stringify(path.join(String(dir), "target.txt"))}; fs.watchFile(file, { interval: 20 }, () => { throw new Error("watchfile-boom"); }); @@ -516,7 +514,10 @@ test("fs.watchFile listener throw is a fatal uncaught exception", async () => { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("watchfile-boom"); - expect(exitCode).toBe(1); + 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); From 01aedac3f6cfb2fac59aad1bd5ea9c57619a07b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:35:41 +0000 Subject: [PATCH 78/82] ws,fs: drop the passthrough once() override, gate the abort error emit on listeners [allow size] EventEmitter.once dispatches through this.on, which already hits the overridden on() -> #armAndOn, so the once() override was a pure passthrough after the #armAndOn collapse. The watch abort branch emits only when an "error" listener exists instead of swallowing every throw: no listener stays silent (node emits nothing on abort), and a present listener's throw now reaches guardCallback like the sibling "error" branch. --- src/js/internal/fs/watch.ts | 7 ++++--- src/js/thirdparty/ws.js | 4 ---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index 86454a08c24b..1bac485204b6 100644 --- a/src/js/internal/fs/watch.ts +++ b/src/js/internal/fs/watch.ts @@ -186,9 +186,10 @@ class FSWatcher extends EventEmitter { }); return; } else if (eventType === "abort") { - try { - this.emit("error", filenameOrError); - } catch {} + // node emits only "close" on abort; the "error" delivery is bun's own, + // so with no listener it stays silent instead of ERR_UNHANDLED_ERROR. + // A present listener's throw still reaches guardCallback, like "error". + if (this.listenerCount("error") > 0) this.emit("error", filenameOrError); return; } else if (eventType === "error") { // Next.js/watchpack ends up watching paths it does not have access to, diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 19c977a71128..4dfbea0045d7 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -425,10 +425,6 @@ class BunWebSocket extends EventEmitter { return this.#armAndOn(event, listener); } - once(event, listener) { - return super.once(event, listener); - } - addListener(event, listener) { return this.#armAndOn(event, listener); } From 6f122098c1f5be76597bead10755a49b5523908d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:38:52 +0000 Subject: [PATCH 79/82] fs: guard the promises.watch native listener [allow size] The third consumer of the native fs.watch binding ran the user-supplied ignore matcher unguarded, so a throwing matcher was reported keep-alive and the watcher ref hung the process. guardCallback reroutes it to the fatal path like the FSWatcher and StatWatcher listeners. --- src/js/node/fs.promises.ts | 39 ++++++++++++++++------------- test/js/node/watch/fs.watch.test.ts | 25 ++++++++++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 05a88428ef10..32a7f4dacf99 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,23 +98,27 @@ function watch( }; } - const watcher = fs.watch(filename, options || {}, (eventType: string, filename: string | Buffer | undefined) => { - if ( - eventType !== "close" && - eventType !== "error" && - eventType !== "abort" && - 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" && + eventType !== "abort" && + 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/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index ee60d6100dc9..92f2ea3905d9 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -1647,6 +1647,31 @@ test("fs.watch callback throw is a fatal uncaught exception", async () => { }); }, 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 = ` From c54662ee372ee0ca7010c085c5bbaa79aed50d9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:18:06 +0000 Subject: [PATCH 80/82] fs: report the abort listener error like the sibling emits [allow size] The guarded listener only returns Err for a termination exception now, and report_active_exception_as_unhandled takes it and skips reporting instead of clear_exception dropping termination state on the floor. --- src/runtime/node/node_fs_watcher.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index b410b2dc845a..33266371278b 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -801,8 +801,8 @@ impl FSWatcher { err }, ]; - if listener.call_with_global_this(&global_this, &args).is_err() { - global_this.clear_exception(); + if let Err(e) = listener.call_with_global_this(&global_this, &args) { + global_this.report_active_exception_as_unhandled(e); } } } From db5279bc620161eb7b1e9a5fec391517e9d5ef85 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:02:02 +0000 Subject: [PATCH 81/82] dgram: reroute the data handler throw inline [allow size] Native calls it with five args, past guardCallback's arity fast path, so the per-packet handler carries its own try/catch like child_process's #emitIpcMessage. drain and error stay on guardCallback (zero and one arg). --- src/js/node/dgram.ts | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 42c41e2ab46d..2cf6505f8e5a 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, guardCallback } = require("internal/shared"); +const { + throwNotImplemented, + ErrnoException, + ExceptionWithHostPort, + guardCallback, + reportUncaughtException, +} = require("internal/shared"); const { validateString, validateNumber, @@ -706,20 +712,26 @@ function startBunSocket(self, state, createOptions) { Bun.udpSocket({ ...createOptions, socket: { - data: guardCallback((_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; + // 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) => { + 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: guardCallback(() => { handleDrain.$call(state.handle); }), From a5914e1f65e40759dcd0cdad0a2115de22ea1618 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:31:28 +0000 Subject: [PATCH 82/82] test: let the rewriter transform complete past the handled rejection [allow size] The process.exit(1) in the unhandledRejection handler killed the child mid-transform, dropping the original assertion that the rewrite succeeds despite the detached rejection. A log-only handler pins both: the rejection reaches unhandledRejection and the body still renders. --- test/js/workerd/html-rewriter.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index cada02537f81..1f9b6079566c 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -689,7 +689,6 @@ describe("HTMLRewriter", () => { "-e", `process.on("unhandledRejection", err => { console.error("UNHANDLED:" + err.message); - process.exit(1); }); const r = new HTMLRewriter() .on("p", { async element(e) { @@ -705,10 +704,12 @@ describe("HTMLRewriter", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // 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: "", + stdout: "BODY:

ok

", reported: true, - exitCode: 1, + exitCode: 0, }); });