Skip to content

napi: treat finalizers fired by VM destruction as running from GC - #38506

Open
robobun wants to merge 5 commits into
mainfrom
farm/f3173059/napi-finalizers-during-vm-destruction
Open

napi: treat finalizers fired by VM destruction as running from GC#38506
robobun wants to merge 5 commits into
mainfrom
farm/f3173059/napi-finalizers-during-vm-destruction

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • When a JSC VM is destroyed (a worker_threads Worker exiting; the main thread under BUN_DESTRUCT_VM_ON_EXIT=1), Heap::lastChanceToFinalize fires every Node-API finalizer that is still registered. Bun did not recognize that as a GC context, so:
    • a regular (non-experimental) module's napi_add_finalizer callbacks (both the napi_ref and the result == NULL variants) and zero-length napi_create_external_buffer callbacks ran inline, after NapiEnv::cleanup() had already run the addon's cleanup hooks and instance-data finalizer. NapiEnv::mustDeferFinalizers() documents why that is never safe; the same callbacks are deferred and dropped when the final collection reaches them instead.
    • a node-addon-api style finalizer that opens a handle scope from there allocates a cell in a heap whose allocators are already stopped. Debug build: ASSERTION FAILED: m_cellState == CellState::DefinitelyWhite in JSC::JSCell::JSCell, from NapiHandleScopeImpl::create <- NapiHandleScope::open <- ... <- Heap::lastChanceToFinalize <- JSC::VM::~VM <- destroyVM.
    • an experimental module's finalizers got GC-affecting calls through (napi_get_undefined returns napi_ok from inside PreciseAllocation::sweep <- Heap::lastChanceToFinalize <- ~VM) instead of the FATAL ERROR the same call gets during a collection.
  • Cause: NapiEnv::inGC() (src/jsc/bindings/napi.h) is vm().isCollectorBusyOnCurrentThread(), i.e. mayBeGCThread() || mutatorState() != Running, and NapiHandleScope::open (src/jsc/bindings/napi_handle_scope.cpp) checks mutatorState() == Sweeping. lastChanceToFinalize runs on the mutator thread and leaves the state Running on two of its three paths: WeakSet::lastChanceToFinalize (weak-handle finalizers: Heap::addFinalizer lambdas and NapiRef owners) runs before the block sweep that installs a SweepingScope, and PreciseAllocation::sweep (destructors such as ~NapiExternal) never installs one. Only cells swept through MarkedBlock::Handle::sweep were detected.

Fix

  • inGC() also returns true while vm().heap.isShuttingDown(), and NapiHandleScope::open returns null in that state too. lastChanceToFinalize sets that flag before it fires anything, so every finalizer it fires now takes the path it takes from the final collectNow(): doFinalizer / NapiRef::callFinalizer defer a regular module's callback (which NapiFinalizerTask::schedule then drops, cleanup hooks having run), checkGC() refuses an experimental module's GC-affecting calls, and no handle scope cell is allocated. inGC() is the one predicate every Node-API GC check goes through (doFinalizer, callFinalizer, checkGC() behind NAPI_CHECK_ENV_NOT_IN_GC and napi_internal_check_gc); the handle scope check is the only one outside it.
  • Correct because a finalizer reached in that state is being run by the heap tearing itself down, which is the situation these checks exist for, and isShuttingDown() is set nowhere else: NapiEnv::cleanup(), wrap_cleanup and everything before destroyVM are unchanged. Experimental modules keep running their finalizers synchronously, as they do from a collection, so native memory they free at Worker exit is still freed.
  • Behaviour change to be aware of: for a regular module, the kinds listed under Problem (napi_add_finalizer either way, zero-length external buffers) used to have their callback run at Worker exit, unsafely; with this PR it is not run at all, exactly as already happens to the same callbacks when the final collection gets to the object first (the usual case: those objects are unreachable once the global is released; only objects still rooted, e.g. by a napi_ref, survive into ~VM). Running them at env teardown instead, as Node does and as Bun already does for napi_wrap and non-empty external buffers, is separate work: napi: run napi_add_finalizer and napi_create_external finalizers at env teardown #32912 does it for napi_add_finalizer and napi_create_external but predates Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075 and currently conflicts; nothing covers zero-length external buffers or external strings (whose callbacks a JSString cell swept by ~VM already had dropped, via the detected MarkedBlock path) yet. Whatever is left unbound still ends up in lastChanceToFinalize, so this check is needed in every one of those shapes.
  • Verified with test/napi/napi.test.ts ("finalizers still registered when the VM is destroyed"): new addon test_vm_teardown_finalizers.c, built as a regular and as an experimental module, registers one finalizer per kind and pins each object with a strong ref so only ~VM can reach it, in a Worker and on the main thread; the instance-data finalizer reports how many are still registered when env teardown finishes (4), which is what ties the test to the ~VM path (a change that starts running one of these kinds at env teardown fails that line and should drop the kind from the list). Before: one FAIL: line per kind (and the JSCell assertion on the debug build) for the regular module, status 0 and a clean exit for the experimental one; after: the regular module runs none of them, the experimental module aborts with the FATAL ERROR. Also reproduced on the current release canary with the fixture.
  • The fixture children run with LSan off: the pins are never deleted (nothing of the addon runs after the point they have to survive), and they keep the addon's NapiEnv, and the VM handle ref it holds, alive past the Worker's exit, which LSan otherwise reports on the ASAN lanes.
  • The rest of test/napi/napi.test.ts, napi-finalizer-delete-ref.test.ts, and the finalizer / reference / env-teardown / worker node-napi-tests pass on the debug ASAN build. One unrelated test in napi.test.ts (bigint error codes: three serial node + bun runs) takes ~5.4s on a debug ASAN build and gets the same 10s budget its neighbours have.

Background

  • Node-API finalizers come in two flavours. A regular module's finalizer may call any Node-API function, so Bun never runs it from inside the collector: doFinalizer / NapiRef::callFinalizer enqueue it as a NapiFinalizerTask when inGC() is true. An experimental (NAPI_EXPERIMENTAL) module's finalizer runs synchronously from the collector and may only call functions that do not affect GC state; checkGC() aborts the process with a FATAL ERROR otherwise, as Node does.
  • Env teardown is NapiEnv::cleanup(), a VM cleanup hook that runs with the exit handlers, before destroyVM: it runs the addon's cleanup hooks, the finalizers bound to the env (napi_wrap, non-empty external buffers) and finally the instance-data finalizer. Finalizers registered any other way stay on the JSC heap and are only reached by a collection or by ~VM.
  • Heap::mutatorState() is JSC's record of what the JS thread is doing; MutatorState::Sweeping is installed by a SweepingScope while a MarkedBlock is swept (destructors and weak finalizers run from there during a collection). Heap::finalize() installs one around the precise-allocation sweep too, so during a collection every finalizer is covered; lastChanceToFinalize is the one caller that is not.
  • A PreciseAllocation is a cell JSC allocates and sweeps individually rather than inside a MarkedBlock; the first few cells of every IsoSubspace (such as NapiExternal's) are allocated that way.
  • Heap::isShuttingDown() is the flag lastChanceToFinalize sets on entry; JSC itself uses it (CodeBlock, JSLock) to detect work happening during heap destruction.
  • destroyVM (src/jsc/bindings/ZigGlobalObject.cpp) releases the global, runs a final collectNow(), then the ~VM that calls lastChanceToFinalize. Workers always go through it; the main thread only with BUN_DESTRUCT_VM_ON_EXIT=1.

Heap::lastChanceToFinalize (reached from ~VM when a Worker exits, or on
the main thread under BUN_DESTRUCT_VM_ON_EXIT) fires every finalizer that
is still registered, but runs weak-handle finalizers and the destructors
of precise allocations without the Sweeping mutator state a collection
has. NapiEnv::inGC() only looked at that state, so a regular module's
napi_add_finalizer / empty external buffer callbacks ran inline from the
dying heap after env teardown, an experimental module's finalizers got
past the GC check, and NapiHandleScope::open allocated a cell in a heap
whose allocators were already stopped.

inGC() and the handle scope check now also consult heap.isShuttingDown(),
which lastChanceToFinalize sets first, so these finalizers take the same
paths they take during the final collection: deferred (and dropped by
NapiFinalizerTask::schedule) for regular modules, run under the GC check
for experimental ones.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65a850d0-3149-4173-b279-751df160a2d3

📥 Commits

Reviewing files that changed from the base of the PR and between 5a34f8d and 1afcde2.

📒 Files selected for processing (6)
  • src/jsc/bindings/napi.h
  • src/jsc/bindings/napi_handle_scope.cpp
  • test/napi/napi-app/binding.gyp
  • test/napi/napi-app/test_vm_teardown_finalizers.c
  • test/napi/napi-app/vm-teardown-finalizers.js
  • test/napi/napi.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 PM PT - Aug 14th, 2026

@robobun, your commit 1afcde2 is building: #96392

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on the current release canary and on a debug build with the new fixture (test/napi/napi-app/vm-teardown-finalizers.js + test_vm_teardown_finalizers.c): a regular module's napi_add_finalizer / empty external buffer finalizers ran after env teardown from inside ~VM (debug build then fails the m_cellState == CellState::DefinitelyWhite assertion in JSCell::JSCell when the finalizer opens a handle scope), and an experimental module's finalizers got napi_get_undefined through from PreciseAllocation::sweep <- Heap::lastChanceToFinalize.

Fix is in this PR (#38506): NapiEnv::inGC() and NapiHandleScope::open also treat heap.isShuttingDown() as GC context. Tests: test/napi/napi.test.ts, "finalizers still registered when the VM is destroyed".

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and found no bugs. The production change is two lines (inGC() and NapiHandleScope::open each gain a heap.isShuttingDown() check), but it changes finalizer semantics during Worker/VM teardown, so a human look would still be worthwhile.

What was reviewed:

  • Traced all four inGC() call sites (checkGC, doFinalizer, NapiRef::callFinalizer) — each takes the intended path when isShuttingDown() is true.
  • Confirmed NapiFinalizerTask::schedule already handles the is_shutting_down() && has_run_cleanup_hooks() case by dropping the task, so the newly-deferred regular-module finalizers are released rather than leaked or run.
  • Test fixture covers both build variants × both VM-destruction paths × four finalizer-registration kinds; assertions pin the pre-fix failure modes.
Extended reasoning...

Overview

The PR adds || vm().heap.isShuttingDown() to NapiEnv::inGC() in src/jsc/bindings/napi.h and the same disjunct to the Sweeping guard in NapiHandleScope::open in src/jsc/bindings/napi_handle_scope.cpp. Everything else is comment updates, a new C test addon (test_vm_teardown_finalizers.c, built twice via binding.gyp), a JS driver, four new test cases in napi.test.ts, and an unrelated 10s timeout on a slow bigint test.

Security risks

None. This is internal GC-state detection for the NAPI finalizer scheduler; no user-facing API surface, no parsing of untrusted input, no auth/crypto.

Level of scrutiny

High. Despite the tiny production diff, this sits at the intersection of JSC heap teardown, NAPI finalizer ordering, and Worker lifecycle — the most-blocked native memory-safety category in this repo. The change alters when addon finalizers run (or are dropped) at VM destruction, which is exactly the kind of semantic shift a maintainer should confirm: e.g. that silently dropping regular-module finalizers at Worker exit (via the existing NapiFinalizerTask::schedule shutdown branch) is the intended behavior pending #32912, and that no other inGC() caller is adversely affected by the widened predicate.

Other factors

  • I traced every inGC() caller in src/jsc/bindings/ and each behaves as the PR describes under the new condition: checkGC() aborts experimental modules, doFinalizer/callFinalizer route through napi_internal_enqueue_finalizer, and NapiFinalizerTask::schedule (napi_body.rs:5209) drops the task once cleanup hooks have run.
  • NapiHandleScope::open returning nullptr is already handled by every caller (the RAII wrapper and NapiHandleScope::close both null-check).
  • The test addon uses static globals (env_torn_down, pending_finalizers), which is fine because the fixture loads the addon in exactly one env per process (either the Worker or the main thread, never both).
  • The experimental-module test only runs the Worker path, not --main-thread — but both JSC firing paths (PreciseAllocation::sweep and weak-handle finalize) are covered per-kind, and the regular-module test covers both VMs.
  • No CODEOWNERS match the changed files. No prior human review comments to address.

Comment thread src/jsc/bindings/napi.h Outdated
Comment thread src/jsc/bindings/napi.h Outdated
Comment thread src/jsc/bindings/napi_handle_scope.cpp Outdated
Comment thread src/jsc/bindings/napi.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes how NAPI finalizer dispatch detects GC context during JSC VM teardown — a memory-safety-critical interaction between Heap::lastChanceToFinalize, mustDeferFinalizers(), and the Rust-side NapiFinalizerTask::schedule drop path — a human look from someone familiar with the NAPI/JSC finalizer lifecycle would still be worthwhile.

What was reviewed:

  • inGC() / NapiHandleScope::open: adding heap.isShuttingDown() is strictly more conservative; verified the deferred path (NapiFinalizerTask::schedule) already drops tasks when is_shutting_down() && has_run_cleanup_hooks(), so regular-module finalizers routed there during ~VM are released, not leaked or run.
  • Test fixture: pins via strong napi_ref so only lastChanceToFinalize reaches the finalizers; covers all four registration kinds × Worker/main-thread × regular/experimental; assertions check the positive invariant (pending count, FATAL ERROR text) rather than absence-of-crash.
  • comment-cop feedback was addressed in later commits — the current diff has one-line comments at both sites.
Extended reasoning...

Overview

Two-line native change plus ~270 lines of test infrastructure. NapiEnv::inGC() (src/jsc/bindings/napi.h) and NapiHandleScope::open (src/jsc/bindings/napi_handle_scope.cpp) each gain || vm.heap.isShuttingDown() so finalizers fired by Heap::lastChanceToFinalize during ~VM are treated as GC-context. New test addon test_vm_teardown_finalizers.c (built as regular and experimental), fixture vm-teardown-finalizers.js, two binding.gyp targets, and a new describe block in napi.test.ts. One unrelated timeout bump on the bigint error-code test.

Security risks

None. No user-controlled input parsing; the change tightens an internal safety predicate. The only observable behavior change is that (a) regular-module finalizers still registered at VM destruction are dropped instead of run inline in a dying heap, and (b) experimental-module finalizers making GC-affecting calls from there now abort as documented.

Level of scrutiny

High. This is JSC GC / NAPI finalizer lifecycle code — REVIEW.md's most-blocked category. The change is small and the direction (more conservative inGC()) is safe by construction, but the interaction between JSC's Heap::isShuttingDown(), Bun's Rust-side VirtualMachine::is_shutting_down(), NapiEnv::cleanup() ordering, and NapiFinalizerTask::schedule's drop path is subtle enough that a maintainer who owns this area should confirm the reasoning (and its relationship to #32912).

Other factors

  • I traced the deferred path: doFinalizernapi_internal_enqueue_finalizerNapiFinalizerTask::schedule checks vm.is_shutting_down() and has_run_cleanup_hooks() (src/runtime/napi/napi_body.rs:5209) and drops the task. Since NapiEnv::cleanup() runs before destroyVM, the drop branch is taken for finalizers deferred from lastChanceToFinalize, matching the PR's claim.
  • Test quality is strong: strong-ref pins guarantee only ~VM can reach the finalizers; the instance-data finalizer prints the pending count so the test asserts the precondition; regular-module tests assert stderr is empty and exit 0, experimental tests assert the exact FATAL ERROR text and non-zero exit; LSan is disabled with a stated reason (undeletable pins keep the NapiEnv ref alive past Worker exit).
  • The github-actions comment-cop flagged paragraph-length comments on earlier commits; commits 62c3c9a6 and 761939d2 shortened them to one-liners, which is what the current diff shows.
  • The bug hunting system found no issues.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug hunt found no issues. The two-line native change (inGC() and NapiHandleScope::open also checking heap.isShuttingDown()) looks correct and is thoroughly tested, but since it changes finalizer scheduling in the JSC GC/VM-teardown path — memory-safety-critical NAPI binding code — a human look is still worthwhile.

What was reviewed:

  • Confirmed NapiFinalizerTask::schedule (napi_body.rs:5209) already drops deferred finalizers when is_shutting_down && has_run_cleanup_hooks, so the widened inGC() routes regular-module finalizers to a path that safely discards them.
  • Checked the other inGC() call sites (checkGC, doFinalizer, NapiRef::callFinalizer) — all take the intended branch during heap shutdown.
  • The test fixture's detect_leaks=0 is justified (pins are intentionally never deleted) but is a weakened sanitizer that deserves a second pair of eyes.
Extended reasoning...

Overview

The production change is two lines: NapiEnv::inGC() in src/jsc/bindings/napi.h and the sweep guard in NapiHandleScope::open in src/jsc/bindings/napi_handle_scope.cpp each gain || vm.heap.isShuttingDown(). The rest is test infrastructure: a new C addon (test_vm_teardown_finalizers.c) built as both a regular and an experimental NAPI module, a JS driver, and four new test cases in napi.test.ts. There's also an unrelated 10s timeout on the bigint error-code test.

Security risks

No user-facing input parsing or auth surface. The risk is memory safety: getting the inGC() predicate wrong either lets addon finalizers allocate in a heap being destroyed (the bug being fixed — debug assertion, release UAF/corruption) or, in the other direction, would suppress finalizers that should run. The change is a strict widening gated on Heap::isShuttingDown(), which JSC only sets on entry to lastChanceToFinalize inside ~VM, so paths outside VM destruction are unchanged.

Level of scrutiny

High. This is JSC GC / NAPI finalizer interaction — the review guide's most-blocked category. The PR description traces the mechanism precisely (WeakSet::lastChanceToFinalize and PreciseAllocation::sweep run without SweepingScope), and I verified the deferred-finalizer sink in napi_body.rs:5209-5221 drops the task rather than running it once cleanup hooks have run. The design note that this is "complementary to, not a replacement for" running these finalizers at env teardown (#32912) is a judgment call a maintainer should weigh in on.

Other factors

The comment-cop bot flagged long comments; the author addressed all of them (now one-liners, all threads resolved). Test coverage is thorough: both VM-destruction paths (Worker exit, main thread with BUN_DESTRUCT_VM_ON_EXIT), both module flavours, all four registration kinds that survive env teardown, with assertions on stderr content and exit code. The fixture disables LSan for its children — the justification (undeleteable strong pins keep the NapiEnv/VM handle alive past Worker exit) is sound, but per the review guide weakening a sanitizer warrants explicit human sign-off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants