From 9472c1d3896fc43f608252d044fd04e40fe10c2f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:47:38 +0000 Subject: [PATCH 1/8] napi: read the VM exception slot raw at a gated call's entry, never service a trap there A gated N-API call (NAPI_PREAMBLE) checked for a pending exception through JSC's RETURN_IF_EXCEPTION, which services the VM's traps. A termination another thread requested (worker.terminate()) was then thrown into the VM with only addon frames on the stack, napi_is_exception_pending reported it, napi_get_and_clear_last_exception handed out its value and could not clear it, and node-addon-api escalated the next refused call to napi_fatal_error. The entry check (NapiEnv::entryStatus, shared by the C++ macros and the Rust preamble) now reads the slot as it is: an exception the addon can read answers napi_pending_exception, a pending termination answers as the closed can_call_into_js() gate does, and the two readers never report a termination. The termination is delivered where Node delivers it: in the next script the addon enters, or by the trampoline the addon returns to. --- src/jsc/bindings/napi.cpp | 69 +++++----- src/jsc/bindings/napi.h | 21 ++++ src/runtime/napi/napi_body.rs | 34 ++--- test/napi/napi-app/binding.gyp | 24 ++++ test/napi/napi-app/module.js | 24 ++++ test/napi/napi-app/terminate-probe-worker.js | 8 ++ test/napi/napi-app/terminate_probe_addon.cpp | 126 +++++++++++++++++++ test/napi/napi.test.ts | 18 +++ 8 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 test/napi/napi-app/terminate-probe-worker.js create mode 100644 test/napi/napi-app/terminate_probe_addon.cpp diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 7134dfd34ed0..08d41d1927c2 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -85,11 +85,13 @@ using namespace JSC; using namespace Zig; -// Node: RETURN_STATUS_IF_FALSE(env, env->can_call_into_js(), ...) -#define NAPI_RETURN_IF_CANNOT_CALL_INTO_JS(_env) \ - do { \ - if (!(_env)->canCallIntoJS()) [[unlikely]] \ - return napi_set_last_error(_env, (_env)->cannotCallIntoJSStatus()); \ +// Node's NAPI_PREAMBLE entry check (NapiEnv::entryStatus, which see): the status of an exception the addon can +// read, else of the closed can_call_into_js() gate, read off the VM without servicing its traps. +#define NAPI_RETURN_ENTRY_STATUS_WITH_SCOPE(_env, _scope) \ + do { \ + if (napi_status napi_entry_status__ = (_env)->entryStatus(_scope); \ + napi_entry_status__ != napi_ok) [[unlikely]] \ + return napi_set_last_error(_env, napi_entry_status__); \ } while (0) // Every NAPI function should use this at the start. It does the following: @@ -107,8 +109,7 @@ using namespace Zig; /* PREAMBLE then sees as an unchecked exception. If you need to throw */ \ /* or clear exceptions, make your own scope. */ \ auto napi_preamble_throw_scope__ = DECLARE_TOP_EXCEPTION_SCOPE(_env->vm()); \ - NAPI_RETURN_IF_EXCEPTION(_env); \ - NAPI_RETURN_IF_CANNOT_CALL_INTO_JS(_env); + NAPI_RETURN_ENTRY_STATUS_WITH_SCOPE(_env, napi_preamble_throw_scope__); // Only use this for functions that need their own throw or catch scope. Functions that call into // JS code that might throw should use NAPI_RETURN_IF_EXCEPTION. @@ -118,17 +119,20 @@ using namespace Zig; NAPI_CHECK_ARG(_env, _env); \ } while (0) -// NAPI_PREAMBLE_NO_THROW_SCOPE plus the can_call_into_js gate, for a function Node opens with NAPI_PREAMBLE. -#define NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(_env) \ - do { \ - NAPI_PREAMBLE_NO_THROW_SCOPE(_env); \ - NAPI_RETURN_IF_CANNOT_CALL_INTO_JS(_env); \ +// NAPI_PREAMBLE_NO_THROW_SCOPE plus the entry check, for a function Node opens with NAPI_PREAMBLE. The scope it +// reads the VM through is closed again before the function opens its own. +#define NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(_env) \ + do { \ + NAPI_PREAMBLE_NO_THROW_SCOPE(_env); \ + auto napi_preamble_entry_scope__ = DECLARE_TOP_EXCEPTION_SCOPE((_env)->vm()); \ + NAPI_RETURN_ENTRY_STATUS_WITH_SCOPE(_env, napi_preamble_entry_scope__); \ } while (0) // What the value constructors/accessors Node gates with CHECK_ENV only run under (NAPI_PREAMBLE_NO_PENDING_CHECK // here, `ungated!` in napi_body.rs): callable with an exception pending (stashed for the call; only then, since the // restore is unconditional) and never where a worker.terminate() / node:vm timeout is delivered (DeferTraps: the -// next exception check after the call delivers it, as in Node). No JS or addon code may run under it. +// next JSC exception check after the call delivers it, as in Node; a gated call's entry check is not one either, +// see NapiEnv::entryStatus). No JS or addon code may run under it. struct NapiUngatedScope { explicit NapiUngatedScope(JSC::VM& vm) : deferTraps(vm) @@ -1071,11 +1075,6 @@ static napi_status throwErrorWithCStrings(napi_env env, const char* code_utf8, c auto* globalObject = toJS(env); auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - if (scope.exception() || env->hasPendingException()) { - return napi_set_last_error(env, napi_pending_exception); - } - if (!msg_utf8) { return napi_set_last_error(env, napi_invalid_arg); } @@ -1370,7 +1369,10 @@ extern "C" napi_status napi_is_exception_pending(napi_env env, bool* result) auto& vm = JSC::getVM(globalObject); // Use a catch scope instead of throw scope for safety during cleanup auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - *result = scope.exception() != nullptr; + // A pending termination is the VM's stop, not an exception the addon can read or clear + // (see NapiEnv::entryStatus); Node's readers only ever see what a script threw. + auto* exception = scope.exception(); + *result = exception && !vm.isTerminationException(exception); } } @@ -1388,9 +1390,12 @@ extern "C" napi_status napi_get_and_clear_last_exception(napi_env env, } auto globalObject = toJS(env); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); - if (scope.exception()) [[unlikely]] { - *result = toNapi(JSValue(scope.exception()->value()), globalObject); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* exception = scope.exception(); + // A pending termination stays where it is (tryClearException below refuses it too) and is not handed out. + if (exception && !vm.isTerminationException(exception)) [[unlikely]] { + *result = toNapi(JSValue(exception->value()), globalObject); } else if (std::optional pending = env->pendingException()) { *result = toNapi(pending.value(), globalObject); env->clearPendingException(); @@ -1597,7 +1602,6 @@ extern "C" JS_EXPORT napi_status node_api_create_buffer_from_arraybuffer(napi_en NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(env); auto* globalObject = toJS(env); auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, scope); NAPI_CHECK_ARG(env, arraybuffer); NAPI_CHECK_ARG(env, result); @@ -1879,7 +1883,6 @@ extern "C" napi_status napi_create_dataview(napi_env env, size_t length, NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(env); Zig::GlobalObject* globalObject = toJS(env); auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, scope); NAPI_CHECK_ARG(env, arraybuffer); NAPI_CHECK_ARG(env, result); JSValue arraybufferValue = toJS(arraybuffer); @@ -2298,7 +2301,6 @@ extern "C" napi_status napi_create_buffer(napi_env env, size_t length, NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(env); Zig::GlobalObject* globalObject = toJS(env); auto scope = DECLARE_THROW_SCOPE(env->vm()); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, scope); NAPI_CHECK_ARG(env, result); auto* subclassStructure = globalObject->JSBufferSubclassStructure(); @@ -2336,7 +2338,6 @@ extern "C" napi_status napi_create_buffer_copy(napi_env env, size_t length, NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(env); Zig::GlobalObject* globalObject = toJS(env); auto scope = DECLARE_THROW_SCOPE(env->vm()); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, scope); NAPI_CHECK_ARG(env, result); auto* subclassStructure = globalObject->JSBufferSubclassStructure(); @@ -3050,7 +3051,6 @@ extern "C" napi_status napi_run_script(napi_env env, napi_value script, Zig::GlobalObject* globalObject = toJS(env); auto& vm = JSC::getVM(globalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, throwScope); NAPI_CHECK_ARG(env, script); NAPI_CHECK_ARG(env, result); JSValue scriptValue = toJS(script); @@ -3122,7 +3122,6 @@ extern "C" napi_status napi_create_bigint_words(napi_env env, Zig::GlobalObject* globalObject = toJS(env); auto& vm = env->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - NAPI_RETURN_IF_EXCEPTION_WITH_SCOPE(env, scope); NAPI_CHECK_ARG(env, result); NAPI_CHECK_ARG(env, words); NAPI_RETURN_EARLY_IF_FALSE(env, word_count <= INT_MAX, napi_invalid_arg); @@ -3420,13 +3419,11 @@ extern "C" bool NapiEnv__setCompletingForStoppedContext(napi_env env, bool value return std::exchange(env->m_isCompletingForStoppedContext, value); } -extern "C" bool NapiEnv__hasPendingException(napi_env env) +// napi_ok, or the status NAPI_PREAMBLE returns at entry (not yet set as the last error). +extern "C" napi_status NapiEnv__entryStatus(napi_env env) { - if (env->hasPendingException()) { - return true; - } auto scope = DECLARE_TOP_EXCEPTION_SCOPE(env->vm()); - return scope.exception() != nullptr; + return env->entryStatus(scope); } extern "C" uint32_t Bun__napi_get_version(napi_env env) @@ -3434,12 +3431,6 @@ extern "C" uint32_t Bun__napi_get_version(napi_env env) return env->napiModule().nm_version; } -// napi_ok, or the status NAPI_RETURN_IF_CANNOT_CALL_INTO_JS returns (not yet set as the last error). -extern "C" napi_status NapiEnv__checkCanCallIntoJS(napi_env env) -{ - return env->canCallIntoJS() ? napi_ok : env->cannotCallIntoJSStatus(); -} - extern "C" JSGlobalObject* NapiEnv__globalObject(napi_env env) { return env->globalObject(); diff --git a/src/jsc/bindings/napi.h b/src/jsc/bindings/napi.h index 024d45d4b318..d4e61919af22 100644 --- a/src/jsc/bindings/napi.h +++ b/src/jsc/bindings/napi.h @@ -518,6 +518,27 @@ struct NapiEnv : public WTF::RefCounted { return m_napiModule.nm_version >= 10 ? napi_cannot_run_js : napi_pending_exception; } + // Node's NAPI_PREAMBLE entry check, in its order: napi_pending_exception for an exception the addon can + // still read (recorded on the env, or pending on the VM), then the can_call_into_js() gate. The VM's slot + // is read as it is, never through a JSC exception check: that check services the VM's traps, and would + // throw a termination another thread requested (worker.terminate()) into the VM with only addon frames on + // the stack. Node delivers such a termination in the next script the addon enters, or when the addon + // returns to the trampoline that called it; so does Bun now. A termination already pending is the stop + // itself, not an exception the addon can read or clear, so it answers as the closed gate does. + inline napi_status entryStatus(JSC::TopExceptionScope& scope) const + { + if (m_pendingException) [[unlikely]] { + return napi_pending_exception; + } + if (auto* exception = scope.exception()) [[unlikely]] { + return m_vm.isTerminationException(exception) ? cannotCallIntoJSStatus() : napi_pending_exception; + } + if (!canCallIntoJS()) [[unlikely]] { + return cannotCallIntoJSStatus(); + } + return napi_ok; + } + // Almost all NAPI functions should set error_code to the status they're returning right before // they return it napi_extended_error_info m_lastNapiErrorInfo = { diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 76421fc09949..b309118cea8a 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -105,11 +105,10 @@ bun_opaque::opaque_ffi! { unsafe extern "C" { fn NapiEnv__globalObject(env: *mut NapiEnv) -> *mut JSGlobalObject; fn NapiEnv__getAndClearPendingException(env: *mut NapiEnv, out: *mut JSValue) -> bool; - fn NapiEnv__hasPendingException(env: *mut NapiEnv) -> bool; /// Returns the previous value. fn NapiEnv__setCompletingForStoppedContext(env: *mut NapiEnv, value: bool) -> bool; - /// `NapiStatus::ok`, or the status Node's `NAPI_PREAMBLE` returns when `can_call_into_js()` is false. - fn NapiEnv__checkCanCallIntoJS(env: *mut NapiEnv) -> napi_status; + /// `NapiStatus::ok`, or the status Node's `NAPI_PREAMBLE` returns at entry (`NapiEnv::entryStatus`). + fn NapiEnv__entryStatus(env: *mut NapiEnv) -> napi_status; fn NapiEnv__deref(env: *mut NapiEnv); fn NapiEnv__ref(env: *mut NapiEnv); /// The reference to its VM's handle the env holds (`BunVmHandleRef`). @@ -187,17 +186,13 @@ impl NapiEnv { unsafe { NapiEnv__setCompletingForStoppedContext(self.as_mut_ptr(), previous) }; } - /// Checks both `env->m_pendingException` (set by `napi_throw*`) and the JSC - /// VM exception slot. This is the gate Node.js's `NAPI_PREAMBLE` enforces. - pub(crate) fn has_pending_exception(&self) -> bool { + /// Node's `NAPI_PREAMBLE` entry check (napi.h `NapiEnv::entryStatus`): `napi_pending_exception` for an + /// exception the addon can read, then the `can_call_into_js()` gate. Reads the VM without servicing its + /// traps, so a termination another thread requested is never delivered inside the addon's frame. + /// `Err` is the status Node returns, set as the last error. + pub(crate) fn entry_status(&self) -> Result<(), napi_status> { // SAFETY: env is non-null; C++ side is read-only here. - unsafe { NapiEnv__hasPendingException(self.as_mut_ptr()) } - } - - /// Node's `can_call_into_js()` gate. `Err` is the status Node returns, set as the last error. - pub(crate) fn check_can_call_into_js(&self) -> Result<(), napi_status> { - // SAFETY: env is non-null; C++ side is read-only here. - let status = unsafe { NapiEnv__checkCanCallIntoJS(self.as_mut_ptr()) }; + let status = unsafe { NapiEnv__entryStatus(self.as_mut_ptr()) }; if status == NapiStatus::ok as napi_status { return Ok(()); } @@ -535,15 +530,11 @@ macro_rules! get_env { }; } -/// Node's `NAPI_PREAMBLE`: `get_env!`, then `napi_pending_exception` if a JS -/// exception is pending, then the `can_call_into_js()` gate. +/// Node's `NAPI_PREAMBLE`: `get_env!`, then [`NapiEnv::entry_status`]. macro_rules! preamble { ($env:expr) => {{ let env = get_env!($env); - if env.has_pending_exception() { - return env.pending_exception(); - } - if let Err(status) = env.check_can_call_into_js() { + if let Err(status) = env.entry_status() { return status; } env @@ -1705,9 +1696,6 @@ fn conclude_deferred( deferred: napi_deferred, settle: impl FnOnce(&mut jsc::JSPromise, &JSGlobalObject) -> JsResult<()>, ) -> napi_status { - if env.has_pending_exception() { - return env.pending_exception(); - } // SAFETY: deferred was created by heap::alloc in napi_create_promise. let context = unsafe { (*deferred).context }; if !env.to_js().bun_vm().is_context_live(context) { @@ -1718,7 +1706,7 @@ fn conclude_deferred( } let mut status = env.ok(); env.complete_in_context(context, || { - if let Err(refused) = env.check_can_call_into_js() { + if let Err(refused) = env.entry_status() { status = refused; return; } diff --git a/test/napi/napi-app/binding.gyp b/test/napi/napi-app/binding.gyp index 656c4c48c722..1c640bf764ed 100644 --- a/test/napi/napi-app/binding.gyp +++ b/test/napi/napi-app/binding.gyp @@ -373,5 +373,29 @@ "NAPI_VERSION=8", ], }, + { + "target_name": "terminate_probe_addon_v9", + "sources": ["terminate_probe_addon.cpp"], + "include_dirs": [" { } }; +// A worker.terminate() that lands while the addon is inside a call Node gates +// behind can_call_into_js(): its module Init, or a method entered from JS. +// terminate_probe_addon.cpp records what the refused call, the exception +// readers and napi_throw say, then makes one node-addon-api call refused (that +// escalates to napi_fatal_error unless those agree the env is stopping). The +// parent arms terminate() once the addon has created the signal file. +nativeTests.test_terminate_during_gated_calls = async (_gc, version, frame) => { + const { Worker } = require("node:worker_threads"); + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "terminate-probe-")); + const signal = path.join(dir, "signal"); + const probe = path.join(dir, "probe"); + const worker = new Worker(path.join(__dirname, "terminate-probe-worker.js"), { + workerData: { version, frame, signal, probe }, + }); + const failed = new Promise((_, reject) => worker.once("error", reject)); + while (!fs.existsSync(signal)) await new Promise(resolve => setImmediate(resolve)); + console.log("terminate() resolved with", await Promise.race([worker.terminate(), failed])); + console.log(fs.readFileSync(probe, "utf8")); + fs.rmSync(dir, { recursive: true }); +}; + // See ungated_calls_through_timeout in standalone_tests.cpp: 600ms of ungated // calls under a 150ms timeout. The timeout is wall-clock from the start of the // run, so it must comfortably cover reaching the addon on the slowest lane. diff --git a/test/napi/napi-app/terminate-probe-worker.js b/test/napi/napi-app/terminate-probe-worker.js new file mode 100644 index 000000000000..7d8ffa0ace9a --- /dev/null +++ b/test/napi/napi-app/terminate-probe-worker.js @@ -0,0 +1,8 @@ +// Loads terminate_probe_addon_v{9,10}.node while the parent terminates this worker +// (test_terminate_during_gated_calls in module.js). `frame` says where the addon spins +// through gated calls: its module Init, or a method entered from here. +const { workerData } = require("node:worker_threads"); +const { version, frame, signal, probe } = workerData; +globalThis.__terminateProbe = { signal, probe, inInit: frame === "init" }; +const addon = require(`./build/Debug/terminate_probe_addon_v${version}.node`); +if (frame === "method") addon.spin(signal, probe); diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp new file mode 100644 index 000000000000..2618d0191045 --- /dev/null +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -0,0 +1,126 @@ +// Built twice (NAPI_VERSION 9 and 10, both with NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS, as +// sharp is): an addon whose module Init, or whose `spin` method, runs N-API calls that Node +// gates behind can_call_into_js() while the parent terminates the worker that loaded it +// (test_terminate_during_gated_calls in module.js). +// +// The worker sets globalThis.__terminateProbe = { signal, probe } (two file paths) before it +// loads the addon. The addon creates the signal file, and the parent terminates the worker +// once it exists, so the termination lands inside the addon frame. The first refused call is +// probed (its status, what the exception readers say, what napi_throw says) and the line is +// written to the probe file for the parent to print. Then one node-addon-api call runs +// refused: that escalates to napi_fatal_error unless the readers and napi_throw agree with +// Node that the env is stopping and no exception is pending. +#include + +#include +#include +#include + +static std::string string_of(napi_env env, napi_value value) { + size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) { + return ""; + } + std::string out(length, '\0'); + napi_get_value_string_utf8(env, value, out.data(), length + 1, &length); + return out; +} + +static void touch(const std::string &path) { + if (FILE *f = fopen(path.c_str(), "w")) { + fclose(f); + } +} + +static void probe(napi_env env, const std::string &signal_path, + const std::string &probe_path) { + napi_value object = nullptr; + napi_create_object(env, &object); + touch(signal_path); + +#if NAPI_VERSION >= 10 + const napi_status gate_status = napi_cannot_run_js; +#else + const napi_status gate_status = napi_pending_exception; +#endif + std::string line = "never refused"; + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(20); + while (std::chrono::steady_clock::now() < deadline) { + napi_value one = nullptr; + napi_create_int32(env, 1, &one); + napi_status status = napi_set_named_property(env, object, "k", one); + if (status == napi_ok) { + continue; + } + // A call already inside its body when the stop lands answers with whatever + // status the check that found it maps an exception to (JSC services the + // stop at checks inside the body; V8 does not). The next call is refused + // at entry, by the gate; that is the answer probed. + if (status != gate_status) { + continue; + } + bool pending = false; + napi_is_exception_pending(env, &pending); + napi_value message = nullptr, error = nullptr; + napi_create_string_utf8(env, "probe", NAPI_AUTO_LENGTH, &message); + napi_create_error(env, nullptr, message, &error); + napi_status throw_status = napi_throw(env, error); + bool pending_after_throw = false; + napi_is_exception_pending(env, &pending_after_throw); + napi_value exception = nullptr; + napi_status clear_status = + napi_get_and_clear_last_exception(env, &exception); + napi_valuetype type = napi_undefined; + if (exception != nullptr) { + napi_typeof(env, exception, &type); + } + line = "refused: status=" + std::to_string(status) + + " pending=" + std::to_string(pending) + + " napi_throw=" + std::to_string(throw_status) + + " pending_after_throw=" + std::to_string(pending_after_throw) + + " get_and_clear=" + std::to_string(clear_status) + + " exception_type=" + std::to_string(type); + break; + } + +#if NAPI_VERSION < 10 + // Object::Set on a refused napi_set_named_property runs + // Error::New(env).ThrowAsJavaScriptException(). node-addon-api 8.0.0 swallows + // only napi_pending_exception there, so under NAPI_VERSION >= 10 (napi_cannot_run_js) + // it is fatal on Node too. + Napi::Object(env, object).Set("k", Napi::Number::New(env, 1)); + line += "; node-addon-api returned"; +#endif + + if (FILE *f = fopen(probe_path.c_str(), "w")) { + fputs(line.c_str(), f); + fclose(f); + } +} + +static Napi::Value spin(const Napi::CallbackInfo &info) { + probe(info.Env(), info[0].As().Utf8Value(), + info[1].As().Utf8Value()); + return info.Env().Undefined(); +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value global = nullptr, config = nullptr, signal = nullptr, + probe_out = nullptr, in_init = nullptr; + napi_get_global(env, &global); + napi_get_named_property(env, global, "__terminateProbe", &config); + napi_get_named_property(env, config, "signal", &signal); + napi_get_named_property(env, config, "probe", &probe_out); + napi_get_named_property(env, config, "inInit", &in_init); + Napi::Object(env, exports) + .Set("spin", Napi::Function::New(Napi::Env(env), spin, "spin")); + bool spin_in_init = false; + napi_get_value_bool(env, in_init, &spin_in_init); + if (spin_in_init) { + probe(env, string_of(env, signal), string_of(env, probe_out)); + } + return exports; +} + +NAPI_MODULE(terminate_probe_addon, init) diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9379b2e0b34d..e1c696f738e0 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -623,6 +623,24 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { const result = await checkSameOutput("test_ungated_calls_worker_terminate", []); expect(result.split(/\r?\n/)).toEqual([...Array(2).fill("terminate() resolved with 1"), "resolved to undefined"]); }, 30_000); + + // A terminate() that lands while the addon is inside a gated call (sharp's + // module Init, #45252) must not put the termination where the addon can read + // it: node-addon-api escalates a refused call with an unexplained pending + // exception to napi_fatal_error, which aborts the whole process. + const refusedV9 = "refused: status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; + const refusedV10 = "refused: status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; + describe.each([ + [9, "init", `${refusedV9}; node-addon-api returned`], + [9, "method", `${refusedV9}; node-addon-api returned`], + [10, "init", refusedV10], + [10, "method", refusedV10], + ])("worker.terminate() during gated calls (NAPI_VERSION=%i, %s frame)", (version, frame, probe) => { + it("refuses the calls as Node does and the process survives", async () => { + const result = await checkSameOutput("test_terminate_during_gated_calls", [version, frame]); + expect(result.split(/\r?\n/)).toEqual(["terminate() resolved with 1", probe, "resolved to undefined"]); + }, 30_000); + }); }); describe("status code alignment with Node.js", () => { From 94f5a56f641d9d2b2e21bdfc775c75ed9128f5d8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:51:22 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- test/napi/napi.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index e1c696f738e0..79e358a05c40 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -628,8 +628,10 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { // module Init, #45252) must not put the termination where the addon can read // it: node-addon-api escalates a refused call with an unexplained pending // exception to napi_fatal_error, which aborts the whole process. - const refusedV9 = "refused: status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; - const refusedV10 = "refused: status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; + const refusedV9 = + "refused: status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; + const refusedV10 = + "refused: status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; describe.each([ [9, "init", `${refusedV9}; node-addon-api returned`], [9, "method", `${refusedV9}; node-addon-api returned`], From 2cd7190ab4a0000509eed9b413f02b9797f182ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:53:30 +0000 Subject: [PATCH 3/8] napi: shorter comments on the entry check --- src/jsc/bindings/napi.cpp | 14 +++++--------- src/jsc/bindings/napi.h | 9 +++------ src/runtime/napi/napi_body.rs | 6 ++---- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 08d41d1927c2..b4195a4879ff 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -85,8 +85,7 @@ using namespace JSC; using namespace Zig; -// Node's NAPI_PREAMBLE entry check (NapiEnv::entryStatus, which see): the status of an exception the addon can -// read, else of the closed can_call_into_js() gate, read off the VM without servicing its traps. +// Node's NAPI_PREAMBLE entry check: NapiEnv::entryStatus. #define NAPI_RETURN_ENTRY_STATUS_WITH_SCOPE(_env, _scope) \ do { \ if (napi_status napi_entry_status__ = (_env)->entryStatus(_scope); \ @@ -119,8 +118,7 @@ using namespace Zig; NAPI_CHECK_ARG(_env, _env); \ } while (0) -// NAPI_PREAMBLE_NO_THROW_SCOPE plus the entry check, for a function Node opens with NAPI_PREAMBLE. The scope it -// reads the VM through is closed again before the function opens its own. +// NAPI_PREAMBLE_NO_THROW_SCOPE plus the entry check, for a function Node opens with NAPI_PREAMBLE. #define NAPI_PREAMBLE_NO_THROW_SCOPE_GATED(_env) \ do { \ NAPI_PREAMBLE_NO_THROW_SCOPE(_env); \ @@ -131,8 +129,7 @@ using namespace Zig; // What the value constructors/accessors Node gates with CHECK_ENV only run under (NAPI_PREAMBLE_NO_PENDING_CHECK // here, `ungated!` in napi_body.rs): callable with an exception pending (stashed for the call; only then, since the // restore is unconditional) and never where a worker.terminate() / node:vm timeout is delivered (DeferTraps: the -// next JSC exception check after the call delivers it, as in Node; a gated call's entry check is not one either, -// see NapiEnv::entryStatus). No JS or addon code may run under it. +// next JSC exception check after the call delivers it, as in Node). No JS or addon code may run under it. struct NapiUngatedScope { explicit NapiUngatedScope(JSC::VM& vm) : deferTraps(vm) @@ -1369,8 +1366,7 @@ extern "C" napi_status napi_is_exception_pending(napi_env env, bool* result) auto& vm = JSC::getVM(globalObject); // Use a catch scope instead of throw scope for safety during cleanup auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - // A pending termination is the VM's stop, not an exception the addon can read or clear - // (see NapiEnv::entryStatus); Node's readers only ever see what a script threw. + // A pending termination is not an exception the addon can read (NapiEnv::entryStatus). auto* exception = scope.exception(); *result = exception && !vm.isTerminationException(exception); } @@ -1393,7 +1389,7 @@ extern "C" napi_status napi_get_and_clear_last_exception(napi_env env, auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* exception = scope.exception(); - // A pending termination stays where it is (tryClearException below refuses it too) and is not handed out. + // A pending termination is not handed out (NapiEnv::entryStatus); tryClearException leaves it too. if (exception && !vm.isTerminationException(exception)) [[unlikely]] { *result = toNapi(JSValue(exception->value()), globalObject); } else if (std::optional pending = env->pendingException()) { diff --git a/src/jsc/bindings/napi.h b/src/jsc/bindings/napi.h index d4e61919af22..16181b485284 100644 --- a/src/jsc/bindings/napi.h +++ b/src/jsc/bindings/napi.h @@ -519,12 +519,9 @@ struct NapiEnv : public WTF::RefCounted { } // Node's NAPI_PREAMBLE entry check, in its order: napi_pending_exception for an exception the addon can - // still read (recorded on the env, or pending on the VM), then the can_call_into_js() gate. The VM's slot - // is read as it is, never through a JSC exception check: that check services the VM's traps, and would - // throw a termination another thread requested (worker.terminate()) into the VM with only addon frames on - // the stack. Node delivers such a termination in the next script the addon enters, or when the addon - // returns to the trampoline that called it; so does Bun now. A termination already pending is the stop - // itself, not an exception the addon can read or clear, so it answers as the closed gate does. + // read (on the env or on the VM), then the can_call_into_js() gate. The VM's slot is read raw, not through + // a JSC exception check: that would service the traps and deliver a worker.terminate() with only addon + // frames on the stack, where V8 never does. A termination already pending answers as the closed gate does. inline napi_status entryStatus(JSC::TopExceptionScope& scope) const { if (m_pendingException) [[unlikely]] { diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index b309118cea8a..eb8c3ab2066f 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -186,10 +186,8 @@ impl NapiEnv { unsafe { NapiEnv__setCompletingForStoppedContext(self.as_mut_ptr(), previous) }; } - /// Node's `NAPI_PREAMBLE` entry check (napi.h `NapiEnv::entryStatus`): `napi_pending_exception` for an - /// exception the addon can read, then the `can_call_into_js()` gate. Reads the VM without servicing its - /// traps, so a termination another thread requested is never delivered inside the addon's frame. - /// `Err` is the status Node returns, set as the last error. + /// Node's `NAPI_PREAMBLE` entry check (napi.h `NapiEnv::entryStatus`). `Err` is the status Node + /// returns, set as the last error. pub(crate) fn entry_status(&self) -> Result<(), napi_status> { // SAFETY: env is non-null; C++ side is read-only here. let status = unsafe { NapiEnv__entryStatus(self.as_mut_ptr()) }; From a9085c078d76f632f63b48c229c238f3a2646331 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:55:53 +0000 Subject: [PATCH 4/8] napi: probe a termination thrown in script the addon entered, drop a redundant latch check --- src/jsc/bindings/napi.cpp | 1 - test/napi/napi-app/terminate-probe-worker.js | 8 ++- test/napi/napi-app/terminate_probe_addon.cpp | 51 ++++++++++++++++++++ test/napi/napi.test.ts | 21 ++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index b4195a4879ff..b5b25afce9b0 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -1774,7 +1774,6 @@ extern "C" JS_EXPORT napi_status node_api_create_external_sharedarraybuffer(napi NAPI_PREAMBLE(env); NAPI_CHECK_ENV_NOT_IN_GC(env); NAPI_CHECK_ARG(env, result); - NAPI_RETURN_EARLY_IF_FALSE(env, !env->hasPendingException(), napi_pending_exception); Zig::GlobalObject* globalObject = toJS(env); JSC::VM& vm = JSC::getVM(globalObject); diff --git a/test/napi/napi-app/terminate-probe-worker.js b/test/napi/napi-app/terminate-probe-worker.js index 7d8ffa0ace9a..bc24751860f8 100644 --- a/test/napi/napi-app/terminate-probe-worker.js +++ b/test/napi/napi-app/terminate-probe-worker.js @@ -1,8 +1,14 @@ // Loads terminate_probe_addon_v{9,10}.node while the parent terminates this worker // (test_terminate_during_gated_calls in module.js). `frame` says where the addon spins -// through gated calls: its module Init, or a method entered from here. +// through gated calls: its module Init, a method entered from here, or script the +// method enters. const { workerData } = require("node:worker_threads"); const { version, frame, signal, probe } = workerData; globalThis.__terminateProbe = { signal, probe, inInit: frame === "init" }; const addon = require(`./build/Debug/terminate_probe_addon_v${version}.node`); if (frame === "method") addon.spin(signal, probe); +if (frame === "script") { + addon.spinScript(signal, probe, () => { + for (;;) {} + }); +} diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp index 2618d0191045..8cbb57757eef 100644 --- a/test/napi/napi-app/terminate_probe_addon.cpp +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -99,6 +99,54 @@ static void probe(napi_env env, const std::string &signal_path, } } +// spinScript(signal, probe, fn): enter `fn` (a JS loop that never returns on +// its own) until the stop lands inside it. JSC then throws the termination in +// the script, so it is pending on the VM when napi_call_function returns. The +// readers must not hand it out, and napi_throw answers as the closed gate does. +// Bun only: Node's TryCatch stores null as last_exception for a termination, +// so Node reports an exception pending here and hands out null. +static Napi::Value spin_script(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + std::string signal_path = info[0].As().Utf8Value(); + std::string probe_path = info[1].As().Utf8Value(); + napi_value fn = info[2]; + napi_value undefined = nullptr; + napi_get_undefined(env, &undefined); + touch(signal_path); + + napi_value unused = nullptr; + napi_status call_status = + napi_call_function(env, undefined, fn, 0, nullptr, &unused); + bool pending = false; + napi_is_exception_pending(env, &pending); + napi_value exception = nullptr; + napi_status clear_status = + napi_get_and_clear_last_exception(env, &exception); + napi_valuetype type = napi_undefined; + if (exception != nullptr) { + napi_typeof(env, exception, &type); + } + bool pending_after_clear = false; + napi_is_exception_pending(env, &pending_after_clear); + napi_value message = nullptr, error = nullptr; + napi_create_string_utf8(env, "probe", NAPI_AUTO_LENGTH, &message); + napi_create_error(env, nullptr, message, &error); + napi_status throw_status = napi_throw(env, error); + + std::string line = "call_function=" + std::to_string(call_status) + + " pending=" + std::to_string(pending) + + " get_and_clear=" + std::to_string(clear_status) + + " exception_type=" + std::to_string(type) + + " pending_after_clear=" + + std::to_string(pending_after_clear) + + " napi_throw=" + std::to_string(throw_status); + if (FILE *f = fopen(probe_path.c_str(), "w")) { + fputs(line.c_str(), f); + fclose(f); + } + return info.Env().Undefined(); +} + static Napi::Value spin(const Napi::CallbackInfo &info) { probe(info.Env(), info[0].As().Utf8Value(), info[1].As().Utf8Value()); @@ -115,6 +163,9 @@ static napi_value init(napi_env env, napi_value exports) { napi_get_named_property(env, config, "inInit", &in_init); Napi::Object(env, exports) .Set("spin", Napi::Function::New(Napi::Env(env), spin, "spin")); + Napi::Object(env, exports) + .Set("spinScript", + Napi::Function::New(Napi::Env(env), spin_script, "spinScript")); bool spin_in_init = false; napi_get_value_bool(env, in_init, &spin_in_init); if (spin_in_init) { diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 79e358a05c40..6871c4161b81 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -643,6 +643,27 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(result.split(/\r?\n/)).toEqual(["terminate() resolved with 1", probe, "resolved to undefined"]); }, 30_000); }); + + // The stop lands inside script the addon entered, so JSC throws the + // termination there and it is pending on the VM when napi_call_function + // returns. The readers never hand it out, and napi_throw answers as the + // closed gate does. Bun only: Node stores null as its last exception for a + // termination and reports it pending. + it.each([ + [9, 10], + [10, 23], + ])( + "worker.terminate() inside script entered from an addon (NAPI_VERSION=%i) is not a readable exception", + async (version, gateStatus) => { + const result = await runOn(bunExe(), "test_terminate_during_gated_calls", [version, "script"]); + expect(result.trim().split(/\r?\n/)).toEqual([ + "terminate() resolved with 1", + `call_function=10 pending=0 get_and_clear=0 exception_type=0 pending_after_clear=0 napi_throw=${gateStatus}`, + "resolved to undefined", + ]); + }, + 30_000, + ); }); describe("status code alignment with Node.js", () => { From 66ff4d01f8cf86471d76b9b57f82ceb41df9ce44 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:15:03 +0000 Subject: [PATCH 5/8] napi tests: keep the terminate probe in the test's directory, signal from the script frame, reject on early worker exit --- test/napi/napi-app/module.js | 23 +++++++++----- test/napi/napi-app/terminate-probe-worker.js | 4 ++- test/napi/napi-app/terminate_probe_addon.cpp | 32 ++++++++++---------- test/napi/napi.test.ts | 20 ++++++++++-- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index e16ed2c03819..24b74232b939 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -405,7 +405,6 @@ nativeTests.test_get_all_property_names_throwing_proxy_traps = () => { napi_key_keep_numbers, ), ); - }; nativeTests.test_get_all_property_names_get_prototype_throws_in_descriptor_walk = () => { @@ -1955,23 +1954,31 @@ nativeTests.test_ungated_calls_worker_terminate = async () => { // terminate_probe_addon.cpp records what the refused call, the exception // readers and napi_throw say, then makes one node-addon-api call refused (that // escalates to napi_fatal_error unless those agree the env is stopping). The -// parent arms terminate() once the addon has created the signal file. -nativeTests.test_terminate_during_gated_calls = async (_gc, version, frame) => { +// parent arms terminate() once the addon has created the signal file. `dir` is +// the test's temporary directory; the probe file is left there for the test to +// read if this process does not get to print it. +nativeTests.test_terminate_during_gated_calls = async (_gc, version, frame, dir) => { const { Worker } = require("node:worker_threads"); const fs = require("node:fs"); - const os = require("node:os"); const path = require("node:path"); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "terminate-probe-")); + dir = path.join(dir, process.versions.bun ? "bun" : "node"); + fs.mkdirSync(dir, { recursive: true }); const signal = path.join(dir, "signal"); const probe = path.join(dir, "probe"); const worker = new Worker(path.join(__dirname, "terminate-probe-worker.js"), { workerData: { version, frame, signal, probe }, }); - const failed = new Promise((_, reject) => worker.once("error", reject)); - while (!fs.existsSync(signal)) await new Promise(resolve => setImmediate(resolve)); + const failed = new Promise((_, reject) => { + worker.once("error", reject); + worker.once("exit", code => reject(new Error(`worker exited with ${code} before the signal`))); + }); + const signalSeen = (async () => { + while (!fs.existsSync(signal)) await new Promise(resolve => setImmediate(resolve)); + })(); + await Promise.race([signalSeen, failed]); + worker.removeAllListeners("exit"); console.log("terminate() resolved with", await Promise.race([worker.terminate(), failed])); console.log(fs.readFileSync(probe, "utf8")); - fs.rmSync(dir, { recursive: true }); }; // See ungated_calls_through_timeout in standalone_tests.cpp: 600ms of ungated diff --git a/test/napi/napi-app/terminate-probe-worker.js b/test/napi/napi-app/terminate-probe-worker.js index bc24751860f8..ed578ea3c652 100644 --- a/test/napi/napi-app/terminate-probe-worker.js +++ b/test/napi/napi-app/terminate-probe-worker.js @@ -8,7 +8,9 @@ globalThis.__terminateProbe = { signal, probe, inInit: frame === "init" }; const addon = require(`./build/Debug/terminate_probe_addon_v${version}.node`); if (frame === "method") addon.spin(signal, probe); if (frame === "script") { - addon.spinScript(signal, probe, () => { + const { writeFileSync } = require("node:fs"); + addon.spinScript(probe, () => { + writeFileSync(signal, ""); for (;;) {} }); } diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp index 8cbb57757eef..46b1ab9a1cb6 100644 --- a/test/napi/napi-app/terminate_probe_addon.cpp +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -32,6 +32,13 @@ static void touch(const std::string &path) { } } +static void write_probe(const std::string &path, const std::string &line) { + if (FILE *f = fopen(path.c_str(), "w")) { + fputs(line.c_str(), f); + fclose(f); + } +} + static void probe(napi_env env, const std::string &signal_path, const std::string &probe_path) { napi_value object = nullptr; @@ -84,36 +91,32 @@ static void probe(napi_env env, const std::string &signal_path, break; } + write_probe(probe_path, line); + #if NAPI_VERSION < 10 // Object::Set on a refused napi_set_named_property runs // Error::New(env).ThrowAsJavaScriptException(). node-addon-api 8.0.0 swallows // only napi_pending_exception there, so under NAPI_VERSION >= 10 (napi_cannot_run_js) // it is fatal on Node too. Napi::Object(env, object).Set("k", Napi::Number::New(env, 1)); - line += "; node-addon-api returned"; + write_probe(probe_path, line + "; node-addon-api returned"); #endif - - if (FILE *f = fopen(probe_path.c_str(), "w")) { - fputs(line.c_str(), f); - fclose(f); - } } -// spinScript(signal, probe, fn): enter `fn` (a JS loop that never returns on -// its own) until the stop lands inside it. JSC then throws the termination in +// spinScript(probe, fn): enter `fn` (a JS loop that never returns on its own) +// until the stop lands inside it. JSC then throws the termination in // the script, so it is pending on the VM when napi_call_function returns. The // readers must not hand it out, and napi_throw answers as the closed gate does. // Bun only: Node's TryCatch stores null as last_exception for a termination, // so Node reports an exception pending here and hands out null. static Napi::Value spin_script(const Napi::CallbackInfo &info) { napi_env env = info.Env(); - std::string signal_path = info[0].As().Utf8Value(); - std::string probe_path = info[1].As().Utf8Value(); - napi_value fn = info[2]; + std::string probe_path = info[0].As().Utf8Value(); + napi_value fn = info[1]; napi_value undefined = nullptr; napi_get_undefined(env, &undefined); - touch(signal_path); + // `fn` creates the signal file itself, so the stop lands while it runs. napi_value unused = nullptr; napi_status call_status = napi_call_function(env, undefined, fn, 0, nullptr, &unused); @@ -140,10 +143,7 @@ static Napi::Value spin_script(const Napi::CallbackInfo &info) { " pending_after_clear=" + std::to_string(pending_after_clear) + " napi_throw=" + std::to_string(throw_status); - if (FILE *f = fopen(probe_path.c_str(), "w")) { - fputs(line.c_str(), f); - fclose(f); - } + write_probe(probe_path, line); return info.Env().Undefined(); } diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 6871c4161b81..9e5685ff3b46 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -632,6 +632,18 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { "refused: status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; const refusedV10 = "refused: status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; + // The fixture leaves its probe line in the test's directory: what the addon + // saw is in the failure when the process did not live to print it. + async function terminateProbe(version: number, frame: string, run: (dir: string) => Promise) { + await using dir = tempDir("terminate-probe", {}); + try { + return await run(String(dir)); + } catch (error) { + const probe = join(String(dir), "bun", "probe"); + if (existsSync(probe)) (error as Error).message += `\nbun probe file: ${readFileSync(probe, "utf8")}`; + throw error; + } + } describe.each([ [9, "init", `${refusedV9}; node-addon-api returned`], [9, "method", `${refusedV9}; node-addon-api returned`], @@ -639,7 +651,9 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { [10, "method", refusedV10], ])("worker.terminate() during gated calls (NAPI_VERSION=%i, %s frame)", (version, frame, probe) => { it("refuses the calls as Node does and the process survives", async () => { - const result = await checkSameOutput("test_terminate_during_gated_calls", [version, frame]); + const result = await terminateProbe(version, frame, dir => + checkSameOutput("test_terminate_during_gated_calls", [version, frame, dir]), + ); expect(result.split(/\r?\n/)).toEqual(["terminate() resolved with 1", probe, "resolved to undefined"]); }, 30_000); }); @@ -655,7 +669,9 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { ])( "worker.terminate() inside script entered from an addon (NAPI_VERSION=%i) is not a readable exception", async (version, gateStatus) => { - const result = await runOn(bunExe(), "test_terminate_during_gated_calls", [version, "script"]); + const result = await terminateProbe(version, "script", dir => + runOn(bunExe(), "test_terminate_during_gated_calls", [version, "script", dir]), + ); expect(result.trim().split(/\r?\n/)).toEqual([ "terminate() resolved with 1", `call_function=10 pending=0 get_and_clear=0 exception_type=0 pending_after_clear=0 napi_throw=${gateStatus}`, From b59b6dc35048cc0b5e529c575031f2141d8972a6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:39:35 +0000 Subject: [PATCH 6/8] napi: napi_resolve_deferred and napi_reject_deferred answer napi_invalid_arg for a null deferred, as Node does --- src/runtime/napi/napi_body.rs | 5 ++++- test/napi/napi-app/module.js | 6 +++++- test/napi/napi-app/terminate_probe_addon.cpp | 5 +++++ test/napi/napi.test.ts | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index eb8c3ab2066f..7d243161fe23 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -1694,7 +1694,10 @@ fn conclude_deferred( deferred: napi_deferred, settle: impl FnOnce(&mut jsc::JSPromise, &JSGlobalObject) -> JsResult<()>, ) -> napi_status { - // SAFETY: deferred was created by heap::alloc in napi_create_promise. + if deferred.is_null() { + return env.invalid_arg(); + } + // SAFETY: deferred is non-null and was created by heap::alloc in napi_create_promise. let context = unsafe { (*deferred).context }; if !env.to_js().bun_vm().is_context_live(context) { // Of a Bun.ModuleGraph that has been disposed: released, and nothing is settled. diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index 24b74232b939..d023d9e4e840 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1968,12 +1968,16 @@ nativeTests.test_terminate_during_gated_calls = async (_gc, version, frame, dir) const worker = new Worker(path.join(__dirname, "terminate-probe-worker.js"), { workerData: { version, frame, signal, probe }, }); + let done = false; const failed = new Promise((_, reject) => { worker.once("error", reject); worker.once("exit", code => reject(new Error(`worker exited with ${code} before the signal`))); + }).catch(error => { + done = true; + throw error; }); const signalSeen = (async () => { - while (!fs.existsSync(signal)) await new Promise(resolve => setImmediate(resolve)); + while (!done && !fs.existsSync(signal)) await new Promise(resolve => setImmediate(resolve)); })(); await Promise.race([signalSeen, failed]); worker.removeAllListeners("exit"); diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp index 46b1ab9a1cb6..2cf8166034d0 100644 --- a/test/napi/napi-app/terminate_probe_addon.cpp +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -67,6 +67,10 @@ static void probe(napi_env env, const std::string &signal_path, if (status != gate_status) { continue; } + // A gated call with no check inside its body: its status is the entry's. + napi_value external = nullptr; + napi_status entry_status = + napi_create_external(env, nullptr, nullptr, nullptr, &external); bool pending = false; napi_is_exception_pending(env, &pending); napi_value message = nullptr, error = nullptr; @@ -83,6 +87,7 @@ static void probe(napi_env env, const std::string &signal_path, napi_typeof(env, exception, &type); } line = "refused: status=" + std::to_string(status) + + " entry_status=" + std::to_string(entry_status) + " pending=" + std::to_string(pending) + " napi_throw=" + std::to_string(throw_status) + " pending_after_throw=" + std::to_string(pending_after_throw) + diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9e5685ff3b46..744431a50e1f 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -629,9 +629,9 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { // it: node-addon-api escalates a refused call with an unexplained pending // exception to napi_fatal_error, which aborts the whole process. const refusedV9 = - "refused: status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; + "refused: status=10 entry_status=10 pending=0 napi_throw=10 pending_after_throw=0 get_and_clear=0 exception_type=0"; const refusedV10 = - "refused: status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; + "refused: status=23 entry_status=23 pending=0 napi_throw=23 pending_after_throw=0 get_and_clear=0 exception_type=0"; // The fixture leaves its probe line in the test's directory: what the addon // saw is in the failure when the process did not live to print it. async function terminateProbe(version: number, frame: string, run: (dir: string) => Promise) { From a42e6da2e26d8757ff048b12b5fddbf06efafe78 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:38:09 +0000 Subject: [PATCH 7/8] napi tests: replay node-addon-api's error path raw in the v9 terminate probe --- test/napi/napi-app/terminate_probe_addon.cpp | 34 +++++++++++++++++++- test/napi/napi.test.ts | 15 ++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp index 2cf8166034d0..0e3be9248ecf 100644 --- a/test/napi/napi-app/terminate_probe_addon.cpp +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -102,7 +102,39 @@ static void probe(napi_env env, const std::string &signal_path, // Object::Set on a refused napi_set_named_property runs // Error::New(env).ThrowAsJavaScriptException(). node-addon-api 8.0.0 swallows // only napi_pending_exception there, so under NAPI_VERSION >= 10 (napi_cannot_run_js) - // it is fatal on Node too. + // it is fatal on Node too. The same calls are made raw first, each status + // recorded, so a fatal inside node-addon-api still names the call that differed. + line += "\nreplay:"; + auto record = [&](const char *name, napi_status status) { + line += std::string(" ") + name + "=" + std::to_string(status); + }; + const napi_extended_error_info *info = nullptr; + record("last_error_info", napi_get_last_error_info(env, &info)); + bool pending_before = false; + record("is_pending", napi_is_exception_pending(env, &pending_before)); + napi_value message = nullptr, error = nullptr; + record("create_string", napi_create_string_utf8(env, "replay", NAPI_AUTO_LENGTH, &message)); + record("create_error", napi_create_error(env, nullptr, message, &error)); + napi_ref ref = nullptr; + record("create_reference", napi_create_reference(env, error, 1, &ref)); + napi_handle_scope scope = nullptr; + record("open_handle_scope", napi_open_handle_scope(env, &scope)); + napi_value ref_value = nullptr; + record("get_reference_value", napi_get_reference_value(env, ref, &ref_value)); + napi_valuetype ref_type = napi_undefined; + record("typeof", napi_typeof(env, ref_value, &ref_type)); + napi_value key = nullptr; + record("create_key", napi_create_string_utf8(env, "wrap", NAPI_AUTO_LENGTH, &key)); + bool has = false; + record("has_property", napi_has_property(env, ref_value, key, &has)); + bool pending_after = false; + record("is_pending_again", napi_is_exception_pending(env, &pending_after)); + line += " pending=" + std::to_string(pending_before) + "," + std::to_string(pending_after); + record("throw", napi_throw(env, ref_value)); + record("close_handle_scope", napi_close_handle_scope(env, scope)); + record("delete_reference", napi_delete_reference(env, ref)); + write_probe(probe_path, line); + Napi::Object(env, object).Set("k", Napi::Number::New(env, 1)); write_probe(probe_path, line + "; node-addon-api returned"); #endif diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 744431a50e1f..9720a60b880e 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -644,17 +644,22 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { throw error; } } + // node-addon-api's Error::New(env).ThrowAsJavaScriptException() sequence, made raw by the v9 fixture. + const replayV9 = + "replay: last_error_info=0 is_pending=0 create_string=0 create_error=0 create_reference=0 open_handle_scope=0" + + " get_reference_value=0 typeof=0 create_key=0 has_property=10 is_pending_again=0 pending=0,0 throw=10" + + " close_handle_scope=0 delete_reference=0; node-addon-api returned"; describe.each([ - [9, "init", `${refusedV9}; node-addon-api returned`], - [9, "method", `${refusedV9}; node-addon-api returned`], - [10, "init", refusedV10], - [10, "method", refusedV10], + [9, "init", [refusedV9, replayV9]], + [9, "method", [refusedV9, replayV9]], + [10, "init", [refusedV10]], + [10, "method", [refusedV10]], ])("worker.terminate() during gated calls (NAPI_VERSION=%i, %s frame)", (version, frame, probe) => { it("refuses the calls as Node does and the process survives", async () => { const result = await terminateProbe(version, frame, dir => checkSameOutput("test_terminate_during_gated_calls", [version, frame, dir]), ); - expect(result.split(/\r?\n/)).toEqual(["terminate() resolved with 1", probe, "resolved to undefined"]); + expect(result.split(/\r?\n/)).toEqual(["terminate() resolved with 1", ...probe, "resolved to undefined"]); }, 30_000); }); From 69987517ab355e26667e54498f7c1b029c8d84de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:33:14 +0000 Subject: [PATCH 8/8] napi tests: define the swallow flag in the probe addon's source and record the compiled configuration --- test/napi/napi-app/terminate_probe_addon.cpp | 18 +++++++++++++++++- test/napi/napi.test.ts | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/test/napi/napi-app/terminate_probe_addon.cpp b/test/napi/napi-app/terminate_probe_addon.cpp index 0e3be9248ecf..60dfc345ef7d 100644 --- a/test/napi/napi-app/terminate_probe_addon.cpp +++ b/test/napi/napi-app/terminate_probe_addon.cpp @@ -10,6 +10,11 @@ // written to the probe file for the parent to print. Then one node-addon-api call runs // refused: that escalates to napi_fatal_error unless the readers and napi_throw agree with // Node that the env is stopping and no exception is pending. +// Defined here, not only in binding.gyp: a define the build drops turns every +// refused node-addon-api call into napi_fatal_error, on Node as well. +#ifndef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS +#define NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS +#endif #include #include @@ -104,7 +109,18 @@ static void probe(napi_env env, const std::string &signal_path, // only napi_pending_exception there, so under NAPI_VERSION >= 10 (napi_cannot_run_js) // it is fatal on Node too. The same calls are made raw first, each status // recorded, so a fatal inside node-addon-api still names the call that differed. - line += "\nreplay:"; + line += "\nreplay: swallow="; +#ifdef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS + line += "1"; +#else + line += "0"; +#endif +#ifdef NAPI_CPP_EXCEPTIONS + line += " cpp_exceptions=1"; +#else + line += " cpp_exceptions=0"; +#endif + line += " napi_version=" + std::to_string(NAPI_VERSION); auto record = [&](const char *name, napi_status status) { line += std::string(" ") + name + "=" + std::to_string(status); }; diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9720a60b880e..f8dcb67ba117 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -646,7 +646,7 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { } // node-addon-api's Error::New(env).ThrowAsJavaScriptException() sequence, made raw by the v9 fixture. const replayV9 = - "replay: last_error_info=0 is_pending=0 create_string=0 create_error=0 create_reference=0 open_handle_scope=0" + + "replay: swallow=1 cpp_exceptions=0 napi_version=9 last_error_info=0 is_pending=0 create_string=0 create_error=0 create_reference=0 open_handle_scope=0" + " get_reference_value=0 typeof=0 create_key=0 has_property=10 is_pending_again=0 pending=0,0 throw=10" + " close_handle_scope=0 delete_reference=0; node-addon-api returned"; describe.each([