napi: run napi_add_finalizer and napi_create_external finalizers at env teardown#32912
napi: run napi_add_finalizer and napi_create_external finalizers at env teardown#32912robobun wants to merge 8 commits into
Conversation
|
Updated 6:49 AM PT - Jul 8th, 2026
❌ @robobun, your commit 6ed3422 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 32912That installs a local version of the PR into your bun-32912 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
I looked at each of these against their stack traces before adding any #32144 is not about which finalizers are registered for teardown. Its leaked objects are #30286 fatals inside a finalizer that is already running during #30431 reports two distinct crashes, and the Linux one ( |
Walkthrough
ChangesNAPI teardown finalizer correctness
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/napi/napi-teardown-finalizers.test.ts`:
- Around line 16-18: The rebuild guard in the native fixture setup is too weak
because the `existsSync(addonPath)` check in the teardown finalizers test only
skips when the `.node` file exists. Update the fixture build logic in
`napi-teardown-finalizers.test.ts` to compare the addon binary against its
source inputs, such as `test_teardown_finalizers.c` and `binding.gyp`, or
otherwise force a rebuild in this hook so local reruns don’t reuse stale native
code. Keep the fix centered around the fixture setup that resolves `addonPath`
and triggers `bun install`, and ensure the gating reflects source changes rather
than file existence alone.
- Around line 81-95: The current test in runFixture only verifies the final set
of finalized names, so it does not prove that gc_add_finalizer and gc_external
actually ran during Bun.gc(true) rather than at process exit. Add a clear
barrier in the fixture after the forced garbage collections and assert the gc_*
finalizer output appears before that barrier, while keeping the existing
exit-time checks for exit_add_finalizer and exit_external. Use the existing
runFixture, Bun.gc(true), and finalized assertions to ensure the test validates
GC-time finalization specifically, not just that finalizers were invoked once.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 826b55e0-0538-44f4-bd3b-ad6316b7f9ee
📒 Files selected for processing (6)
src/jsc/bindings/napi.cppsrc/jsc/bindings/napi_external.cppsrc/jsc/bindings/napi_external.htest/napi/napi-app/binding.gyptest/napi/napi-app/test_teardown_finalizers.ctest/napi/napi-teardown-finalizers.test.ts
There was a problem hiding this comment.
The drain-until-quiescent loop in cd76b3d (plus the wrapNesting test) addresses the UAF I flagged — every appended entry now runs its *_cleanup and nulls its owner's back-pointer before m_finalizers.clear(). Still deferring to a human given this reworks NAPI finalizer lifetime across GC and env teardown.
Extended reasoning...
Overview
This PR makes napi_add_finalizer and napi_create_external register their finalizers on the NapiEnv::m_finalizers teardown list (previously only napi_wrap did), so finalizers for objects still alive at exit run during env teardown as Node-API guarantees. It touches src/jsc/bindings/napi.{cpp,h} and napi_external.{cpp,h}, adds a C test addon, and a three-case test suite.
Since my previous review, commit cd76b3d changed NapiEnv::cleanup() from a single reverse pass + clear() to a do { … } while (ranAny) loop that re-scans until no active entries remain, and added a wrapNesting fixture/test that registers new finalizers from inside a teardown finalizer. I traced the new loop: each entry is marked active = false before its callback runs, the callback (wrap_cleanup / external_cleanup) nulls the owning NapiRef::boundCleanup / NapiExternal::m_boundCleanup, and newly appended entries are picked up on the next pass — so by the time m_finalizers.clear() frees the nodes, no live cell holds a pointer into them. That resolves the dangling-boundCleanup UAF I raised.
Security risks
No auth/crypto/input-parsing surface. The risk class here is memory safety (UAF / double-free) in native finalizer lifetime management, reachable from any addon. The specific hazard I identified is now mitigated; the new test exercises it and the ASAN CI failure from the first commit should be re-checked on this revision.
Level of scrutiny
High. This is C++ GC-heap / destructor-ordering code on a hot Node-API path used by every native addon, with subtle interactions between ListHashSet iterator stability, DeferGCForAWhile, weak-handle owners, and worker teardown. Mistakes here manifest as rare crashes in third-party addons. A maintainer familiar with Bun's NAPI ref/finalizer model should sign off.
Other factors
napi_add_finalizer(result == NULL)now allocates aNapiRefper call instead of the cheapervm.heap.addFinalizerpath; that's a deliberate cost-for-correctness trade the author should confirm is acceptable.- The two CodeRabbit test-quality nits and my prior inline comment are all addressed in cd76b3d.
- CI on the first commit showed the new test failing on Windows (CRLF, fixed) and on x64-asan; the asan result on the new commit is the thing I'd want a human to confirm is green before merge.
|
Thanks for the careful trace of the drain loop, and especially for calling out that the x64-asan result needed to be confirmed rather than assumed. You were right: the x64-asan failure on the first commit was not the Windows CRLF bug, and it still reproduced on cd76b3d. I have fixed it in b2ebce5. What it was. On the linux x64-asan lane, all three tests received a completely empty array, in about 120ms, meaning the spawned child produced no finalizer output at all. Two in-tree facts pin the cause. On that same lane, The failing assertions were also hiding this: they compared only the filtered name list, so CI reported an unreadable On the |
There was a problem hiding this comment.
The drain-until-idle rewrite in cd76b3d looks like it correctly closes the UAF I flagged, and I didn't find anything new in b2ebce5 — but this is subtle NAPI finalizer/GC lifecycle code with a rewritten teardown loop, so it's worth a human pass before merging.
Extended reasoning...
Overview
This PR registers napi_add_finalizer and napi_create_external finalizers on the env's teardown list (previously only napi_wrap was), so they run at env teardown for objects still alive at exit. It touches src/jsc/bindings/napi.cpp (rewrites napi_add_finalizer to always allocate a NapiRef with a weak handle owner and adds an external_cleanup registration to napi_create_external), napi.h (rewrites the NapiEnv::cleanup() drain loop to repeat until a pass runs nothing new), napi_external.{h,cpp} (adds m_boundCleanup and deactivates it in the destructor), plus a new C addon and test suite.
Security risks
No injection/auth/data-exposure surface. The risk profile here is memory safety: finalizer double-invocation, use-after-free on BoundFinalizer nodes, and GC/teardown ordering. My earlier review found a real UAF (finalizers registered during teardown leaving dangling boundCleanup pointers after m_finalizers.clear()); the author confirmed it and fixed it in cd76b3d by looping the reverse pass until idle and pre-deactivating each entry. That fix looks correct to me, and external_cleanup now mirrors wrap_cleanup's deactivate-then-call shape.
Level of scrutiny
High. This is production-critical native lifecycle code where the failure modes are heap corruption and crashes at process/worker shutdown, the interactions (GC sweep vs. teardown drain vs. DeferGCForAWhile vs. weak-handle-owner self-delete) are subtle, and the drain loop in NapiEnv::cleanup() was rewritten mid-review. The napi_add_finalizer path also switched from vm.heap.addFinalizer to NapiRef + NapiRefSelfDeletingWeakHandleOwner, which changes the ownership/deletion model for the result == NULL case.
Other factors
The author addressed all three prior review threads (my UAF, the two CodeRabbit test-shape comments) with code changes and detailed reasoning, added a regression test for the nested-registration case, and reports ASAN clean. CI on the first commit had real failures that were diagnosed and fixed in the two follow-ups. Given that a memory-safety bug was already found and fixed during review and the teardown loop is newly rewritten, a human reviewer familiar with Bun's NAPI ref/weak-owner machinery should sign off.
There was a problem hiding this comment.
Thanks for the thorough fix to the drain loop and the regression test — the UAF concern looks addressed. I'd still like a human to sign off here since this reworks finalizer lifetime across GC and env teardown with new raw-pointer bookkeeping on a GC cell.
Extended reasoning...
Overview
This PR makes napi_add_finalizer and napi_create_external register their finalizers on the per-env m_finalizers teardown list (previously only napi_wrap did), so they run at env teardown for objects still reachable at exit. It also reworks NapiEnv::cleanup() to loop the reverse drain until quiescent so finalizers registered mid-teardown are reached, adds a m_boundCleanup raw pointer to NapiExternal that the destructor deactivates, and adds a new native test addon plus three Bun tests.
Security risks
No injection/auth/data-exposure surface. The risk class here is memory safety: a new raw const BoundFinalizer* is stored on a GC-managed cell and must stay valid across the GC-sweep ↔ env-teardown ordering. My earlier review flagged a UAF on that pointer when a finalizer registered during teardown was freed by m_finalizers.clear(); the author fixed it by draining until a pass runs nothing new, deactivating each entry before calling it, and added an ASAN-verified regression test. I don't see a remaining concrete hole, but the invariants (deactivate-before-call, m_isFinishingFinalizers routing removals to active=false, clear() only after a fully-inactive pass) are subtle.
Level of scrutiny
High. This is production NAPI lifecycle code in C++ that interacts with JSC's GC, weak handle owners, and destructible-object sweep ordering. napi_add_finalizer also changed shape (now always allocates a NapiRef and uses NapiRefSelfDeletingWeakHandleOwner for the no-ref variant instead of vm.heap.addFinalizer), which is a behavior-equivalent-but-different code path. None of this is mechanical.
Other factors
- My prior inline UAF comment was addressed in cd76b3d with a clear explanation, a regression test, and ASAN confirmation; CodeRabbit's two test-quality nits were also addressed.
- The current bug-hunting pass found nothing.
- Test coverage is good (teardown-only, GC-then-teardown with a barrier, and nested-registration-during-teardown), and existing
test_wrap_lifetime_*/ external-buffer suites are reported unchanged. - Still, the combination of a new raw pointer on a GC cell, a modified drain loop, and a swapped weak-handle-owner path is exactly the kind of change that benefits from a maintainer familiar with Bun's NAPI/GC interaction taking a look.
|
Closing the loop on the x64-asan question, since it was the one open item. The lane is now explained, and it was not a teardown or memory-safety problem. What the lane showed. After I made the assertions self-diagnosing in b2ebce5, the x64-asan failure finally carried its own cause: every spawned child exited 134 with before a single finalizer ran. That is JSC's It is not caused by this PR. A It also cannot be fixed one site at a time, which I verified rather than assumed. I added the exception check the first message asks for (discharging the The fix (0da31b6) is to strip Two things a maintainer may want to act on separately: that agent should probably have the option removed from its environment, and |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/napi.h`:
- Around line 210-215: Shorten the long inline comments in the affected Napi
finalizer logic so each comment stays within the 3-line limit while preserving
the key invariant. Update the comments near the boundCleanup/finalizer handling
in napi.h to be more concise, and trim the other matching comment block
referenced in the review so the explanation stays brief with extra detail moved
out of the source.
In `@test/napi/napi-app/test_teardown_finalizers.c`:
- Around line 2-8: Trim the long teardown comment block in
test_teardown_finalizers.c to the repo’s 3-line limit by keeping only the
durable invariant about finalizers running once, teardown draining newly
registered finalizers, and stdout being used; move CI/debug history and extra
failure mechanics out of the comment. Apply the same reduction to the related
comment block noted in the review, preserving the intent around
teardown_finalizers and any nearby fixture names if needed for context.
In `@test/napi/napi-teardown-finalizers.test.ts`:
- Around line 11-14: Shorten the newly added explanatory comments in the napi
teardown finalizers test to satisfy the repo’s 3-line comment limit. Keep only
the essential invariant next to the relevant assertions in the test file (around
the gc_* finalizers/barrier logic in the test blocks), and move the extra
CI/bug-history context out of the code comments. Apply the same cleanup to the
other highlighted comment blocks so each comment stays brief and directly tied
to the local code.
- Line 94: Remove the explicit per-test timeout arguments from the affected
bun:test cases in napi-teardown-finalizers.test.ts and let bun:test use its
default timeout instead. Update the test declarations around the finalizer and
recycling cases so they no longer pass 30_000 (and any matching timeout in the
other referenced tests), keeping the existing test names and bodies unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1fe0080d-2e52-40d1-a8f1-9e6b2ffd15d2
📒 Files selected for processing (4)
src/jsc/bindings/napi.cppsrc/jsc/bindings/napi.htest/napi/napi-app/test_teardown_finalizers.ctest/napi/napi-teardown-finalizers.test.ts
There was a problem hiding this comment.
All my earlier findings are addressed and this round found nothing new, but the change reworks finalizer lifecycle and the cleanup() drain in memory-safety-critical NAPI bindings, so it's worth a maintainer's pass before merge.
Extended reasoning...
Overview
The PR makes napi_add_finalizer and napi_create_external register their finalizers on the per-env teardown list so they run at env teardown (Node parity). Concretely: napi_add_finalizer now always allocates a NapiRef and registers wrap_cleanup (both the ref-returning and result == NULL variants, the latter via NapiRefSelfDeletingWeakHandleOwner); napi_create_external registers a new external_cleanup and stores the bound entry on a new NapiExternal::m_boundCleanup field whose destructor deactivates it. NapiEnv::cleanup() is rewritten from a single reverse pass into a do-while drain that repeats until no active entries remain, and addFinalizer() reactivates the entry it returns to handle tombstone aliasing under TZone address reuse. BoundFinalizer::call() is removed as dead. A new C addon and four-test suite cover teardown, GC-vs-teardown exclusivity, nested registration during teardown, and the delete-then-reregister recycling path.
Security risks
No injection, auth, or data-exposure surface. The risk class is memory safety: dangling BoundFinalizer* back-pointers across m_finalizers.clear(), double-invocation of user finalizers, and iterator invalidation under reentrant registration. Two real UAFs in this class were found in earlier review rounds and fixed (the single-pass-then-clear UAF in cd76b3d, and the ListHashSet dedup-to-tombstone aliasing in bc35f2c). The current revision looks correct on those axes and the bug hunter found nothing further.
Level of scrutiny
High. This is core NAPI bindings C++ that every native addon goes through, with bidirectional raw pointers between GC-managed cells (NapiExternal), TZone-allocated objects (NapiRef), and ListHashSet node storage, across both GC-time and env-teardown orderings. The napi_add_finalizer(result == NULL) path also changes ownership model (heap finalizer → self-deleting weak ref). These are exactly the interactions a maintainer familiar with Bun's NAPI/JSC lifetime model should sign off on.
Other factors
All prior inline comments (mine and CodeRabbit's) are resolved; the most recent commit only trimmed comments and dropped per-test timeouts. Test coverage is good and the PR description carefully scopes out napi_create_external_arraybuffer/_buffer as a separate change. CI shows unrelated build-rust failures on several lanes. Given the complexity, the two UAFs already found and fixed during review, and the runtime-wide blast radius, deferring to a human is the right call rather than auto-approving.
|
CI status on the final head (6896241, build 66156): the diff is green; every red lane is unrelated to this PR, and large parts of the build never ran because Buildkite did not assign them agents. Of 53 non-passing jobs, only 3 actually executed and failed, and none involves this PR's test or code:
The signal that matters: I am deliberately not pushing further empty retrigger commits. If a maintainer re-runs the expired jobs or re-triggers the build, I would expect it to come back with only the usual unrelated flakes. Local verification is in the description: all 4 tests fail on an unfixed build and pass under the debug ASAN build. |
…nv teardown Node-API guarantees that every registered finalizer runs when the environment is torn down, even for objects that are still strongly reachable at exit. Bun only honored that for napi_wrap: its finalizer is registered on the NapiEnv teardown list (m_finalizers) that NapiEnv::cleanup() drains. napi_add_finalizer and napi_create_external registered their finalizers only as GC callbacks (vm.heap.addFinalizer / the NapiExternal destructor), so for objects still alive at exit they simply never ran. Native resources owned by externals (DB handles, mmaps, thread pools) leaked on every env teardown, and in long-lived processes that create and destroy envs (workers) the leak is unbounded. napi_add_finalizer now always creates a NapiRef and registers the same wrap_cleanup entry napi_wrap uses, for both the napi_ref-returning and result==NULL variants. napi_create_external registers an external_cleanup entry that the NapiExternal destructor deactivates if GC collects the external first, so a finalizer never runs twice. napi_create_external_arraybuffer / napi_create_external_buffer have the same gap but their finalizer is the ArrayBuffer contents deleter; invoking it at env teardown while the JSArrayBuffer is still a reachable cell would free memory that later cleanup hooks can still read through the buffer. Running them safely requires detaching the buffer first, which needs per-env tracking of live external buffers, so they are left for a separate change.
…LF in the test A teardown finalizer may call napi_create_external / napi_add_finalizer / napi_wrap; Bun accepts these (Node rejects them with napi_pending_exception). addFinalizer() appends the new entry after the position NapiEnv::cleanup()'s single reverse pass has already moved past, so it was never run, and the m_finalizers.clear() that followed freed it while the still-live NapiExternal or NapiRef kept a pointer to it in boundCleanup: a use-after-free on that object's next sweep. The same hazard already existed for napi_wrap; this PR's previous commit extended it to napi_add_finalizer and napi_create_external. cleanup() now repeats the reverse pass until one runs nothing new, deactivating each entry before calling it so nothing runs twice. external_cleanup also deactivates its entry, matching wrap_cleanup. Also: - The test split stderr on a bare newline; on Windows the C runtime writes CRLF, so every finalizer name carried a trailing carriage return and the assertion failed. Split on either line ending. - The no-double-run test now proves the gc-time finalizers ran during Bun.gc, not only at exit, by polling the addon's finalize counter and asserting the gc and exit groups land on opposite sides of a barrier line. - The addon rebuild in beforeAll also triggers when the addon sources are newer than the built binary, not only when it is missing. - New test: finalizers registered by a teardown finalizer are drained in the same teardown, which is the regression test for the use-after-free.
…elf-diagnosing The teardown-finalizer test failed on the linux x64-asan CI lane with every received array empty, in about 120ms, on both commits so far. That is not the Windows CRLF bug. On that same lane, napi.test.ts's LIFO-order test passes while asserting that an identically spawned bun child's stderr is exactly empty, and it reads its own teardown-finalizer output from stdout. The addon here printed every finalize line to stderr, so on that lane the test saw nothing at all. Switch the addon to stdout, which is what the existing test_wrap_cleanup_order fixture already does. The assertions also hid the cause: they compared only the filtered name list, so the lane reported an unreadable empty array with no hint of why. Each test now asserts one combined object carrying the exit code and, when no finalizer fired, the child's raw stdout and stderr, so the next lane-specific failure names its own cause.
…gent
On one debian x64-asan shard every spawned child aborted with exit 134 and
ERROR: Unchecked JS exception:
This scope can throw a JS exception: NAPICallFrame @ napi.h
But the exception was unchecked as of this scope: napi_get_cb_info
before a single finalizer ran. That agent has BUN_JSC_validateExceptionChecks
set, and bunEnv spreads process.env, so the children inherited it. A main
debug build with none of this PR's changes aborts identically on the simplest
possible addon call, so it is not caused by this PR.
It also cannot be fixed one site at a time: every napi_* function opens a
ThrowScope whose destructor simulates a throw when it returns into the
addon's C frame (never an LLInt/JIT frame), and Node-API reports errors as
napi_status with no room for a JSC exception check between calls, so any
addon callback that makes two napi calls trips the validator by construction.
Discharging the NAPICallFrame scope in the dispatcher just moves the blame to
the next pair (napi_get_cb_info then napi_get_value_string_utf8).
Strip the option from the child env, the same treatment harness.ts already
gives JSC_useJIT for ad-hoc JSC debug options left on CI agents. Verified the
suite passes with BUN_JSC_validateExceptionChecks=1 exported in the runner's
environment, which reproduces the failing lane exactly.
During NapiEnv::cleanup() a dying owner's BoundFinalizer is only marked inactive, not erased (erasing the entry the drain currently points at would invalidate its iterator). NapiRef is TZone allocated, so a teardown finalizer that calls napi_delete_reference and then registers a new finalizer can get the freed NapiRef address back; the new entry's (callback, hint, data) key then equals the tombstone's, ListHashSet::add deduplicates to the dead node, and addFinalizer returns an entry every drain pass skips. m_finalizers.clear() frees it while the new NapiRef's boundCleanup still points at it, a use-after-free on that ref's later destruction. A key can only alias a tombstone when the old owner's address was freed and reused, so the entry now belongs exclusively to the new registration: reactivate it in addFinalizer. For new nodes active is already true, so this is a no-op outside that window. The new test registers a finalizer, deletes its ref from inside another teardown finalizer, and registers a replacement. It pins that the replacement runs and the deleted one does not. It reaches the aliasing path only on allocators that recycle the address (TZone release builds do; ASAN's quarantine does not), which is inherent to the condition, so it prints a recycled-address diagnostic rather than asserting on allocator behavior. Also corrects a stale comment in the test addon.
…drain loop The drain in NapiEnv::cleanup() must deactivate an entry before dispatching it so nothing can run twice across passes, and call() guards on active, so it can no longer be used there. Nothing else calls it.
…eouts The comments kept bug history and CI narration that belongs in the PR; keep only the invariants. The tests each spawn one short-lived child and finish in about a second, so they do not need a custom timeout. The beforeAll keeps its timeout because it runs the node-gyp toolchain.
6896241 to
6ed3422
Compare
There was a problem hiding this comment.
All of my earlier findings look addressed and I didn't find anything new on this revision, but this rewrites finalizer lifecycle in GC-adjacent native NAPI code and went through several UAF iterations, so it should get a human sign-off.
Extended reasoning...
Overview
This PR changes napi_add_finalizer and napi_create_external to register their finalizers on the per-env teardown list (NapiEnv::m_finalizers) so they run at env teardown for objects still alive at exit, matching Node's RefTracker::FinalizeAll contract. To make that safe it also rewrites NapiEnv::cleanup() from a single reverse pass into a repeat-until-idle drain, adds an m_boundCleanup back-pointer to NapiExternal (deactivated in its destructor), makes addFinalizer() reactivate any tombstoned entry it hands back, and removes the now-dead BoundFinalizer::call(). A new C addon and four subprocess tests cover teardown, GC-then-teardown de-duplication, mid-teardown nesting, and the delete-then-reregister recycling case.
Security risks
No injection/auth/data-exposure surface. The risk here is memory safety: the change threads raw pointers into ListHashSet nodes (m_boundCleanup / boundCleanup) across GC and env-teardown ordering, and the drain loop's correctness depends on ListHashSet iterator stability under append plus the tombstone/reactivate invariant. Two UAF-class bugs were found and fixed during review (mid-drain append never reached before clear(); TZone address reuse aliasing a tombstoned entry). I did not find a residual instance on the current head, and the bug-hunting pass came back clean.
Level of scrutiny
High. This is production C++ in src/jsc/bindings/ that changes GC/finalizer interaction and object-lifetime ownership across the JS/native boundary — exactly the category the repo's own review guidance calls the most-blocked. It also changes user-visible behavior for every native addon that uses napi_add_finalizer or napi_create_external (more finalizers now run at exit/worker teardown), which the PR description itself notes may make latent addon bugs easier to hit.
Other factors
Every inline comment I raised earlier (mid-drain UAF, tombstone-aliasing UAF, dead call(), stale comment) is resolved in the current diff, and the CodeRabbit nits (comment length, per-test timeouts, rebuild gating, GC barrier) are also addressed. Test coverage is good and self-diagnosing. That said, this is not a mechanical or obvious change — it took multiple rounds to converge on a memory-safe drain design, and the addFinalizer reactivation fix rests on an argument about TZone address reuse that a maintainer who owns this code should validate. Deferring rather than approving.
|
CI on the rebased head (6ed3422, build 70437) has finished: 284 jobs passed, and the two failures are both unrelated macOS integration/infra issues.
|
Problem
Node-API guarantees that every registered finalizer runs at environment teardown, even for objects that are still strongly reachable at exit (the
RefTracker::FinalizeAllpass innapi_env__::DeleteMe()). Addons rely on this to flush and close native resources held by externals: DB handles, mmap'd files, thread pools.Bun only honored that contract for
napi_wrap. Its finalizer is registered on theNapiEnvteardown list (m_finalizers) thatNapiEnv::cleanup()drains.napi_add_finalizerandnapi_create_externalregistered their finalizers only as GC callbacks (vm.heap.addFinalizerand theNapiExternaldestructor respectively), so for anything still alive at exit they never ran.At process exit that means buffers are never flushed. In a long-lived process that creates and destroys envs (workers), it is an unbounded native leak.
Reproduction
An addon that registers all three kinds of finalizer (each prints
finalize: <name>to stderr), with every JS object kept strongly reachable until exit so env teardown is their only chance to run:Node v26.3.0 prints all four. Bun prints only:
Fix
napi_add_finalizernow always creates aNapiRefand registers the samewrap_cleanupteardown entrynapi_wrapuses, for both thenapi_ref-returning andresult == NULLvariants. The GC-time deferral semantics are unchanged:NapiRef::callFinalizerapplies the samemustDeferFinalizers() && inGC()rule the olddoFinalizerlambda did.napi_create_externalregisters anexternal_cleanupentry on the same list.NapiExternalgets anm_boundCleanupfield; its destructor deactivates the entry when GC collects the external first, andexternal_cleanupclearsm_finalizerbefore calling it, so a finalizer never runs twice on either ordering.Existing behavior that protected against double-invocation is preserved:
callFinalizer()copies and clears the finalizer before calling it, andBoundFinalizer::deactivateremoves the entry (or marks it inactive if teardown is mid-iteration).Review also found a second, narrower instance of the same class in the drain loop itself. A dying owner's entry is only tombstoned during the drain (erasing the current node would invalidate the iterator), and
NapiRefis TZone allocated, so a teardown finalizer that deletes a ref and registers a new one can get the freed address back; the new entry's(callback, hint, data)key then aliases the tombstone,ListHashSet::adddeduplicates to the dead node, andclear()frees it while the new ref still points at it.addFinalizernow reactivates the entry it returns, which is a no-op for genuinely new nodes and is the only way a key can alias a dead one (two live owners can never collide because the key includes the owner pointer).Intentionally excluded sibling
napi_create_external_arraybuffer/napi_create_external_bufferhave the same gap (Node runs their finalizers at teardown, Bun does not). Their finalizer is theArrayBuffer::Contentsdeleter, and invoking it at env teardown while theJSArrayBufferis still a reachable cell would free memory that later env cleanup hooks can still read through the buffer. Doing it safely requires detaching the buffer first, which needs per-env tracking of live external buffers. That is a larger, separate change.Verification
test/napi/napi-teardown-finalizers.test.ts(new addontest_teardown_finalizers.c):Both tests fail on the unfixed build (only
wrapfires) and pass with the fix. With the fix, all 7test_wrap_lifetime_* / test_ref_deleted_*sub-tests produce output identical to node v26.3.0, and thenapi_create_external_buffer/napi_create_external_arraybuffersuites still pass.Rebase conflicts
Rebased onto main after it landed the #30286 fix, which added
clearExceptionsBetweenFinalizers()after each finalizer in the samecleanup()loop this PR rewrites into a drain-until-idle. Resolved by callingclearExceptionsBetweenFinalizers()after each callback inside the new drain loop, preserving both the #30286 semantics (each finalizer starts from a clean exception state) and this PR's (entries appended mid-drain are reached beforeclear()frees them). Both of main's #30286 tests pass with the merged loop, as do this PR's four tests and the existing LIFO-order test.The other conflict was
binding.gyp: both sides appended a target at the end of the list; kept both.