import { spawn, spawnSync } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { bunEnv, bunExe, canBuildNodeAddons, isASAN, isCI, isMacOS, isMusl, isWindows, nodeExeMatchingAbi, tempDir, } from "harness"; import { join } from "path"; // The napi-app addons don't link against bun, so existing binaries stay valid // across bun builds. `bun install` runs a full `node-gyp rebuild` (clean + build // of every target), so skip it when every target's .node output already exists // and is newer than every native source / gyp / package file in napi-app. function needsInstall(): boolean { const app = join(__dirname, "napi-app"); if (!existsSync(join(app, "node_modules/node-addon-api"))) return true; const targets = [...readFileSync(join(app, "binding.gyp"), "utf8").matchAll(/"target_name":\s*"([\w-]+)"/g)].map( m => m[1], ); if (targets.length === 0) return true; const newestInput = Math.max( ...readdirSync(app) .filter(f => /\.(c|cc|cpp|h)$/.test(f) || f === "binding.gyp" || f === "package.json") .map(f => statSync(join(app, f)).mtimeMs), ); for (const target of targets) { const built = join(app, `build/Debug/${target}.node`); if (!existsSync(built)) return true; if (statSync(built).mtimeMs < newestInput) return true; } return false; } // File-scoped so every describe block below (including the non-concurrent // `napi_create_string_latin1` and `cleanup hooks` blocks) gets a built addon // even when `-t` filters out every test in describe.concurrent("napi"). beforeAll(async () => { if (!canBuildNodeAddons()) return; // Resolve (and possibly download) the ABI-matching node here, under the // generous hook timeout, instead of inside the first test that needs it. await nodeExeMatchingAbi(); // build gyp if (needsInstall()) { console.time("Building node-gyp"); const install = spawnSync({ cmd: [bunExe(), "install", "--verbose"], cwd: join(__dirname, "napi-app"), stderr: "inherit", env: bunEnv, stdout: "inherit", stdin: "inherit", }); if (!install.success) { console.error("build failed, bailing out!"); process.exit(1); } console.timeEnd("Building node-gyp"); } // node-gyp rebuild can take a while under a debug/ASAN binary (and the // hook may first download an ABI-matching node); default 5s hook timeout // kills the install subprocess mid-build. }, 300_000); describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { describe.each(["esm", "cjs"])("bundle .node files to %s via", format => { describe.each(["node", "bun"])("target %s", target => { it("Bun.build", async () => { await using dir = tempDir("node-file-cli", { "package.json": JSON.stringify({ name: "napi-app", version: "1.0.0", type: format === "esm" ? "module" : "commonjs", }), }); const build = spawnSync({ cmd: [ bunExe(), "build", "--target", target, "--outdir", dir, "--format=" + format, join(__dirname, "napi-app/main.js"), ], cwd: join(__dirname, "napi-app"), env: bunEnv, stdout: "inherit", stderr: "inherit", }); expect(build.success).toBeTrue(); for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, stdin: "inherit", stderr: "inherit", stdout: "pipe", }); const stdout = result.stdout.toString().trim(); expect(stdout).toBe("hello world!"); expect(result.success).toBeTrue(); } }); if (target === "bun") { it( "should work with --compile", async () => { await using dir = tempDir("napi-app-compile-" + format, { "package.json": JSON.stringify({ name: "napi-app", version: "1.0.0", type: format === "esm" ? "module" : "commonjs", }), }); const exe = join(dir, "main" + (process.platform === "win32" ? ".exe" : "")); const build = spawnSync({ cmd: [ bunExe(), "build", "--target=" + target, "--format=" + format, "--compile", join(__dirname, "napi-app", "main.js"), ], cwd: dir, env: bunEnv, stdout: "inherit", stderr: "inherit", }); expect(build.success).toBeTrue(); await using tmpdir = tempDir("should-be-empty-except", {}); const result = spawnSync({ cmd: [exe, "self"], env: { ...bunEnv, BUN_TMPDIR: tmpdir }, stdin: "inherit", stderr: "inherit", stdout: "pipe", }); const stdout = result.stdout.toString().trim(); expect(stdout).toBe("hello world!"); expect(result.success).toBeTrue(); if (process.platform !== "win32") { expect(readdirSync(tmpdir), "bun should clean up .node files").toBeEmpty(); } else { // On Windows, we have to mark it for deletion on reboot. // Not clear how to test for that. } }, // CI runs tests under bun-profile (~700 MB on linux with ThinLTO // DWARF); --compile copies+reads+rewrites the whole thing to /tmp, // which on debian/ubuntu is disk-backed gp3. 30 * 1000, ); } it("`bun build`", async () => { await using dir = tempDir("node-file-build", { "package.json": JSON.stringify({ name: "napi-app", version: "1.0.0", type: format === "esm" ? "module" : "commonjs", }), }); const build = await Bun.build({ entrypoints: [join(__dirname, "napi-app/main.js")], outdir: dir, target, format, }); expect(build.logs).toBeEmpty(); for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, stdin: "inherit", stderr: "inherit", stdout: "pipe", }); const stdout = result.stdout.toString().trim(); expect(stdout).toBe("hello world!"); expect(result.success).toBeTrue(); } }); }); }); describe("issue_7685", () => { it("works", async () => { const args = [...Array(20).keys()]; await checkSameOutput("test_issue_7685", args); }); }); describe("issue_11949", () => { it("napi_call_threadsafe_function should accept null", async () => { const result = await checkSameOutput("test_issue_11949", []); expect(result).toStartWith("data = 1234, context = 42"); }); }); describe("napi_get_value_string_utf8 with buffer", () => { // see https://github.com/oven-sh/bun/issues/6949 it("copies one char", async () => { const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 2]); expect(result).toEndWith("str: a"); }); it("copies null terminator", async () => { const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 1]); expect(result).toEndWith("str:"); }); it("copies zero char", async () => { const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 0]); expect(result).toEndWith("str: *****************************"); }); it("copies more than given len", async () => { const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 25]); expect(result).toEndWith("str: abcdef"); }); // TODO: once we upgrade the Node version on macOS and musl to Node v24.3.0, remove this TODO it.todoIf(isCI && (isMacOS || isMusl))("copies auto len", async () => { const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 424242]); expect(result).toEndWith("str: abcdef"); }); }); describe("napi_get_value_string_*", () => { it("behaves like node on edge cases", async () => { await checkSameOutput("test_get_value_string", []); }); }); it("#1288", async () => { const result = await checkSameOutput("self", []); expect(result).toBe("hello world!"); }); describe("handle_scope", () => { it("keeps strings alive", async () => { await checkSameOutput("test_napi_handle_scope_string", []); }); it("keeps bigints alive", async () => { await checkSameOutput("test_napi_handle_scope_bigint", []); }, 10000); it("keeps the parent handle scope alive", async () => { await checkSameOutput("test_napi_handle_scope_nesting", []); }); it("exists when calling a napi constructor", async () => { await checkSameOutput("test_napi_class_constructor_handle_scope", []); }); it("exists while calling a napi_async_complete_callback", async () => { await checkSameOutput("create_promise", [false]); }); it("keeps arguments moved off the stack alive", async () => { await checkSameOutput("test_napi_handle_scope_many_args", ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]); }); }); describe("escapable_handle_scope", () => { it("keeps the escaped value alive in the outer scope", async () => { await checkSameOutput("test_napi_escapable_handle_scope", []); }); }); describe("napi_delete_property", () => { it("returns a valid boolean", async () => { await checkSameOutput( "test_napi_delete_property", // generate a string representing an array around an IIFE which main.js will eval // we do this as the napi_delete_property test needs an object with an own non-configurable // property "[(" + function () { const object = { foo: 42 }; Object.defineProperty(object, "bar", { get() { return 1; }, configurable: false, }); return object; }.toString() + ")()]", ); }); }); describe("napi_get_all_property_names", () => { it("own_only with skip_strings/skip_symbols includes non-enumerable own keys", async () => { const result = await checkSameOutput("test_get_all_property_names_own_only", []); expect(result).toContain(`own_only + skip_symbols: status=0 keys=["x", "ne"]`); expect(result).toContain(`own_only + skip_strings: status=0 keys=[Symbol(s), Symbol(nes)]`); expect(result).toContain(`own_only + all_properties: status=0 keys=["x", "ne", Symbol(s), Symbol(nes)]`); expect(result).toContain(`own_only + skip_symbols|enumerable: status=0 keys=["x"]`); expect(result).toContain(`own_only + skip_strings|enumerable: status=0 keys=[Symbol(s)]`); }); }); describe("napi_ref", () => { it("can recover the value from a weak ref", async () => { await checkSameOutput("test_napi_ref", []); }); it("napi_reference_ref returns 0 after the referent is collected", async () => { const output = await checkSameOutput("test_reference_ref_after_collect_driver", []); expect(output).toContain("get_reference_value is undefined=1 reference_ref count=0"); expect(output).toContain("second reference_ref count=0"); }); it("napi_get_buffer_info rejects a bare ArrayBuffer", async () => { const output = await checkSameOutput("test_napi_get_buffer_info_gate", []); expect(output).toContain("get_buffer_info(ArrayBuffer): status=1 pending=0"); expect(output).toContain("get_buffer_info(Uint8Array): status=0 pending=0"); expect(output).toContain("get_buffer_info(DataView): status=0 pending=0"); }); it("allows creating a handle scope in the finalizer", async () => { await checkSameOutput("test_napi_handle_scope_finalizer", []); }); it("prevents underflow when unref called on zero refcount", async () => { // This tests the fix for napi_reference_unref underflow protection await checkSameOutput("test_ref_unref_underflow", []); }); }); describe("napi_get_version / node_api_create_external_string_*", () => { it("reports Node-API v10 and accepts zero-length external strings", async () => { const result = await checkSameOutput("test_napi_v10_surface", []); expect(result).toContain("napi_get_version >= 10 = true"); expect(result).toContain("external latin1 empty: status=0 copied=0 finalized=1"); expect(result).toContain("external latin1 empty: length=0"); expect(result).toContain("external utf16 empty: status=0 copied=0 finalized=1"); expect(result).toContain("external utf16 empty: length=0"); expect(result).toContain("external utf16 nonempty: copied=0"); }); it("accepts (NULL, 0) and rejects length > INT_MAX", async () => { const output = await checkSameOutput("test_napi_external_string_args", []); expect(output).toContain("ext_string_latin1(NULL,0): status=0 pending=0"); expect(output).toContain("ext_string_latin1(ptr,INT_MAX+1): status=1 pending=0"); expect(output).toContain("ext_string_utf16(ptr,INT_MAX+1): status=1 pending=0"); }); }); describe("napi_create_external_buffer", () => { it("handles empty/null data without throwing", async () => { const result = await checkSameOutput("test_napi_create_external_buffer_empty", []); expect(result).toContain("PASS: napi_create_external_buffer with nullptr and zero length"); expect(result).toContain("PASS: napi_create_external_buffer with non-null data and zero length"); expect(result).toContain("PASS: napi_create_external_buffer with nullptr finalizer"); expect(result).not.toContain("FAIL"); }); it("finalize_cb is tied to the ArrayBuffer lifetime, not the Buffer view", async () => { const result = await checkSameOutput("test_external_buffer_data_lifetime", []); expect(result).toContain("PASS: external buffer data intact through ArrayBuffer after GC"); expect(result).not.toContain("FAIL"); }); it("empty buffer returns null pointer and 0 length from napi_get_buffer_info and napi_get_typedarray_info", async () => { const result = await checkSameOutput("test_napi_empty_buffer_info", []); expect(result).toContain("PASS: napi_get_buffer_info returns null pointer and 0 length for empty buffer"); expect(result).toContain("PASS: napi_get_typedarray_info returns null pointer and 0 length for empty buffer"); expect(result).toContain("PASS: napi_is_detached_arraybuffer returns true for empty buffer's arraybuffer"); expect(result).not.toContain("FAIL"); }); it("rejects with napi_pending_exception before adopting data when an exception is pending", async () => { const result = await checkSameOutput("test_external_buffer_with_pending_exception", []); expect(result).toContain("status=10"); expect(result).toContain("PASS: caller retains ownership on failure with pending exception"); expect(result).not.toContain("FAIL"); }); }); describe("napi_create_external_arraybuffer", () => { it("wraps caller data and does not fire finalize_cb while the ArrayBuffer is alive", async () => { const result = await checkSameOutput("test_external_arraybuffer_finalizer", []); expect(result).toContain("PASS: napi_create_external_arraybuffer wraps caller data without copying"); expect(result).toContain( "PASS: napi_create_external_arraybuffer finalizer not called while ArrayBuffer is alive", ); expect(result).toContain("PASS: napi_create_external_arraybuffer data intact after GC"); expect(result).not.toContain("FAIL"); }); it("rejects with napi_pending_exception before adopting external_data when an exception is pending", async () => { const result = await checkSameOutput("test_external_arraybuffer_with_pending_exception", []); expect(result).toContain("status=10"); expect(result).toContain("PASS: caller retains ownership on failure with pending exception"); expect(result).not.toContain("FAIL"); }); }); describe("pending-exception gate", () => { it("refuses and performs no side effects while a napi exception is pending", async () => { const result = await checkSameOutput("test_pending_exception_gate", []); // every gated call must report napi_pending_exception (10) for (const fn of [ "napi_object_freeze", "napi_object_seal", "napi_set_element", "napi_run_script", "napi_instanceof", "napi_strict_equals", "napi_wrap", "napi_get_prototype", "napi_get_date_value", "napi_get_array_length", "napi_create_date", "napi_create_dataview", "napi_create_promise", "napi_resolve_deferred", ]) { expect(result).toContain(`${fn}: status=10`); } // functions Node.js does NOT gate (CHECK_ENV) must still succeed for (const fn of [ "napi_get_global", "napi_create_reference", "napi_reference_unref", "napi_get_reference_value", "napi_create_bigint_int64", "napi_create_symbol", "napi_is_buffer", "napi_is_typedarray", "napi_get_instance_data", "napi_get_value_bigint_uint64", "napi_add_async_cleanup_hook", "napi_remove_async_cleanup_hook", ]) { expect(result).toContain(`${fn}: status=0`); } // side effects must NOT have happened expect(result).toContain("side_effect frozen=false"); expect(result).toContain("side_effect arr[7]=undefined"); expect(result).toContain("side_effect script_ran=false"); }); // Same ungated functions, but with the exception pending on the engine // (napi_call_function raises the napi_throw_error one before refusing), // which is the state node-addon-api builds its Error object in. They must // still succeed and must leave that exception pending. it("ungated functions succeed while an engine exception is pending and preserve it", async () => { const result = await checkSameOutput("test_ungated_calls_with_engine_exception", []); // printf() via the Windows CRT emits \r\n, so split on either ending. expect(result.split(/\r?\n/)).toEqual([ "napi_call_function: status=10", "napi_get_value_bigint_int64: status=0 value=-7", "napi_get_value_bigint_uint64: status=0 lossless=0", "napi_get_value_string_utf8: status=0 value=ungated", "napi_create_bigint_int64: status=0", "napi_create_bigint_uint64: status=0", "napi_create_symbol: status=0", "napi_create_array_with_length: status=0", "napi_is_array: status=0 is_array=1", "napi_create_string_utf8: status=0", "napi_create_int32: status=0", "exception pending after: true", "pending exception code: EPENDING", ]); }); // A node:vm timeout requested while the addon is inside those calls, again // with an engine exception pending: none of the calls reports it, the // exception they found is still the one pending when they are done, and the // timeout still stops the script once the addon returns. it("a termination requested during ungated calls is delivered after them, not by them", async () => { const result = await checkSameOutput("test_ungated_calls_through_vm_timeout", []); expect(result.split(/\r?\n/)).toEqual([ "napi_call_function: status=10", "ungated call failures: 0", "exception pending: before clear=true after clear=false", "ERR_SCRIPT_EXECUTION_TIMEOUT", ]); }); // A script / worker looping through ungated calls, stopped while inside one // of them nearly every time. Hangs when the request is lost. it("a node:vm timeout interrupts a script looping through ungated functions", async () => { const result = await checkSameOutput("test_ungated_calls_vm_timeout", []); expect(result.split(/\r?\n/)).toEqual(Array(5).fill("ERR_SCRIPT_EXECUTION_TIMEOUT")); }); // Worker startup dominates this one: about two seconds per worker under a // debug build, before any CI load. it("worker.terminate() stops a worker looping through ungated functions", async () => { 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); }); describe("status code alignment with Node.js", () => { it("matches Node for symbol description, empty named key, result-on-exception ordering, and module filename", async () => { const output = await checkSameOutput("test_napi_symbol_key_result_ordering", []); expect(output).toContain('set_named_property(""): status=0 pending=0'); expect(output).toContain('obj[""]=1'); expect(output).toContain("create_symbol(undefined): status=3 pending=0"); expect(output).toContain("create_symbol(null): status=3 pending=0"); expect(output).toContain('create_symbol(""): status=0 pending=0'); expect(output).toContain('create_symbol("") description: type=4 len=0'); expect(output).toContain("new_instance(throws): status=10 result_written=0"); expect(output).toContain("get_named_property(throws): status=10 result_written=0"); expect(output).toContain("has_named_property(throws): status=10 result_written=0"); expect(output).toContain("module_file_name: status=0 is_null=0"); }); it("returns the same napi_status as Node.js for invalid inputs", async () => { const result = await checkSameOutput("test_napi_status_codes_node26", []); // napi_wrap/unwrap/remove_wrap/add_finalizer on primitive -> napi_invalid_arg (1) for (const fn of ["napi_wrap", "napi_unwrap", "napi_remove_wrap", "napi_add_finalizer"]) { expect(result).toContain(`${fn}(number): status=1 pending=0`); } // napi_coerce_to_*: type-specific status, exception stays pending expect(result).toContain("napi_coerce_to_number(Symbol): status=6 pending=1"); expect(result).toContain("napi_coerce_to_string(Symbol): status=3 pending=1"); expect(result).toContain("napi_coerce_to_object(null): status=2 pending=1"); // napi_run_script: napi_generic_failure (9), exception stays pending expect(result).toContain("napi_run_script(throw): status=9 pending=1"); expect(result).toContain("napi_run_script(syntax): status=9 pending=1"); // napi_create_bigint_words > INT_MAX: napi_invalid_arg, no throw expect(result).toContain("napi_create_bigint_words(INT_MAX+1): status=1 pending=0"); // napi_create_buffer / napi_create_buffer_copy: napi_generic_failure (9) on alloc failure expect(result).toContain("napi_create_buffer(SIZE_MAX): status=9 pending=1"); expect(result).toContain("napi_create_buffer_copy(SIZE_MAX): status=9 pending=1"); // second napi_throw_error: napi_pending_exception, first message kept expect(result).toContain("napi_throw_error(2nd): status=10"); expect(result).toContain("napi_throw_error(2nd): kept=first"); // node_api_post_finalizer(NULL): napi_ok expect(result).toContain("node_api_post_finalizer(NULL): status=0 pending=0"); // napi_make_callback validation -> napi_invalid_arg for (const which of ["recv=NULL", "argc>0,argv=NULL", "func=number"]) { expect(result).toContain(`napi_make_callback(${which}): status=1 pending=0`); } // napi_ref/unref_threadsafe_function: env ignored, last_error untouched expect(result).toContain("napi_ref_threadsafe_function(env=NULL): status=0"); expect(result).toContain("napi_unref_threadsafe_function(env=NULL): status=0"); expect(result).toContain("napi_ref_threadsafe_function: last_error_preserved=1"); }); }); describe("napi_async_work", () => { it("null checks execute callbacks", async () => { const output = await checkSameOutput("test_napi_async_work_execute_null_check", []); expect(output).toContain("success!"); expect(output).not.toContain("failure!"); }); it("null checks complete callbacks after scheduling", async () => { // This test verifies that async work can be created with a null complete callback. // The output order can vary due to thread scheduling on Linux, so we normalize // the output lines before comparing. const [nodeResult, bunResult] = await Promise.all([ runOn("node", "test_napi_async_work_complete_null_check", []), runOn(bunExe(), "test_napi_async_work_complete_null_check", []), ]); // Filter out debug logs and normalize const cleanBunResult = bunResult.replaceAll(/^\[\w+\].+$/gm, "").trim(); // Both should contain these two lines, but order may vary const expectedLines = ["execute called!", "resolved to undefined"]; const nodeLines = nodeResult .trim() .split("\n") .filter(line => line) .sort(); const bunLines = cleanBunResult .split("\n") .filter(line => line) .sort(); expect(bunLines).toEqual(nodeLines); expect(bunLines).toEqual(expectedLines.sort()); }); it("works with cancelation", async () => { const output = await checkSameOutput("test_napi_async_work_cancel", [], { "UV_THREADPOOL_SIZE": "2" }); expect(output).toContain("success!"); expect(output).not.toContain("failure!"); }); }); describe("napi_threadsafe_function", () => { it("passes NULL js_callback to call_js when created without a func", async () => { const output = await checkSameOutput("test_tsfn_null_js_callback_driver", []); expect(output).toContain("js_callback == NULL: 1"); }); it("keeps the event loop alive without async_work", async () => { const result = await checkSameOutput("test_promise_with_threadsafe_function", []); expect(result).toContain("tsfn_callback"); expect(result).toContain("resolved to 1234"); expect(result).toContain("tsfn_finalize_callback"); }); it("does not hang on finalize", async () => { const result = await checkSameOutput("test_napi_threadsafe_function_does_not_hang_after_finalize", []); expect(result).toBe("success!"); }); it.each([0, 3])( "runs the finalizer and exits when the last reference is released after abort (%d queued items)", async queued => { const result = await checkSameOutput("test_threadsafe_function_abort_then_last_release", [queued]); expect(result).toContain("finalized: true"); }, ); it("wakes blocked producers, runs the finalizer and exits when aborted with a bounded queue", async () => { const result = await checkSameOutput("test_threadsafe_function_abort_blocked_producers", []); expect(result).toContain("finalized: true"); }); // A full bounded queue must not hide that the function is closing: the call // reports napi_closing (16) and consumes the caller's thread reference, so // the finalizer still runs. napi_queue_full (17) would strand it forever. it("reports napi_closing, not napi_queue_full, on an aborted full queue", async () => { const result = await checkSameOutput("test_threadsafe_function_abort_full_queue", []); expect(result).toContain("call after abort: 16\nfinalized: true"); }); it("drains microtasks between callbacks of one dispatch, not before the first", async () => { const result = await checkSameOutput("test_threadsafe_function_microtask_order", []); expect(result).toContain("callback 1\nmicrotask 1\ncallback 2\nmicrotask 2\ncallback 3"); }); it("reports what call_js throws as each item's uncaught exception and keeps draining", async () => { const result = await checkSameOutput("test_threadsafe_function_call_js_throws", []); expect(result).toContain("uncaughtException 3 call_js error 3"); expect(result).toContain("done 3"); }); // 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. // MIMALLOC_PURGE_DELAY=0 makes a stale event-loop pointer fault instead of // reading recycled memory that still happens to look intact. it("survives the last call and release after the creating worker is gone", async () => { // Both threadsafe functions are finalized at worker teardown; a later call // reports napi_closing (16), a later release napi_ok (0). const result = await checkSameOutput("test_threadsafe_function_orphaned_by_worker", [], { MIMALLOC_PURGE_DELAY: "0", }); expect(result).toContain("worker exited with 0\nfinalized=2 call=16 release=0"); }); // A finalizer running while a worker's env drains its finalizers can // register another (here: an external buffer with a finalize_cb). Bun runs // that one in the same cleanup rather than leaving it behind the walk. Bun- // only rather than same-output: node hands an external buffer's finalizer to // the BackingStore deleter, not to the env's tracked references, so a buffer // created during teardown dies with the isolate and node prints late=0. it("runs a finalizer that another finalizer registered during env cleanup", async () => { await using proc = spawn({ cmd: [bunExe(), join(__dirname, "napi-app/main.js"), "test_finalizer_registered_during_env_cleanup", "[]"], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(stdout).toContain("worker exited with 0\nlate=1"); expect(exitCode).toBe(0); }); // A call that reports napi_closing consumes the calling thread's reference // (node's ThreadSafeFunction::Push), so on an orphaned threadsafe function // it can drop the last one -- and then it must free it, or every worker that // leaves one behind leaks. An addon that uses the handle after napi_closing // (a release, say) therefore touches freed memory, in node as well: the docs // say to make no further use of it. Bun-only: reads bun's live tsfn count. // The spawned run takes >10s under a debug+ASAN bun, so the 5s default // times out on slow machines. it("frees an orphaned threadsafe function whose last reference a call consumed", async () => { await using proc = spawn({ cmd: [bunExe(), join(__dirname, "napi-app/main.js"), "test_threadsafe_function_orphan_leak", "[]"], env: { ...bunEnv, MIMALLOC_PURGE_DELAY: "0" }, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); // Each iteration: the dead worker leaves 5 orphaned threadsafe functions, // all 5 calls report napi_closing (16), and none is still alive after. A // leak shows up as orphaned=10 on the second iteration. const iteration = "orphaned=5 closing=5 leaked=0"; expect({ stdout: stdout .replaceAll("\r\n", "\n") .replaceAll(/^\[\w+\].+$/gm, "") .trim(), stderr, exitCode, signalCode: proc.signalCode, }).toMatchObject({ stdout: `${Array(5).fill(iteration).join("\n")}\nresolved to undefined`, exitCode: 0, signalCode: null, }); }, 30_000); // napi_create_threadsafe_function once the env has torn its threadsafe // functions down (here: from a cleanup hook that a threadsafe function's // teardown finalizer registered). There is no event loop left, so it must // fail instead of handing back a handle whose finalizer already ran. The // failed creation must not run the addon's finalizer either -- the addon's // own error handling owns those resources. Node creates one and returns // napi_ok, so this is bun-only. it("fails when created after the env is torn down", async () => { await using proc = spawn({ cmd: [bunExe(), join(__dirname, "napi-app/main.js"), "create_threadsafe_function_after_teardown", "[]"], 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, ]); // napi_generic_failure = 9, and no handle is written back. stderr is part // of the compared object so a crash or assert prints with the failure. expect({ stdout: stdout .replaceAll("\r\n", "\n") .replaceAll(/^\[\w+\].+$/gm, "") .trim(), stderr, exitCode, }).toMatchObject({ stdout: "registered\ntsfn finalizer at teardown\nlate cleanup hook: name=0 create=9 handle=null", exitCode: 0, }); }); }); describe("exception handling", () => { it("can check for a pending error and catch the right value", async () => { await checkSameOutput("test_get_exception", [5]); await checkSameOutput("test_get_exception", [{ foo: "bar" }]); }); it("can throw an exception from an async_complete_callback", async () => { const count = 10; await Promise.all(Array.from({ length: count }, () => checkSameOutput("create_promise", [true]))); }); it("napi_fatal_exception triggers uncaughtException for non-Error values", async () => { // Node's napi_fatal_exception only guards against a null argument; any // value reaches the uncaughtException path. Addons commonly forward // whatever a JS callback threw (strings, plain objects) verbatim. const addon = join(__dirname, "napi-app/build/Debug/napitests.node"); const code = ` const addon = require(${JSON.stringify(addon)}); const caught = []; process.on("uncaughtException", e => { caught.push(e); }); const values = ["addon says: something fatal", 42, { plain: "object" }, new Error("real error")]; const statuses = values.map(v => addon.call_fatal_exception(v)); process.on("exit", () => { console.log(JSON.stringify({ statuses, caught: caught.map(e => e instanceof Error ? String(e) : e), })); }); `; await using proc = spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(JSON.parse(stdout.trim())).toEqual({ statuses: [0, 0, 0, 0], caught: ["addon says: something fatal", 42, { plain: "object" }, "Error: real error"], }); expect(exitCode).toBe(0); }); }); describe("napi_adjust_external_memory", () => { it("applies negative deltas and reports the running total", async () => { const result = await checkSameOutput("test_napi_adjust_external_memory", []); // printf() via the Windows CRT emits \r\n, so split on either ending. expect(result.split(/\r?\n/)).toEqual([ "after_add-base=8192", "after_sub-after_add=-8192", "readback-after_sub=0", "readback-base=0", ]); }); }); describe("napi_run_script", () => { it("evaluates a basic expression", async () => { await checkSameOutput("test_napi_run_script", ["5 * (1 + 2)"]); }); it("provides the right this value", async () => { await checkSameOutput("test_napi_run_script", ["this === global"]); }); it("propagates exceptions", async () => { await checkSameOutput("test_napi_run_script", ["(()=>{ throw new TypeError('oops'); })()"]); }); it("cannot see locals from around its invocation", async () => { // variable should_not_exist is declared on main.js:18, but it should not be in scope for the eval'd code // this doesn't use await checkSameOutput because V8 and JSC use different error messages for a missing variable let bunResult = await runOn(bunExe(), "test_napi_run_script", ["shouldNotExist"]); // remove all debug logs bunResult = bunResult.replaceAll(/^\[\w+\].+$/gm, "").trim(); expect(bunResult).toBe( `synchronously threw ReferenceError: message "shouldNotExist is not defined", code undefined`, ); }); }); describe("napi_get_named_property", () => { it("handles edge cases", async () => { await checkSameOutput("test_get_property", []); }); }); describe("napi_set_named_property", () => { it("handles edge cases", async () => { await checkSameOutput("test_set_property", []); }); }); describe("napi_define_properties", () => { it("goes through [[DefineOwnProperty]] and validates the name", async () => { await checkSameOutput("test_define_properties", []); }); }); describe("napi_get_property_names / napi_get_all_property_names", () => { it("does not poison JSC's per-Structure own-keys cache", async () => { const output = await checkSameOutput("test_property_names_cache_poisoning", []); expect(output).toContain("Reflect.ownKeys after get_all_property_names(include_prototypes): a,b"); expect(output).toContain("Object.keys after get_property_names: w1,w2"); expect(output).toContain("napi get_property_names result: w1,w2,pEnum"); }); it("handles accessor properties when filtering by napi_key_writable", async () => { await checkSameOutput("test_get_all_property_names_accessor", []); }); it("matches Node for Proxy and String wrapper with napi_key_writable/napi_key_configurable", async () => { const output = await checkSameOutput("test_get_all_property_names_proxy_and_string_wrapper", []); expect(output).toContain(`proxy own_only writable: status=0 keys=["x","y"]`); expect(output).toContain(`proxy own_only configurable: status=0 keys=["x","y"]`); expect(output).toContain(`proxy(no traps) writable: status=0 keys=["ro","rw"]`); expect(output).toContain(`string own_only writable: status=0 keys=[0,1]`); expect(output).toContain(`string own_only configurable: status=0 keys=[0,1]`); expect(output).toContain(`derived string writable: status=0 keys=[0,1]`); expect(output).toContain(`proxy-proto include_prototypes writable: status=0 keys=["x","y"]`); expect(output).toContain(`string-proto include_prototypes configurable: status=0 keys=[0,1]`); expect(output).toContain(`plain writable: status=0 keys=["w","nc"]`); expect(output).toContain(`frozen writable: status=0 keys=[]`); }); }); describe("napi_value <=> integer conversion", () => { it("works", async () => { await checkSameOutput("test_number_integer_conversions_from_js", []); await checkSameOutput("test_number_integer_conversions", []); }); }); describe("arrays", () => { describe("napi_create_array_with_length", () => { it("creates an array with empty slots", async () => { await checkSameOutput("test_create_array_with_length", []); }); }); }); describe("napi_throw functions", () => { it("has the right code and message", async () => { await checkSameOutput("test_throw_functions_exhaustive", []); }); it("does not throw with nullptr", async () => { await checkSameOutput("test_napi_throw_with_nullptr", []); }); }); describe("napi_create_error functions", () => { it("has the right code and message", async () => { await checkSameOutput("test_create_error_functions_exhaustive", []); }); }); describe("napi_type_tag_object", () => { it("works", async () => { await checkSameOutput("test_type_tag", []); }); }); describe("napi_create_object", () => { // https://github.com/oven-sh/bun/issues/25658 it("result is clonable with structuredClone", async () => { await checkSameOutput("test_napi_create_object_structured_clone", []); }); }); describe("napi_create_arraybuffer", () => { it("returns zero-filled memory", async () => { const output = await checkSameOutput("test_create_arraybuffer_zeroed", []); expect(output).toBe("PASS: napi_create_arraybuffer memory is zero-filled"); }); }); describe("node_api experimental", () => { it("node_api_set_prototype sets [[Prototype]]", async () => { const output = await checkSameOutput("test_node_api_set_prototype", []); expect(output.split(/\r?\n/)).toEqual([ "set_prototype: proto_matches=true inherited=123", "set_prototype: null_proto_type=1", ]); }); it("node_api_create_object_with_properties creates an object with the given prototype and properties", async () => { const output = await checkSameOutput("test_node_api_create_object_with_properties", []); expect(output.split(/\r?\n/)).toEqual([ "create_object_with_properties: proto_type=1 a=1 b=2 sym=3 idx0=4", "create_object_with_properties: bad_name_status=4", "create_object_with_properties: custom_proto_matches=true", ]); }); it("node_api_create_sharedarraybuffer / is_sharedarraybuffer / create_external_sharedarraybuffer", async () => { const output = await checkSameOutput("test_node_api_sharedarraybuffer", []); expect(output.split(/\r?\n/)).toEqual([ "create_sharedarraybuffer: data_nonnull=true is_sab=true is_ab=false", "create_sharedarraybuffer: info_data_matches=true info_len=16", "is_sharedarraybuffer: plain_ab=false number=false", "create_external_sharedarraybuffer: is_sab=true data_matches=true len=8 first=176 finalized_early=false", ]); }); }); describe("napi_get_typedarray_info", () => { it("reports a zero byte offset for a view over the whole buffer and the view's byte offset for an offset view", async () => { const whole = await checkSameOutput("test_typedarray_info_byte_offset", "[new Uint8Array(new ArrayBuffer(64))]"); expect(whole).toBe( "byte_offset=0 length=64 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", ); const offset = await checkSameOutput( "test_typedarray_info_byte_offset", "[new Uint8Array(new ArrayBuffer(64), 48)]", ); expect(offset).toBe( "byte_offset=48 length=16 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", ); }); // Small typed arrays use JSC's "fast" mode, whose GC-heap storage is // abandoned when the backing ArrayBuffer is materialized. The data pointer // NAPI hands out must point at the storage that survives, or native writes // are silently dropped (see issue #37151). it("returns a data pointer whose writes are visible through small typed arrays", async () => { // 999/1000 straddle JSC's fastSizeLimit; 2048 is always wasteful mode. await Promise.all( [16, 999, 1000, 2048].map(async size => { const output = await checkSameOutput("test_typedarray_info_write_visibility", `[new Uint8Array(${size})]`); expect(output).toBe(`length=${size} first=42 last=42`); }), ); }); it("returns a data pointer at the reported byte offset for small typed arrays", async () => { await Promise.all( [ "new Uint8Array(0)", "new Uint8Array(64)", "new Int32Array(8)", // offset view derived from a small array "new Uint8Array(64).subarray(16)", ].map(async expr => { const output = await checkSameOutput("test_typedarray_info_byte_offset", `[${expr}]`); expect(output).toEndWith("data_is_arraybuffer_data_plus_byte_offset=true"); }), ); }); it("napi_get_buffer_info pointer stays valid after the backing ArrayBuffer is materialized", async () => { await Promise.all( ["Buffer.alloc(32)", "new Uint8Array(32)"].map(async expr => { const output = await checkSameOutput("test_buffer_info_pointer_stability", `[${expr}]`); expect(output).toBe("length=32 first=43 last=43"); }), ); }); it("napi_create_buffer and napi_create_buffer_copy pointers stay valid after the backing ArrayBuffer is materialized", async () => { const output = await checkSameOutput("test_create_buffer_pointer_stability", []); expect(output.split(/\r?\n/)).toEqual(["create_buffer last=44", "create_buffer_copy last=45"]); }); it("reports the view's byte offset into its backing buffer", async () => { const output = await checkSameOutput( "test_typedarray_info_byte_offset", "[new Uint8Array(new ArrayBuffer(64), 16, 8)]", ); expect(output).toBe( "byte_offset=16 length=8 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", ); }); it("reports the byte offset in bytes for an element type wider than one byte", async () => { const output = await checkSameOutput( "test_typedarray_info_byte_offset", "[new Int32Array(new ArrayBuffer(64), 32, 4)]", ); expect(output).toBe( "byte_offset=32 length=4 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", ); }); it("maps Float16Array to napi_float16_array in both napi_get_typedarray_info and napi_create_typedarray", async () => { const output = await checkSameOutput( "test_napi_float16_array", "[(() => { const f = new Float16Array(new ArrayBuffer(16), 4, 4); f.set([1.5, 2, 3, 4]); return f; })()]", ); // printf() via the Windows CRT emits \r\n, so split on either ending. expect(output.split(/\r?\n/)).toEqual([ "is_typedarray=1 info_status=0 type=11 length=4 byte_offset=4 e0=0x3E00", "arraybuffer_byte_length=16 data_is_ab_plus_offset=1", "create_status=0 created_is_typedarray=1 created_type=11 created_length=4", "created instanceof Float16Array=1", ]); }); }); describe("napi_get_dataview_info", () => { it("reports the view's byte offset into its backing buffer", async () => { const output = await checkSameOutput( "test_dataview_info_byte_offset", "[new DataView(new ArrayBuffer(64), 24, 8)]", ); expect(output).toBe( "byte_offset=24 byte_length=8 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", ); }); }); // TODO(@190n) test allocating in a finalizer from a napi module with the right version describe("napi_wrap", () => { it("accepts the right kinds of values", async () => { await checkSameOutput("test_napi_wrap", []); }); it("is shared between addons", async () => { await checkSameOutput("test_napi_wrap_cross_addon", []); }); it("does not follow prototypes", async () => { await checkSameOutput("test_napi_wrap_prototype", []); }); it("does not consider proxies", async () => { await checkSameOutput("test_napi_wrap_proxy", []); }); it("can remove a wrap", async () => { await checkSameOutput("test_napi_remove_wrap", []); }); it("has the right lifetime", async () => { await Promise.all([ checkSameOutput("test_wrap_lifetime_without_ref", []), checkSameOutput("test_wrap_lifetime_with_weak_ref", []), checkSameOutput("test_wrap_lifetime_with_strong_ref", []), checkSameOutput("test_remove_wrap_lifetime_with_weak_ref", []), checkSameOutput("test_remove_wrap_lifetime_with_strong_ref", []), // napi finalizers also run at VM exit, even if they didn't get run by GC checkSameOutput("test_ref_deleted_in_cleanup", []), // calling napi_delete_ref in the ref's finalizer is not use-after-free checkSameOutput("test_ref_deleted_in_async_finalize", []), ]); }); }); describe("napi_define_class", () => { it("handles edge cases in the constructor", async () => { await Promise.all([ checkSameOutput("test_napi_class", []), checkSameOutput("test_subclass_napi_class", []), checkSameOutput("test_napi_class_non_constructor_call", []), checkSameOutput("test_reflect_construct_napi_class", []), ]); }); it("does not crash with Reflect.construct when newTarget has no prototype", async () => { await checkSameOutput("test_reflect_construct_no_prototype_crash", []); }); }); describe("bigint conversion to int64/uint64", () => { it("works", async () => { const tests = [-1n, 0n, 1n]; for (const power of [63, 64, 65]) { for (const sign of [-1, 1]) { const boundary = BigInt(sign) * 2n ** BigInt(power); tests.push(boundary, boundary - 1n, boundary + 1n); } } const testsString = "[" + tests.map(bigint => bigint.toString() + "n").join(",") + "]"; await checkSameOutput("bigint_to_i64", testsString); await checkSameOutput("bigint_to_u64", testsString); }); it("returns the right error code", async () => { const badTypes = '[null, undefined, 5, "123", "abc"]'; await checkSameOutput("bigint_to_i64", badTypes); await checkSameOutput("bigint_to_u64", badTypes); await checkSameOutput("bigint_to_64_null", []); }); }); describe("create_bigint_words", () => { it("works", async () => { await checkSameOutput("test_create_bigint_words", []); }); it("returns correct word count with small buffer", async () => { // This tests the fix for the BigInt word count bug // When buffer is smaller than needed, word_count should still return actual words needed await checkSameOutput("test_bigint_word_count", []); }); }); describe("napi_get_last_error_info", () => { it("returns information from the most recent call", async () => { await checkSameOutput("test_extended_error_messages", []); }); it("returns a non-null message for napi_cannot_run_js", async () => { // A v10 addon calling napi_throw inside a teardown finalizer gets // napi_cannot_run_js. napi_get_last_error_info must then report a // non-null message (previously the error_messages table stopped at // napi_would_deadlock, so error_message was left as nullptr). const code = `globalThis.keep = require(${JSON.stringify( join(__dirname, "napi-app/build/Debug/test_last_error_cannot_run_js.node"), )});`; const run = async (exe: string) => { await using proc = spawn({ cmd: [exe, "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout: stdout.trim(), stderr, exitCode }; }; const [bun, node] = await Promise.all([run(bunExe()), run(await nodeExeMatchingAbi())]); expect(bun).toEqual(node); expect(bun).toEqual({ stdout: "napi_throw status=23 error_code=23 error_message=Cannot run JavaScript", stderr: "", exitCode: 0, }); }); }); describe.each(["buffer", "typedarray"])("napi_is_%s", kind => { const tests: Array<[string, boolean]> = [ ["new Uint8Array()", true], ["new BigUint64Array()", true], ["new ArrayBuffer()", false], ["Buffer.alloc(0)", true], ["new DataView(new ArrayBuffer())", kind == "buffer"], ["new (class Foo extends Uint8Array {})()", true], ["false", false], ["[1, 2, 3]", false], ["'hello'", false], ]; // main.js does eval then spread so to pass a single value we need to wrap in an array it.each(tests)("returns consistent values with node.js for %s", async (value, expected) => { const output = await checkSameOutput(`test_is_${kind}`, "[" + value + "]"); expect(output).toBe(`napi_is_${kind} -> ${expected.toString()}`); }); }); it.each([ ["nullptr", { number: 123 }], ["null", null], ["undefined", undefined], ])("works when the module register function returns %s", (returnKind, expected) => { expect(require(`./napi-app/build/Debug/${returnKind}_addon.node`)).toEqual(expected); }); it("works when the module register function throws", async () => { expect(() => require("./napi-app/build/Debug/throw_addon.node")).toThrow(new Error("oops!")); }); it("runs the napi_module_register callback after dlopen finishes", async () => { await checkSameOutput("test_constructor_order", []); }); it("handles napi_module_register called re-entrantly from nm_register_func", async () => { // The init callback of the first static-constructor-registered module // calls napi_module_register() 64 more times. Before the fix, that // appended to the same WTF::Vector the execute loop was range-for // iterating, reallocating it and leaving a dangling iterator for the // second static-constructor-registered module (heap-use-after-free // under ASAN, garbage nm_register_func pointer otherwise). const addonPath = join(__dirname, "napi-app", "build", "Debug", "reentrant_register_addon.node"); await using proc = spawn({ cmd: [bunExe(), "-e", `require(${JSON.stringify(addonPath)});`], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout.split(/\r?\n/).filter(Boolean)).toEqual([ "register_cb_a", "register_cb_b", "register_cb_reentrant x 64", ]); expect(exitCode).toBe(0); }); it("behaves as expected when performing operations with an exception pending", async () => { await checkSameOutput("test_deferred_exceptions", []); }); it("behaves as expected when performing operations with numeric string keys", async () => { await checkSameOutput("test_napi_numeric_string_keys", []); }); it("napi_get_named_property copies utf8 string data", async () => { // Must spawn bun directly (not via checkSameOutput/main.js) because the // bug only reproduces when global property names like "Response" haven't // been pre-atomized. Loading through main.js → module.js pre-initializes // globals, masking the use-after-free in the atom string table. const addonPath = join(__dirname, "napi-app", "build", "Debug", "napitests.node"); await using proc = spawn({ cmd: [ bunExe(), "-e", `const addon = require(${JSON.stringify(addonPath)}); addon.test_napi_get_named_property_copied_string(() => { Bun.gc(true); });`, ], 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).toBe(""); expect(stdout).toInclude("PASS"); expect(exitCode).toBe(0); }); it("behaves as expected when performing operations with default values", async () => { await checkSameOutput("test_napi_get_default_values", []); }); it("NAPI finalizer iterator invalidation crash prevention", () => { // This test verifies that the DeferGCForAWhile fix prevents iterator invalidation // during NAPI finalizer cleanup. While we couldn't reproduce the exact crash // conditions, this test ensures the addon loads and runs without issues. const addon = require("./napi-app/build/Debug/test_finalizer_iterator_invalidation.node"); // Create objects with finalizers (should not crash) const objects = addon.createProblematicObjects(5); expect(objects).toHaveLength(5); // Clear references objects.length = 0; // Get initial count const count = addon.getFinalizeCount(); expect(typeof count).toBe("number"); }); it("napi_wrap finalizers run in LIFO order during env teardown", async () => { // Mirrors sqlite3/duckdb crash: a child wrapped after its parent must be finalized // first so its destructor can still touch the parent. Bun previously iterated an // unordered_set here, so order was hash-dependent and the child could see a freed parent. const code = ` const addon = require(${JSON.stringify(join(__dirname, "napi-app/build/Debug/test_wrap_cleanup_order.node"))}); globalThis.keep = addon.createParentAndChildren(32); `; await using proc = spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout.trim()).toBe( "finalize order: " + Array.from({ length: 32 }, (_, i) => 32 - i) .concat(0) .join(" "), ); expect(exitCode).toBe(0); }); it("napi_create_error succeeds during env cleanup when a prior finalizer leaked a VM exception (#30286)", async () => { // Reproduces the gitnexus + tree-sitter crash: one finalizer left a // pending JSC exception on the VM, the next finalizer called // napi_create_error, and Bun returned napi_pending_exception. Under // node-addon-api's Error::New that turns into // NAPI FATAL ERROR: Error::New napi_create_error // during web_worker.exitAndDeinit. After the fix napi_create_error // ignores pre-existing VM exceptions (matching Node.js) so the // finalizer completes cleanly and the process exits 0. const code = ` const addon = require(${JSON.stringify(join(__dirname, "napi-app/build/Debug/test_finalizer_create_error.node"))}); // A function that throws -- called from the first-to-run finalizer // so the throw leaves a JSC VM exception pending for the next // finalizer (the one that calls napi_create_error). globalThis.keep = addon.setup(() => { throw new Error("from js"); }); `; await using proc = spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // napi_ok == 0 -- before the fix this was 10 (napi_pending_exception) in // release builds, or an ASAN abort in debug builds. A panic would leave // stdout empty, so the positive assertion covers both crash modes without // relying on stderr-contains-"panic" (which is unreliable per CLAUDE.md). expect(stdout.trim()).toBe("create_error_status=0"); expect(exitCode).toBe(0); expect(stderr).toBe(""); }); it("the first napi finalizer starts clean when a cleanup hook leaked a VM exception (#30286)", async () => { // The node-canvas shape of #30286 (terminated Workers): a native // teardown callback fails internally, its scheduled exception gets // promoted onto the JSC VM (napi_call_function's prologue does this // before validating arguments), and the exception is still pending // when NapiEnv::cleanup() reaches the FIRST wrap finalizer. That // finalizer's first napi call (napi_create_string_utf8 in // node-addon-api's ObjectWrap teardown) then fails with // napi_pending_exception and the addon escalates to napi_fatal_error // ("Error::Error napi_create_object"). Cleanup hooks run before wrap // finalizers, so the addon's leaking hook reproduces the state // deterministically; the fix clears pending exceptions before the // finalizer phase starts. const code = ` const addon = require(${JSON.stringify(join(__dirname, "napi-app/build/Debug/test_finalizer_create_error.node"))}); globalThis.keep = addon.setupSingle(); `; await using proc = spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // The wrap finalizer prints the napi_create_error status; 0 == napi_ok. // Before the fix the finalizer's napi_create_string_utf8 failed on the // hook's leaked exception (status -110 on the string step). expect(stdout.trim()).toBe("create_error_status=0"); expect(exitCode).toBe(0); expect(stderr).toBe(""); }); it("napi_reference_unref can be called from finalizers in regular modules", async () => { // This test ensures that napi_reference_unref can be called during GC // without triggering the NAPI_CHECK_ENV_NOT_IN_GC assertion for regular modules. // This was causing crashes with packages like rolldown-vite when used with Nuxt. // See: https://github.com/oven-sh/bun/issues/22596 const result = await checkSameOutput("test_reference_unref_in_finalizer", []); expect(result).toContain("Created 100 objects with finalizers"); expect(result).toContain("Finalizers called:"); expect(result).toContain("Unrefs succeeded:"); expect(result).toContain("SUCCESS: napi_reference_unref worked in finalizers without crashing"); expect(result).toContain("Test completed:"); }, 10_000); it.todoIf( // The test does not properly avoid the non-zero exit code on Windows. isWindows, )( "napi_reference_unref is blocked from finalizers in experimental modules", async () => { // Experimental NAPI modules should NOT be able to call napi_reference_unref from finalizers // The process should crash/abort when this is attempted // This matches Node.js behavior for experimental modules // Note: Node.js may not enforce this check for manually-registered experimental modules // (ones that set nm_version to NAPI_VERSION_EXPERIMENTAL manually) // But Bun should still enforce it for safety // Test with Bun - should crash // Use the wrapper script that kills the process after seeing the crash messages // to avoid hanging on llvm-symbolizer const { BUN_INSPECT_CONNECT_TO: _, ASAN_OPTIONS, ...rest } = bunEnv; const bunProc = spawn({ cmd: [bunExe(), join(__dirname, "napi-app/test_experimental_with_timeout.js")], env: { ...rest, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", // Override ASAN_OPTIONS to disable coredump and symbolization for this specific test // Otherwise ASAN will hang trying to create a core dump or symbolize ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0", }, stdout: "pipe", stderr: "pipe", }); const [bunStdout, bunStderr, bunExitCode] = await Promise.all([ bunProc.stdout.text(), bunProc.stderr.text(), bunProc.exited, ]); // The wrapper script should exit with 0 if the test passed expect(bunExitCode).toBe(0); expect(bunStdout + bunStderr).toContain("Loading experimental module"); expect(bunStdout + bunStderr).toContain("Created"); expect(bunStderr).toContain("FATAL ERROR"); expect(bunStdout + bunStderr).toContain("TEST PASSED: Process crashed as expected"); // The marker must NOT have actually been printed. Only check stdout: the // fixture prints the marker via console.log (stdout), while stderr contains // the debug-build panic report whose "Args:" line echoes the full -e script // source, including the literal "ERROR: Did not crash! Test failed!". expect(bunStdout).not.toContain("ERROR: Did not crash"); }, 25_000, ); }); // Kept outside describe.concurrent("napi") so RSS measurement isn't skewed by // the other tests' subprocesses and doesn't add load to the --compile tests. describe.skipIf(!canBuildNodeAddons())("napi_create_string_latin1", () => { it("does not leak the WTFStringImpl", async () => { const fixture = /* js */ ` const nativeTests = require(${JSON.stringify(join(__dirname, "napi-app/build/Debug/napitests.node"))}); const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; const size = 256 * 1024; for (let i = 0; i < 20; i++) { const s = nativeTests.create_latin1_string(size); if (s.length !== size) throw new Error("wrong length: " + s.length); } Bun.gc(true); const before = rss(); for (let i = 0; i < 300; i++) { const s = nativeTests.create_latin1_string(size); if (s.length !== size) throw new Error("wrong length: " + s.length); } Bun.gc(true); const growthMB = (rss() - before) / 1024 / 1024; console.error("RSS growth: " + growthMB.toFixed(1) + " MB"); process.exit(growthMB > Number(process.env.THRESHOLD_MB) ? 1 : 0); `; // 300 iterations * 256 KiB = 75 MB if every WTFStringImpl leaks. // ASAN's quarantine inflates RSS; widen the threshold there. const thresholdMB = isASAN ? 48 : 24; await using proc = Bun.spawn({ cmd: [bunExe(), "--smol", "-e", fixture], env: { ...bunEnv, THRESHOLD_MB: String(thresholdMB) }, 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("RSS growth:"), exitCode: 0, }); }); }); async function checkSameOutput(test: string, args: any[] | string, envArgs: Record = {}) { let [nodeResult, bunResult] = await Promise.all([ runOn("node", test, args, envArgs), runOn(bunExe(), test, args, envArgs), ]); nodeResult = nodeResult.trim(); // remove all debug logs bunResult = bunResult .replaceAll(/^\[\w+\].+$/gm, "") // TODO: we don't seem to print ProxyObject in this case. .replaceAll("function ProxyObject()", "function ()") .trim(); expect(bunResult).toEqual(nodeResult); return nodeResult; } async function runOn(executable: string, test: string, args: any[] | string, envArgs: Record = {}) { const env = { ...bunEnv, ...envArgs }; // "node" means a Node whose addon ABI matches the headers the fixture was // compiled against (the system node may lag the version Bun reports). if (executable === "node") executable = await nodeExeMatchingAbi(); const exec = spawn({ cmd: [ executable, "--expose-gc", join(__dirname, "napi-app/main.js"), test, typeof args == "string" ? args : JSON.stringify(args), ], env, stdout: "pipe", stderr: "pipe", stdin: "inherit", }); const [stdout, stderr, result] = await Promise.all([ new Response(exec.stdout).text(), new Response(exec.stderr).text(), exec.exited, ]); const errs = stderr.toString(); if (errs !== "") { throw new Error(errs); } expect(result).toBe(0); return stdout; } async function checkBothFail(test: string, args: any[] | string, envArgs: Record = {}) { const [node, bun] = await Promise.all( ["node", bunExe()].map(async executable => { if (executable === "node") executable = await nodeExeMatchingAbi(); const { BUN_INSPECT_CONNECT_TO: _, ...rest } = bunEnv; const env = { ...rest, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", ...envArgs }; const exec = spawn({ cmd: [ executable, "--expose-gc", join(__dirname, "napi-app/main.js"), test, typeof args == "string" ? args : JSON.stringify(args), ], env, stdout: Bun.version_with_sha.includes("debug") ? "inherit" : "pipe", stderr: Bun.version_with_sha.includes("debug") ? "inherit" : "pipe", stdin: "inherit", }); const exitCode = await exec.exited; return { exitCode, signalCode: exec.signalCode }; }), ); expect(node.exitCode || node.signalCode).toBeTruthy(); expect(!!node.exitCode).toEqual(!!bun.exitCode); expect(!!node.signalCode).toEqual(!!bun.signalCode); } describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { describe("execution order", () => { it("executes in reverse insertion order like Node.js", async () => { // Test that cleanup hooks execute in reverse insertion order (LIFO) await checkSameOutput("test_cleanup_hook_order", []); }); }); describe("napi_strict_equals", () => { it("should match JavaScript === operator behavior", async () => { const output = await checkSameOutput("test_napi_strict_equals", []); expect(output).toContain("PASS: NaN !== NaN"); expect(output).toContain("PASS: -0 === 0"); expect(output).toContain("PASS: 42 === 42"); expect(output).toContain("PASS: 42 !== 43"); expect(output).not.toContain("FAIL"); }); }); describe("napi_instanceof", () => { it("honors Symbol.hasInstance and propagates exceptions", async () => { const output = await checkSameOutput("test_napi_instanceof", []); expect(output).toContain("class/instance: status=0 result=true pending=false"); expect(output).toContain("class/plain-obj: status=0 result=false pending=false"); expect(output).toContain("arrow+hasInstance: status=0 result=true pending=false"); expect(output).toContain("bound+hasInstance: status=0 result=true pending=false"); expect(output).toContain("bare-arrow ctor: status=9 result=false pending=true errName=TypeError"); expect(output).toContain("hasInstance throws: status=9 result=false pending=true errName=RangeError"); expect(output).toContain("proxy get throws: status=9 result=false pending=true errName=RangeError"); expect(output).toContain( "number ctor: status=5 result=false pending=true errName=TypeError errCode=ERR_NAPI_CONS_FUNCTION", ); expect(output).toContain( "plain-obj ctor: status=5 result=false pending=true errName=TypeError errCode=ERR_NAPI_CONS_FUNCTION", ); expect(output).toContain("null ctor: status=2 result=false pending=true errName=TypeError errCode=undefined"); expect(output).toContain( "undefined ctor: status=2 result=false pending=true errName=TypeError errCode=undefined", ); }); }); describe("napi_call_function", () => { it("should handle null recv parameter consistently", async () => { const output = await checkSameOutput("test_napi_call_function_recv_null", []); expect(output).toContain("PASS"); expect(output).toContain("napi_call_function with valid recv succeeded"); expect(output).not.toContain("FAIL"); }); }); describe("napi_new_instance", () => { it("returns the same status codes as Node.js for non-constructible targets", async () => { const output = await checkSameOutput( "test_napi_new_instance_status", "[() => {}, (() => {}).bind(null), 42, null, {}, function () {}]", ); // arrow + bound arrow: napi_pending_exception with a pending TypeError expect(output).toContain("target 1: status=10 pending=1 type_error=1"); expect(output).toContain("target 2: status=10 pending=1 type_error=1"); // number / null / plain object: napi_invalid_arg, nothing thrown expect(output).toContain("target 3: status=1 pending=0 type_error=0"); expect(output).toContain("target 4: status=1 pending=0 type_error=0"); expect(output).toContain("target 5: status=1 pending=0 type_error=0"); // regular function: napi_ok expect(output).toContain("target 6: status=0 pending=0 type_error=0"); }); }); describe("napi_create_array_with_length", () => { it("should handle boundary values consistently", async () => { const output = await checkSameOutput("test_napi_create_array_boundary", []); expect(output).toContain("PASS"); expect(output).toContain("napi_create_array_with_length(10) created array with correct length"); expect(output).not.toContain("FAIL"); }); }); describe("napi_create_dataview", () => { it("should validate bounds and provide consistent error messages", async () => { const output = await checkSameOutput("test_napi_dataview_bounds_errors", []); expect(output).toContain("napi_create_dataview"); // Check for proper bounds validation }); }); describe("NULL napi_value arguments", () => { it("returns napi_invalid_arg instead of crashing", async () => { const output = await checkSameOutput("test_napi_null_value_args", []); expect(output).toContain("napi_detach_arraybuffer(NULL) -> 1"); expect(output).toContain("node_api_create_buffer_from_arraybuffer(NULL) -> 1"); expect(output).toContain("napi_strict_equals(NULL, NULL) -> 1"); expect(output).toContain("napi_instanceof(NULL, NULL) -> 1"); expect(output).toContain("napi_new_instance(NULL) -> 1"); expect(output).toContain("napi_is_array(NULL) -> 1"); expect(output).toContain("napi_is_error(NULL) -> 1"); expect(output).toContain("napi_is_arraybuffer(NULL) -> 1"); expect(output).toContain("napi_is_dataview(NULL) -> 1"); expect(output).toContain("napi_is_date(NULL) -> 1"); expect(output).toContain("napi_get_array_length(NULL) -> 1"); expect(output).toContain("napi_get_dataview_info(NULL) -> 1"); }); it("returns napi_invalid_arg for NULL env / NULL out-param instead of crashing", async () => { const output = await checkSameOutput("test_napi_null_env_and_result", []); expect(output).toContain("napi_typeof(NULL env) -> 1"); expect(output).toContain("napi_is_exception_pending(NULL env) -> 1"); expect(output).toContain("napi_call_function(NULL env) -> 1"); expect(output).toContain("napi_is_error(NULL result) -> 1"); expect(output).toContain("napi_async_init(NULL result) -> 1"); }); }); describe("napi_typeof", () => { it("should handle empty/invalid values", async () => { const output = await checkSameOutput("test_napi_typeof_empty_value", []); // This test explores edge cases with empty/invalid napi_values // Bun has special handling for isEmpty() that Node doesn't have expect(output).toContain("napi_typeof"); }); it("should return napi_function for AsyncContextFrame in threadsafe callback", async () => { // Test for https://github.com/oven-sh/bun/issues/25933 // When a threadsafe function is created inside AsyncLocalStorage.run(), // the callback gets wrapped in AsyncContextFrame. napi_typeof must // report it as napi_function, not napi_object. const output = await checkSameOutput("test_napi_typeof_async_context_frame", []); expect(output).toContain("PASS: napi_typeof returned napi_function"); }); it("should handle AsyncContextFrame in napi_make_callback", async () => { // When a threadsafe function's call_js_cb receives an AsyncContextFrame // as js_callback and passes it to napi_make_callback, it should succeed. const output = await checkSameOutput("test_make_callback_with_async_context", []); expect(output).toContain("PASS: napi_make_callback succeeded"); }); it("derives napi_make_callback status from pending exception, not return value", async () => { // Returning an Error is napi_ok; throwing (any value) is napi_pending_exception // and leaves the exception observable to napi_is_exception_pending. const output = await checkSameOutput( "test_napi_make_callback_status", "[() => 42, () => new Error('returned'), () => { throw new Error('thrown'); }, () => { throw 'string'; }]", ); expect(output).toContain("cb 1: status=0 pending=0 wrote_result=1 result_is_error=0"); expect(output).toContain("cb 2: status=0 pending=0 wrote_result=1 result_is_error=1"); expect(output).toContain("cb 3: status=10 pending=1 wrote_result=0 result_is_error=0"); expect(output).toContain("cb 4: status=10 pending=1 wrote_result=0 result_is_error=0"); }); it("should accept AsyncContextFrame in napi_create_threadsafe_function with null call_js_cb", async () => { // When a threadsafe function's call_js_cb receives an AsyncContextFrame // and passes it to a second napi_create_threadsafe_function with // call_js_cb=NULL, it should not reject with function_expected. const output = await checkSameOutput("test_create_tsfn_with_async_context", []); expect(output).toContain("PASS: napi_create_threadsafe_function accepted AsyncContextFrame"); }); it("should return napi_object for boxed primitives (String, Number, Boolean)", async () => { // Regression test for https://github.com/oven-sh/bun/issues/25351 // napi_typeof was incorrectly returning napi_string for String objects (new String("hello")) // when it should return napi_object (matching JavaScript's typeof behavior) const output = await checkSameOutput("test_napi_typeof_boxed_primitives", []); expect(output).toContain("PASS: primitive string returns napi_string"); expect(output).toContain("PASS: String object returns napi_object"); expect(output).toContain("PASS: Number object returns napi_object"); expect(output).toContain("PASS: Boolean object returns napi_object"); expect(output).toContain("All boxed primitive tests passed!"); }); }); describe("object API ToObject coercion", () => { // The element/property-name/prototype family must coerce primitive targets // via ToObject (succeeding for strings/numbers/booleans) and return // napi_object_expected with a pending TypeError for null/undefined. // napi_get_all_property_names must reject out-of-range enum arguments and // honor napi_key_keep_numbers so index keys come back as numbers. it("matches Node's CHECK_TO_OBJECT semantics and validates enums", async () => { const output = await checkSameOutput("test_napi_object_coercion", []); // Spot-check the lines that carry the most signal; checkSameOutput has // already asserted full byte-for-byte parity with Node. expect(output).toContain("set_element(number): status=0 pending=0"); expect(output).toContain("set_element(null): status=2 pending=1"); expect(output).toContain("get_element(string,1): status=0 pending=0"); expect(output).toContain("get_element(string,1) value=b"); expect(output).toContain("get_prototype(number): status=0 pending=0"); expect(output).toContain("get_prototype(number) is Number.prototype=1"); expect(output).toContain("get_prototype(null): status=2 pending=1"); expect(output).toContain("get_all_property_names(key_mode=99): status=1 pending=0"); expect(output).toContain("get_all_property_names(key_conversion=99): status=1 pending=0"); expect(output).toContain("keep_numbers key0 typeof=number"); expect(output).toContain("numbers_to_strings key0 typeof=string"); }); it("coerces primitives for define_properties/freeze/seal/type_tag/set_prototype", async () => { const output = await checkSameOutput("test_napi_toobject_coercion_node26", []); expect(output).toContain("define_properties(number): status=0 pending=0"); expect(output).toContain("define_properties(null): status=2 pending=1"); expect(output).toContain("object_freeze(number): status=0 pending=0"); expect(output).toContain("object_freeze(null): status=2 pending=1"); expect(output).toContain("object_seal(number): status=0 pending=0"); expect(output).toContain("object_seal(undefined): status=2 pending=1"); expect(output).toContain("type_tag_object(number): status=0 pending=0"); expect(output).toContain("type_tag_object(null): status=10 pending=1"); expect(output).toContain("check_object_type_tag(number): status=0 pending=0"); expect(output).toContain("check_object_type_tag(null): status=10 pending=1"); expect(output).toContain("node_api_set_prototype(number): status=0 pending=0"); expect(output).toContain("node_api_set_prototype(null): status=2 pending=1"); }); }); describe("napi_object_freeze and napi_object_seal", () => { it("should handle arrays with indexed properties", async () => { const output = await checkSameOutput("test_napi_freeze_seal_indexed", []); // Bun has a check for indexed properties that Node.js doesn't have // This might cause different behavior when freezing/sealing arrays expect(output).toContain("freeze"); }); }); describe("napi_is_arraybuffer", () => { it("distinguishes ArrayBuffer from SharedArrayBuffer and typed arrays", async () => { // https://github.com/oven-sh/bun/issues/32624 // napi_is_arraybuffer must report false for a SharedArrayBuffer the way // Node does, even though JSC gives it the same cell type as a plain // ArrayBuffer. napi_get_arraybuffer_info still accepts a SharedArrayBuffer // in Node (napi_ok), so the check below pins that asymmetry too. // napi_ok is 0 and napi_invalid_arg is 1. const output = await checkSameOutput( "test_is_arraybuffer", "[new ArrayBuffer(8), new SharedArrayBuffer(8), new Uint8Array(8)]", ); // printf() via the Windows CRT emits \r\n, so split on either ending. expect(output.split(/\r?\n/)).toEqual([ "napi_is_arraybuffer=true napi_get_arraybuffer_info=0", "napi_is_arraybuffer=false napi_get_arraybuffer_info=0", "napi_is_arraybuffer=false napi_get_arraybuffer_info=1", ]); }); }); describe("napi_detach_arraybuffer", () => { it("rejects SharedArrayBuffer instead of returning napi_ok for a no-op detach", async () => { // napi_ok on a SharedArrayBuffer is a memory-lifetime lie: the addon // believes the backing store is neutralized while JS (and other threads) // still read and write it. Node rejects a SharedArrayBuffer with // napi_arraybuffer_expected (19) because V8's IsArrayBuffer() is false // for a SharedArrayBuffer. The same ArrayBuffer is passed twice so the // third row covers a second detach on an already-detached buffer. const output = await checkSameOutput( "test_detach_arraybuffer", "(() => { const ab = new ArrayBuffer(8); return [new SharedArrayBuffer(8), ab, ab, new Uint8Array(8)]; })()", ); expect(output.split(/\r?\n/)).toEqual([ "napi_detach_arraybuffer=19 napi_is_detached_arraybuffer=0 is_detached=false napi_get_arraybuffer_info=0 length=8", "napi_detach_arraybuffer=0 napi_is_detached_arraybuffer=0 is_detached=true napi_get_arraybuffer_info=0 length=0", "napi_detach_arraybuffer=0 napi_is_detached_arraybuffer=0 is_detached=true napi_get_arraybuffer_info=0 length=0", "napi_detach_arraybuffer=19 napi_is_detached_arraybuffer=0 is_detached=false napi_get_arraybuffer_info=1 length=0", ]); }); }); describe("error handling", () => { it("removing non-existent env cleanup hook should not crash", async () => { // Test that removing non-existent hooks doesn't crash the process await checkSameOutput("test_cleanup_hook_remove_nonexistent", []); }); it("removing non-existent async cleanup hook should not crash", async () => { // Test that removing non-existent async hooks doesn't crash await checkSameOutput("test_async_cleanup_hook_remove_nonexistent", []); }); it("hook handle stays valid until the addon removes it (#37201)", async () => { // An async cleanup hook releases a threadsafe function whose finalizer // then calls napi_remove_async_cleanup_hook; the handle must not be // freed when the hook returns. const output = await checkSameOutput("test_async_cleanup_hook_tsfn_release", []); // Pin the successful lifecycle, so matching Node on a shared failure // (non-zero status in both) cannot pass. printf() via the Windows CRT // emits \r\n, so split on either ending. expect(output.split(/\r?\n/).slice(-4)).toEqual([ "async cleanup hook fired", "released tsfn: status=0", "tsfn finalize: removing async cleanup hook", "async cleanup hook removed: status=0", ]); }); }); describe("duplicate prevention", () => { it("should crash on duplicate hooks", async () => { await checkBothFail("test_cleanup_hook_duplicates", []); }, 10_000); }); });