Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/jsc/bindings/napi.h
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,11 @@ struct NapiEnv : public WTF::RefCounted<NapiEnv> {
delete handle;
}

// isShuttingDown(): ~VM's Heap::lastChanceToFinalize fires the remaining weak-handle
// finalizers and precise-allocation destructors without entering the Sweeping mutator state.
Comment thread
robobun marked this conversation as resolved.
Outdated
bool inGC() const
{
return this->vm().isCollectorBusyOnCurrentThread();
return this->vm().isCollectorBusyOnCurrentThread() || this->vm().heap.isShuttingDown();
}

void checkGC() const
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/bindings/napi_handle_scope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ NapiHandleScopeImpl* NapiHandleScope::open(Zig::GlobalObject* globalObject, bool
// 2. Do an allocation in a hot code path
// 3. the napi_ref finalizer is called while the constructor is running
// 4. The finalizer creates a new handle scope (yes, it should not do that. No, we can't change that.)
if (vm.heap.mutatorState() == JSC::MutatorState::Sweeping) {
// isShuttingDown(): same thing from ~VM's finalizers, which do not set Sweeping (see NapiEnv::inGC()).
if (vm.heap.mutatorState() == JSC::MutatorState::Sweeping || vm.heap.isShuttingDown()) {
return nullptr;
}

Expand Down
23 changes: 23 additions & 0 deletions test/napi/napi-app/binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,28 @@
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
],
},
{
"target_name": "test_vm_teardown_finalizers",
"sources": ["test_vm_teardown_finalizers.c"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"libraries": [],
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
"defines": [
"NAPI_DISABLE_CPP_EXCEPTIONS",
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
],
},
{
"target_name": "test_vm_teardown_finalizers_experimental",
"sources": ["test_vm_teardown_finalizers.c"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"libraries": [],
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
"defines": [
"NAPI_DISABLE_CPP_EXCEPTIONS",
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
"TEST_EXPERIMENTAL=1",
],
},
]
}
157 changes: 157 additions & 0 deletions test/napi/napi-app/test_vm_teardown_finalizers.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Finalizers that only the destruction of the JSC VM can reach.
//
// setup(kind) registers one finalizer of the given kind and then pins its
// object with a strong napi_ref that is never released, so no garbage
// collection can run the finalizer while the env is alive. Env teardown
// (NapiEnv::cleanup, whose last step is the instance data finalizer below)
// does not run these kinds either, so the finalizer is still registered when
// the JSC VM is destroyed afterwards: a worker_threads Worker exiting, or the
// main thread exiting under BUN_DESTRUCT_VM_ON_EXIT=1. JSC's
// Heap::lastChanceToFinalize then fires every remaining finalizer, without
// the bookkeeping a collection has (MutatorState::Sweeping), so Bun must
// recognize that state on its own.
//
// Built twice (binding.gyp): as a regular module, and with TEST_EXPERIMENTAL
// as a NAPI_EXPERIMENTAL module. Whatever the finalizer reports on stderr
// starts with "FAIL:".
// - Regular module: a finalizer may call any Node-API function, which cannot
// be honored while the heap is being destroyed, so it must not be invoked
// from there at all.
// - Experimental module: finalizers run synchronously from whatever frees
// their object and must not call functions that may affect GC state (Node
// aborts with "FATAL ERROR" when they do). The finalizer calls one such
// function, napi_get_undefined; Bun has to abort rather than let it through.

#ifdef TEST_EXPERIMENTAL
#define NAPI_EXPERIMENTAL
#define NODE_API_EXPERIMENTAL_NO_WARNING
#endif

#include <js_native_api.h>
#include <node_api.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define NODE_API_CALL(env, call) \
do { \
if ((call) != napi_ok) { \
napi_throw_error((env), NULL, #call " failed"); \
return NULL; \
} \
} while (0)

static int env_torn_down = 0;
static int pending_finalizers = 0;
static int instance_data;

static void instance_data_finalizer(napi_env env, void *data, void *hint) {
(void)env;
(void)data;
(void)hint;
env_torn_down = 1;
printf("env teardown: %d finalizer(s) still pending\n", pending_finalizers);
fflush(stdout);
}

// Registered as the finalize callback of every kind; `hint` is the kind name.
static void finalizer(napi_env env, void *data, void *hint) {
const char *kind = hint;
(void)data;
pending_finalizers--;

if (!env_torn_down) {
printf("finalizer: %s\n", kind);
fflush(stdout);
return;
}

// Only VM destruction gets here.
#ifdef TEST_EXPERIMENTAL
// Bun has to refuse this (it aborts, as during a collection); returning from
// it means the call went through.
napi_value undefined;
napi_status status = napi_get_undefined(env, &undefined);
fprintf(stderr,
"FAIL: %s finalizer called napi_get_undefined during VM destruction "
"and got status %d\n",
kind, (int)status);
#else
fprintf(stderr, "FAIL: %s finalizer ran after env teardown\n", kind);
// What node-addon-api's finalizer wrapper does before anything else; here it
// allocates a JSC cell in the heap being destroyed.
napi_handle_scope scope;
if (napi_open_handle_scope(env, &scope) == napi_ok) {
napi_close_handle_scope(env, scope);
}
#endif
}

static char *dup_kind(const char *kind) {
// Leaked on purpose: it is the finalize hint, read whenever the finalizer
// runs, including from VM destruction.
char *copy = malloc(strlen(kind) + 1);
strcpy(copy, kind);
return copy;
}

static napi_value setup(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
char kind[64];
NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
if (argc < 1) {
napi_throw_error(env, NULL, "setup(kind) needs a kind");
return NULL;
}
NODE_API_CALL(env, napi_get_value_string_utf8(env, argv[0], kind,
sizeof(kind), NULL));

napi_value pinned;
if (strcmp(kind, "add_finalizer") == 0) {
// No napi_ref asked for: registered straight on the JSC heap
// (Heap::addFinalizer).
NODE_API_CALL(env, napi_create_object(env, &pinned));
NODE_API_CALL(env, napi_add_finalizer(env, pinned, NULL, finalizer,
dup_kind(kind), NULL));
} else if (strcmp(kind, "add_finalizer_ref") == 0) {
// Registered through a weak NapiRef (the napi_ref variant).
napi_ref weak;
NODE_API_CALL(env, napi_create_object(env, &pinned));
NODE_API_CALL(env, napi_add_finalizer(env, pinned, NULL, finalizer,
dup_kind(kind), &weak));
} else if (strcmp(kind, "external") == 0) {
// Runs from the NapiExternal cell's destructor.
NODE_API_CALL(env, napi_create_external(env, NULL, finalizer,
dup_kind(kind), &pinned));
} else if (strcmp(kind, "empty_external_buffer") == 0) {
// A zero-length external buffer has no contents to hang the finalizer
// on, so it too is registered straight on the JSC heap.
NODE_API_CALL(env, napi_create_external_buffer(env, 0, NULL, finalizer,
dup_kind(kind), &pinned));
} else {
napi_throw_error(env, NULL, "unknown kind");
return NULL;
}
pending_finalizers++;

// Never deleted: the object stays reachable until the VM is destroyed.
napi_ref pin;
NODE_API_CALL(env, napi_create_reference(env, pinned, 1, &pin));

napi_value undefined;
NODE_API_CALL(env, napi_get_undefined(env, &undefined));
return undefined;
}

static napi_value init(napi_env env, napi_value exports) {
NODE_API_CALL(env, napi_set_instance_data(env, &instance_data,
instance_data_finalizer, NULL));
napi_value setup_fn;
NODE_API_CALL(env, napi_create_function(env, "setup", NAPI_AUTO_LENGTH,
setup, NULL, &setup_fn));
NODE_API_CALL(env, napi_set_named_property(env, exports, "setup", setup_fn));
return exports;
}

NAPI_MODULE(test_vm_teardown_finalizers, init)
27 changes: 27 additions & 0 deletions test/napi/napi-app/vm-teardown-finalizers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Usage: bun vm-teardown-finalizers.js <addon target> <kind>[,<kind>...] [--main-thread]
//
// Loads build/Debug/<addon target>.node (see test_vm_teardown_finalizers.c)
// and registers one pinned finalizer per kind, in a worker_threads Worker
// that then exits, or on the main thread with --main-thread (run under
// BUN_DESTRUCT_VM_ON_EXIT=1 so that the main thread's VM is destroyed too).
// Any other argument is ignored.
const { Worker, isMainThread, workerData } = require("node:worker_threads");
const path = require("node:path");

function setup({ addon, kinds }) {
const { setup } = require(path.join(__dirname, "build/Debug", addon + ".node"));
for (const kind of kinds) setup(kind);
console.log("registered:", kinds.join(","));
}

if (!isMainThread) {
setup(workerData);
} else {
const [addon, kindList] = process.argv.slice(2);
const args = { addon, kinds: kindList.split(",") };
if (process.argv.includes("--main-thread")) {
setup(args);
} else {
new Worker(__filename, { workerData: args }).on("exit", code => console.log("worker exited:", code));
}
}
87 changes: 86 additions & 1 deletion test/napi/napi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,12 +1126,13 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => {
await checkSameOutput("bigint_to_i64", testsString);
await checkSameOutput("bigint_to_u64", testsString);
});
// Three serial node + bun runs: over 5s on a debug ASAN build.
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", []);
});
}, 10_000);
});

describe("create_bigint_words", () => {
Expand Down Expand Up @@ -1445,6 +1446,90 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => {
},
25_000,
);

// Destroying a JSC VM (a Worker exiting; the main thread under
// BUN_DESTRUCT_VM_ON_EXIT) fires every finalizer still registered. That
// happens after env teardown and without the "sweeping" mutator state a
// collection has, which Bun used to mistake for an ordinary non-GC context:
// it ran the addon's callbacks inline, inside the heap being destroyed. The
// fixture pins its objects with strong refs, so VM destruction is the only
// thing that can reach these finalizers; the "still pending" count shows they
// were all still registered when env teardown finished.
describe("finalizers still registered when the VM is destroyed", () => {
const fixture = join(__dirname, "napi-app/vm-teardown-finalizers.js");
const kinds = ["add_finalizer", "add_finalizer_ref", "external", "empty_external_buffer"];

// The ASAN lanes export BUN_DESTRUCT_VM_ON_EXIT and LSan settings that the
// children would inherit. The variants below choose which VM is destroyed
// themselves, and the fixture's pins are never deleted (nothing of the
// addon's runs after the point they have to survive), so they keep its
// NapiEnv and the VM handle ref it holds alive, which LSan reports once the
// Worker is gone.
const { BUN_DESTRUCT_VM_ON_EXIT: _destruct, BUN_INSPECT_CONNECT_TO: _inspect, ...inheritedEnv } = bunEnv;
const fixtureEnv = (...asanOptions: string[]) => ({
...inheritedEnv,
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0", ...asanOptions].filter(Boolean).join(":"),
});

const vms = [
{ vm: "a worker_threads Worker", flags: [] as string[], extraEnv: {} as Record<string, string> },
{ vm: "the main thread", flags: ["--main-thread"], extraEnv: { BUN_DESTRUCT_VM_ON_EXIT: "1" } },
];
it.each(vms)(
"a regular module's finalizers are not invoked from the dying heap of $vm",
async ({ flags, extraEnv }) => {
await using proc = spawn({
cmd: [bunExe(), fixture, "test_vm_teardown_finalizers", kinds.join(","), ...flags],
env: { ...fixtureEnv(), ...extraEnv },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Before the fix: one "FAIL: <kind> finalizer ran after env teardown" line
// per kind, then (debug builds) an assertion failure in JSCell::JSCell
// when the finalizer's handle scope allocates in the heap being destroyed.
expect(stderr).toBe("");
expect(stdout).toContain(`env teardown: ${kinds.length} finalizer(s) still pending`);
if (flags.length === 0) expect(stdout).toContain("worker exited: 0");
expect(exitCode).toBe(0);
},
);

// Experimental modules have their finalizers run synchronously by whatever
// frees the object, VM destruction included; what has to hold is that a
// GC-affecting call made from there is refused the same way it is during a
// collection. One kind per path JSC fires them from: the fixture's
// NapiExternal is destroyed as an individually allocated cell
// (PreciseAllocation::sweep), a napi_add_finalizer callback is a weak-handle
// finalizer (WeakBlock::lastChanceToFinalize). The first finalizer to run
// aborts the process, so each kind gets its own run.
it.each(["external", "add_finalizer"])(
"an experimental module's %s finalizer is told it is running from GC",
async kind => {
await using proc = spawn({
// The trailing flag makes a debug build's crash handler skip its slow
// symbolized backtrace; the fixture ignores it.
cmd: [
bunExe(),
fixture,
"test_vm_teardown_finalizers_experimental",
kind,
"--debug-crash-handler-use-trace-string",
],
env: { ...fixtureEnv("disable_coredump=1", "symbolize=0"), BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("env teardown: 1 finalizer(s) still pending");
// Before the fix: "FAIL: <kind> finalizer called napi_get_undefined
// during VM destruction and got status 0" and a clean exit.
expect(stderr).not.toContain("FAIL:");
expect(stderr).toContain("FATAL ERROR: Finalizer is calling a function that may affect GC state.");
expect(exitCode).not.toBe(0);
},
);
});
});

// Kept outside describe.concurrent("napi") so RSS measurement isn't skewed by
Expand Down
Loading