Skip to content

FinalizationRegistry: keep the registry alive while it has registrations - #35213

Open
robobun wants to merge 5 commits into
mainfrom
farm/95fff2a5/finalization-registry-self-root
Open

FinalizationRegistry: keep the registry alive while it has registrations#35213
robobun wants to merge 5 commits into
mainfrom
farm/95fff2a5/finalization-registry-self-root

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

A FinalizationRegistry whose only reference is a module-level or async-function const can be collected, with all its pending registrations, before a single cleanup callback fires.

// bun repro.mjs  -> {collected:500, cleaned:0}
// node --expose-gc repro.mjs -> {collected:499, cleaned:499}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const K = 500; let cleaned = 0;
const fr = new FinalizationRegistry(() => { cleaned++; });
const wrs = [];
for (let i = 0; i < K; i++) { const o = { i, pad: "p".repeat(30) }; wrs.push(new WeakRef(o)); fr.register(o, i); }
await sleep(300);
for (let r = 0; r < 20; r++) { Bun.gc(true); await sleep(20); if (cleaned >= K) break; }
console.log(JSON.stringify({ collected: wrs.filter(w => w.deref() === undefined).length, cleaned }));

Cause

JavaScriptCore's generatorification only saves locals that are read after an await/yield into the suspended generator state. fr is last read at fr.register(o, i), so after await sleep(300) nothing roots it. The GC that fires during the sleep marks neither fr nor its targets; finalizeMarkedUnconditionalFinalizers only walks marked cells, so JSFinalizationRegistry::finalizeUnconditionally never runs for fr and no cleanup task is ever scheduled. The registry is swept along with its registrations. The same applies to plain function* and async generators.

V8 preserves every async-function local across await, so the same program keeps fr reachable in Node.js (verified: a WeakRef to fr derefs non-undefined there, undefined in Bun). When the registry is truly unreachable in both engines (e.g. created inside an IIFE that returns), both deliver zero callbacks, so this is purely the generatorified-local-lifetime difference being exposed. Standalone jsc-shell repro and analysis for an upstream report are in the linked thread.

The spec permits collecting an unreferenced registry without running its callbacks, but the Node.js behaviour is what every FinalizationRegistry-based resource guard assumes.

Fix

Override FinalizationRegistry.prototype.{register,unregister} with Bun host functions that call JSC's registerTarget / unregister and additionally maintain a per-VM JSC::Strong<> root set on JSCTaskScheduler:

  • register() adds the receiver on the first successful registration.
  • unregister() drops it when liveCount + deadCount reaches zero.
  • After each runFinalizationCleanup deferred-work task runs, the same zero check drops the root.

A registry with no registrations is never rooted; a drained one becomes collectable on the next GC. The map is cleared in Bun__JSCTaskScheduler__markShuttingDown so no Strong outlives the event loop's final tick. The hooks are installed for both the main global and node:vm globals.

Retention trade-off: a registry whose only remaining registration watches a target that never dies (e.g. globalThis) is retained until VM shutdown. V8 would collect it. Matching V8 exactly requires either walking unmarked JSFinalizationRegistry cells during marking (private access to the target list to tell "a target is dying this cycle") or changing JSC's BytecodeGenerator to save every generatorified local; the silent-guard failure this fixes is judged worse than the retained registry. The comment on rootFinalizationRegistry and a dedicated test document this.

How did you verify your code works?

New test/js/web/finalization-registry.test.ts (eight concurrent subprocess tests):

  • the repro shape above and an async-function variant deliver every callback (both hit cleaned == 0 on the unpatched build)
  • a registry created in an IIFE delivers all callbacks and is then collectable
  • a batch of 200 registries that unregister() their entries / never register at all are all collected (not leaked by the new root)
  • a node:vm context delivers every callback
  • the immortal-target retention is asserted so a future narrowing is visible
  • register / unregister argument validation and .length are unchanged

USE_SYSTEM_BUN=1 bun test ...: 5 fail with cleaned == 0 / alive == 0.
bun bd test ...: 8 pass.

Adjacent PRs #30857 and #34270 touch the same runPendingWork block for different reasons (exception reporting / worker termination); this change is additive to both.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/finalization-registry.test.ts
bun test v1.4.0 (8e3d7306d)

test/js/web/finalization-registry.test.ts:
(pass) FinalizationRegistry keeps itself alive while it has registrations > a registry that never registers stays collectable [480.66ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > unregister() that drains every entry releases the root [586.23ms]
82 |         console.log(JSON.stringify({ cleaned }));
83 |       }
84 |       await main();
85 |     `);
86 |     expect(stderr).toBe("");
87 |     expect((json as { cleaned: number }).cleaned).toBeGreaterThanOrEqual(199);
                                                       ^
error: expect(received).toBeGreaterThanOrEqual(expected)

Expected: >= 199
Received: 0

      at <anonymous> (/workspace/bun/test/js/web/finalization-registry.test.ts:87:51)
(fail) FinalizationRegistry keeps itself alive while it has registrations > same inside an async function (not just module top level) [1128.92ms]
202 |     const { alive, total } = json as { alive: numbe
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (438efb9f4)

test/js/web/finalization-registry.test.ts:
(pass) FinalizationRegistry keeps itself alive while it has registrations > register/unregister argument validation is unchanged [14.20ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > unregister() that drains every entry releases the root [18.78ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > a registry that never registers stays collectable [18.33ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > same inside an async function (not just module top level) [24.20ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > node:vm contexts get the rooting hooks [30.01ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > the registry is released once every registration is drained [34.33ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > a registry with an immortal target is retained (documented over-correction) [67.96ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > cleanup callbacks fire when the re
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/finalization-registry.test.ts
bun test v1.4.0 (8e3d7306d)

test/js/web/finalization-registry.test.ts:
(pass) FinalizationRegistry keeps itself alive while it has registrations > same inside an async function (not just module top level) [489.76ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > the registry is released once every registration is drained [517.03ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > unregister() that drains every entry releases the root [520.40ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > a registry that never registers stays collectable [552.87ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > cleanup callbacks fire when the registry local dies before its targets [895.20ms]
(pass) FinalizationRegistry keeps itself alive while it has registrations > register/unregister argument validation is unchanged [496.19ms]
(pass) FinalizationRegistry keeps itself alive while it has registra
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 780ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/136] gen bindgenv2
[2/136] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/136] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[4/136] gen cpp.rs (cppbind)
[5/136] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fie
... (truncated)
diff hotspot
src/jsc/bindings/JSCTaskScheduler.cpp     |  98 ++++++++++-
 src/jsc/bindings/JSCTaskScheduler.h       |  30 ++++
 src/jsc/bindings/NodeVM.cpp               |   2 +
 src/jsc/bindings/ZigGlobalObject.cpp      |   2 +
 test/js/web/finalization-registry.test.ts | 263 ++++++++++++++++++++++++++++++
 5 files changed, 394 insertions(+), 1 deletion(-)

gate history · 4 passed · 0 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/jsc/bindings/JSCTaskScheduler.cpp          2     11      0
src/jsc/bindings/JSCTaskScheduler.h            3      5      0
src/jsc/bindings/NodeVM.cpp                    1      1      0
src/jsc/bindings/ZigGlobalObject.cpp           1      1      0
test/js/web/finalization-registry.test.ts      2      5      0

JavaScriptCore's async bytecode generator only preserves locals that are
read after an await, so a `const fr = new FinalizationRegistry(...)`
whose last use is `fr.register(...)` becomes unreachable at the next
suspend point. The registry is then swept in the same GC that collects
its targets, before finalizeUnconditionally ever sees them, and no
cleanup callback fires. V8 preserves every async-function local across
await, so Node.js never hits this; the result is 0/K callbacks delivered
in Bun where Node delivers K/K for the same program.

Override FinalizationRegistry.prototype.{register,unregister} to add the
receiver to a per-VM Strong root set on the first successful register()
and drop it once liveCount + deadCount is zero (checked after each
cleanup-task run and after each unregister()). A registry with no
registrations is never rooted, and a drained one becomes collectable
again on the next GC, so this introduces no new leak.
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:55 AM PT - Jul 23rd, 2026

@robobun, your commit 8e3d7306d5403720ce3552237554379a069c0be9 passed in Build #78410! 🎉


🧪   To try this PR locally:

bunx bun-pr 35213

That installs a local version of the PR into your bun-35213 executable, so you can run:

bun-35213 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. CI: test-net-connect-memleak.js fails on half of PR builds on linux-x64-musl since June 28 ~23:00 UTC #33044 - The failing test-net-connect-memleak.js uses an onGC (FinalizationRegistry) callback that never fires; if the registry itself is collected before its callback runs, this PR's fix (rooting registries while they have pending registrations) would prevent that

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #33044

🤖 Generated with Claude Code

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Re #33044: probably not fixed by this. That test's onGC helper uses a module-scoped finalizationRegistry in test/js/node/test/common/gc.js, which is already rooted by the module closure; the flake there is the callback-vs-setImmediate ordering on musl, not the registry being collected. This PR only helps registries whose last JS reference dies before their first callback is scheduled.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Adds JavaScriptCore FinalizationRegistry prototype hooks and scheduler tracking for registered registries. Registries are rooted while entries remain and unrooted after deferred cleanup drains. Tests cover collection, cleanup, VM contexts, unregister behavior, and API validation.

FinalizationRegistry lifecycle

Layer / File(s) Summary
Scheduler rooting and draining
src/jsc/bindings/JSCTaskScheduler.h, src/jsc/bindings/JSCTaskScheduler.cpp
The scheduler stores strong registry references, releases drained registries after deferred work, and clears them during shutdown.
Prototype hooks and global installation
src/jsc/bindings/JSCTaskScheduler.cpp, src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/NodeVM.cpp
Native register and unregister methods validate arguments, invoke JavaScriptCore operations, and are installed for built-in and VM globals.
Lifecycle and API validation tests
test/js/web/finalization-registry.test.ts
Tests cover cleanup, registry collection after draining, unregister behavior, unregistered registries, VM contexts, immortal targets, and method validation.

Possibly related PRs

  • oven-sh/bun#34270: Changes deferred-work termination behavior involved in FinalizationRegistry cleanup.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: keeping FinalizationRegistry instances alive while registrations remain.
Description check ✅ Passed The description includes the required sections for what the PR does and how the code was verified, with detailed implementation and test notes.

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

Comment thread src/jsc/bindings/JSCTaskScheduler.cpp
Comment thread src/jsc/bindings/JSCTaskScheduler.cpp
Comment thread test/js/web/finalization-registry.test.ts
…ment sleep(300)

- Expand the rootFinalizationRegistry comment to state that a registry holding
  a registration for a target that never dies is retained until VM shutdown,
  and why matching V8 exactly is not done here.
- Call installFinalizationRegistryPrototypeHooks from NodeVMGlobalObject::
  finishCreation so node:vm contexts get the same rooting.
- Add a test documenting the immortal-target retention and a node:vm test.
- Comment the await sleep(300) in the first test: it preserves the reported
  repro shape; the suspend point is what matters.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/web/finalization-registry.test.ts (1)

91-119: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Run this isolated subprocess test concurrently.

run() creates independent fixture and process state, so this test can use test.concurrent like the surrounding GC cases.

Proposed change
-  test("the registry is released once every registration is drained", async () => {
+  test.concurrent("the registry is released once every registration is drained", async () => {
🤖 Prompt for 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.

In `@test/js/web/finalization-registry.test.ts` around lines 91 - 119, Keep the
FinalizationRegistry test declared with test.concurrent, matching the
surrounding GC tests, so the isolated run fixture can execute concurrently. Do
not change the existing cleanup, draining, assertions, or subprocess logic.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@test/js/web/finalization-registry.test.ts`:
- Around line 91-119: Keep the FinalizationRegistry test declared with
test.concurrent, matching the surrounding GC tests, so the isolated run fixture
can execute concurrently. Do not change the existing cleanup, draining,
assertions, or subprocess logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8ac332e6-2680-424f-81ed-ff00de92b3d1

📥 Commits

Reviewing files that changed from the base of the PR and between db60370 and 9b85a1c.

📒 Files selected for processing (3)
  • src/jsc/bindings/JSCTaskScheduler.h
  • src/jsc/bindings/NodeVM.cpp
  • test/js/web/finalization-registry.test.ts

Comment thread test/js/web/finalization-registry.test.ts
Comment thread src/jsc/bindings/JSCTaskScheduler.h Outdated
The Strong root is released only once liveCount + deadCount == 0, i.e. after
every target has died; the immortal-target clause in the same comment and the
over-correction test both describe the longest-lived case.
@robobun
robobun force-pushed the farm/95fff2a5/finalization-registry-self-root branch from 2033827 to 438efb9 Compare July 23, 2026 02:19

@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.

All prior findings are addressed — the shortestlongest doc fix landed in 2033827. No new issues this pass. Deferring to a maintainer only for the retention trade-off itself: pinning a registry to its longest-lived target is now well-documented and tested, but it's a deliberate divergence from V8 in the immortal-target case that's worth a human sign-off.

What was reviewed

  • Strong-root lifecycle: added on first register(), dropped on drained unregister() / post-cleanup-task / markShuttingDown — every acquire has a paired release.
  • node:vm hook in NodeVMGlobalObject::finishCreation; ruled out that forcing JSC's lazy finalizationRegistryStructure() there is a problem (per-context, one-time).
  • Replacement register/unregister argument validation and .length match JSC's originals; error messages preserved.
  • m_rootedFinalizationRegistries is JS-thread-only, keyed by a cell the Strong value keeps alive, and cleared before the shutdown flag flips — no lock needed, no dangling key.
Extended reasoning...

Overview

Overrides FinalizationRegistry.prototype.{register,unregister} with Bun host functions that additionally maintain a per-VM Strong<> root set on JSCTaskScheduler, so a registry whose only JS reference is an async-function local (dropped by JSC's generatorification at the next await) survives until its registrations drain. Hooks are installed for both the main global (ZigGlobalObject::addBuiltinGlobals) and node:vm contexts (NodeVMGlobalObject::finishCreation). Eight subprocess tests cover the fix, the release path, node:vm, and the documented over-retention.

Progress since last review

Three rounds of my inline comments have all been addressed: the immortal-target over-retention is now documented in the header and covered by a dedicated test; node:vm contexts get the hooks; the sleep(300) carries a comment; and the header comment now correctly says "longest-lived" (2033827). No new findings this run; the finder's candidate about eagerly forcing the lazy structure in NodeVM.cpp was verified harmless.

Security risks

None identified. No untrusted parsing; validation of register/unregister arguments mirrors JSC's originals verbatim. The Strong root map is JS-thread-only and cleared at VM shutdown.

Level of scrutiny

High — this is C++ GC-lifetime code adding JSC::Strong<> roots and overriding built-in prototype methods. The mechanism is sound and thoroughly tested, but the semantic trade-off (a registry watching an immortal target is retained until VM shutdown, along with its callback closure and every held value) is a design decision that diverges from V8. The PR documents it clearly and locks it in with a test so a future narrowing is visible; whether that trade-off is acceptable is a maintainer call, not something I should approve unilaterally.

Other factors

Test coverage is strong (positive, negative-leak, node:vm, argument validation, documented over-correction), fails-before/passes-after was re-verified against a real unpatched release after the mechgate artifact was identified, and the change composes with adjacent PRs #30857/#34270 in the same block.

@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.

All four rounds of findings I raised have been addressed (immortal-target retention now documented + tested, node:vm hooked, sleep(300) commented, longest-lived comment fixed). No new issues found on 438efb9. Deferring to a human because this replaces JSC's FinalizationRegistry.prototype.{register,unregister} with Bun host functions that maintain per-VM Strong<> roots — GC-lifecycle C++ with an explicit, documented over-retention trade-off vs. V8 that a maintainer should sign off on.

What was reviewed:

  • Root/unroot balance across all three release paths (unregister, deferred cleanup, markShuttingDown) and that the map is JS-thread-only.
  • Reimplemented register/unregister argument validation matches JSC's originals (canBeHeldWeakly, target≠holdings, token checks, .length, DontEnum).
  • Hook installation covers both Zig::GlobalObject and NodeVMGlobalObject; runPendingWork downcast to JSFinalizationRegistry is safe post-task.
  • Test hermeticity: subprocess-per-test, bounded GC poll loops, batch-of-200 pattern to defeat conservative stack pinning.
Extended reasoning...

Overview

The PR overrides FinalizationRegistry.prototype.register and unregister on both the main global and node:vm globals with Bun host functions that additionally maintain a per-VM UncheckedKeyHashMap<JSCell*, Strong<JSObject>> on JSCTaskScheduler. The Strong root is added on the first successful register(), and dropped when liveCount + deadCount == 0 (checked after unregister() and after each runFinalizationCleanup deferred-work task), or wholesale at markShuttingDown. Files touched: JSCTaskScheduler.{cpp,h} (~130 lines new logic), one-line hook installations in ZigGlobalObject.cpp and NodeVM.cpp, and an 8-test subprocess suite.

Security risks

None identified. No untrusted-input parsing; the reimplemented register/unregister validate arguments identically to JSC's originals (canBeHeldWeakly, target-vs-holdings sameness, unregister-token type) before touching native state. The Strong-root map is JS-thread-only per its comment and all mutation sites are on the JS thread (host function, deferred-work task on the JS event loop, shutdown from the owning JS thread), so no lock is needed on it. The map key is the JSCell* and the value is a Strong<> to the same cell, so the key cannot dangle while an entry exists.

Level of scrutiny

High. This is GC-lifecycle C++ that adds JSC::Strong<> roots — the exact category REVIEW.md flags for extra justification — and it replaces spec-defined built-in prototype methods with Bun-side reimplementations. It also intentionally accepts a semantic over-correction relative to V8 (a registry whose only remaining registration watches an immortal target is retained until VM shutdown), which is now documented in the header comment, the PR description, and locked in by a dedicated test. That trade-off is a design decision a maintainer should ratify.

Other factors

Four prior review rounds from me were all addressed: (1) the immortal-target over-retention is now explicitly documented and tested rather than silent; (2) NodeVMGlobalObject::finishCreation now installs the hooks so node:vm contexts get the fix, with a covering test; (3) the sleep(300) in the first test carries a comment explaining it preserves the exact reported repro shape; (4) the header comment's "shortest-lived" → "longest-lived" inversion was corrected. The mechgate "release without fix: all passed" evidence was confirmed as a gate artifact (it ran the with-fix binary). Test coverage is thorough: fail-before/pass-after verified on ASAN debug and an actual unpatched release, drain-then-collect, unregister-drains-root, never-registered-stays-collectable, node:vm, immortal-target retention, and API-shape preservation. All eight tests are test.concurrent subprocess spawns with bounded GC poll loops.

@robobun
robobun force-pushed the farm/95fff2a5/finalization-registry-self-root branch from afdd939 to 8e3d730 Compare July 23, 2026 04:06

@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.

All four rounds of prior feedback are addressed (immortal-target retention documented + tested, node:vm hooked, sleep(300) commented, shortest→longest fixed) and this pass found nothing new. Deferring to a maintainer for the design call: the per-VM Strong<> root set intentionally over-corrects past V8 (a registry watching an immortal target is retained until VM shutdown), which is documented and locked in by a test but is the kind of GC-lifetime trade-off a human should sign off on.

Checked this pass: the reimplemented register/unregister match JSC's argument validation and .length; the map is JS-thread-only and cleared at markShuttingDown; runPendingWork's post-task dynamicDowncast on ticket->target() is safe because the ticket holds a Strong dependency on its target.

Extended reasoning...

Overview

Roots a FinalizationRegistry in a per-VM UncheckedKeyHashMap<JSCell*, Strong<JSObject>> on JSCTaskScheduler from its first successful register() until liveCount + deadCount == 0, so a registry whose only JS reference is an async-function local that JSC's generatorification drops at the next suspend point still delivers its cleanup callbacks. Implemented by overriding FinalizationRegistry.prototype.{register,unregister} with Bun host functions installed from both Zig::GlobalObject::addBuiltinGlobals and NodeVMGlobalObject::finishCreation, plus an unroot check after each finalization deferred-work task in runPendingWork. ~130 lines of C++ across JSCTaskScheduler.{h,cpp}, two one-line hook installs, and an eight-test subprocess suite.

Security risks

None identified. No untrusted input parsing beyond the existing canBeHeldWeakly / receiver-type checks copied from JSC's own protoFuncFinalizationRegistryRegister; no new FFI surface; the map is per-VM and JS-thread-only.

Level of scrutiny

High. This is GC-lifetime code: a new JSC::Strong<> root set with three release paths, a prototype override on a Web API, and an explicit semantic widening relative to both pre-patch Bun and V8 (immortal-target retention). REVIEW.md singles out unjustified Strong refs as a top-blocked category. The author has documented the trade-off in the header comment, the PR description, and a dedicated test, and explained why a Weak<>+marking-constraint alternative needs WebKit-private access — but whether the over-correction is acceptable for Bun is a maintainer call, not a mechanical one.

Other factors

All four of my prior findings are resolved and the threads are marked as such; the only commit since the last one (438efb9) is a CI retrigger. The bug hunter surfaced no new issues. The test suite covers the positive cases (module-level, async-function, IIFE, node:vm), the release paths (unregister-drains, never-registered, drained-then-collectable), the over-correction, and argument-validation parity, all as concurrent subprocess tests with bounded GC-poll loops. The mechgate "release without fix" artifact was confirmed as a stale-binary issue by the author. Given the design-level trade-off at the centre of the change, I'm not comfortable auto-approving.

@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.

All four concerns from earlier rounds are addressed (node:vm hook, immortal-target retention documented+tested, sleep(300) comment, longest-lived wording). No new issues found. Deferring for maintainer sign-off on the documented trade-off: Strong-rooting registries until liveCount+deadCount==0 means a registry watching an immortal target (and its callback closure + held values) is retained until VM shutdown — a semantic over-correction past V8 that the author has consciously accepted over the silent-guard failure, but it's a user-visible retention change worth a human ack.

What was reviewed:

  • register/unregister overrides mirror JSC's protoFuncFinalizationRegistryRegister/Unregister validation exactly (canBeHeldWeakly, target≠holdings, token check, .length); error messages match JSC's.
  • Root map is JS-thread-only, keyed by cell pointer, cleared in markShuttingDown; unrootFinalizationRegistryIfDrained takes the cellLock before reading counts.
  • runPendingWork unroot check runs after the cleanup task, so a registry that drains mid-callback is released; dynamicDowncast on ticket->target() is safe (ticket keeps target alive).
  • Eager finalizationRegistryStructure() initialization in addBuiltinGlobals/NodeVMGlobalObject::finishCreation was examined and ruled out as a concern.
Extended reasoning...

Overview

The PR overrides FinalizationRegistry.prototype.{register,unregister} with Bun host functions that additionally maintain a per-VM UncheckedKeyHashMap<JSCell*, Strong<JSObject>> on JSCTaskScheduler. A registry is Strong-rooted on its first successful register() and released when liveCount+deadCount reaches zero (via unregister(), via the post-runFinalizationCleanup check in runPendingWork, or at VM shutdown). Hooks are installed for both Zig::GlobalObject::addBuiltinGlobals and NodeVMGlobalObject::finishCreation. Eight concurrent subprocess tests cover the repro shape, async-function variant, drain-then-collectable, unregister-drains-root, never-registered-stays-collectable, node:vm, the immortal-target over-correction, and argument validation.

Security risks

None identified. No untrusted input parsing beyond the argument validation that already existed in JSC's stock implementation, which is reproduced verbatim. The map is JS-thread-only and cleared at shutdown.

Level of scrutiny

High. This is GC-lifetime code adding JSC::Strong<> roots — the most-blocked category in REVIEW.md — and it changes user-visible semantics of a Web API (a registry that would previously be collected without firing callbacks now fires them, and conversely a registry watching an immortal target that would previously be collected is now retained). The mechanism is sound and well-tested; the open question is whether the documented over-correction is the right trade-off for Bun to ship, which is a maintainer call rather than a correctness bug.

Other factors

Four prior review rounds from me were all addressed in 9b85a1c and 438efb9: the immortal-target retention is now documented in the header comment and locked in by a dedicated test; node:vm contexts get the hook; the sleep(300) carries a comment; the "longest-lived" wording is fixed. The bug-hunting system found nothing new this run, and the one candidate it examined (eager LazyClassStructure initialization) was refuted. Test coverage is thorough and the fails-without-fix / passes-with-fix split is verified (5 fail on unpatched release, 8 pass on patched). The change is self-contained to JSCTaskScheduler plus two one-line hook installations.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: test/js/web/finalization-registry.test.ts passes on every lane across builds #78325, #78342 and #78410. Remaining red on #78410 is unrelated flakes this diff doesn't touch:

  • test/js/bun/shell/exec.test.ts (Windows x64): shell panic: expected Node::Cmd on the no-AVX profile
  • test/js/node/test/parallel/test-fs-promises-file-handle-readFile.js (Debian x64-asan): the /proc/sys/kernel/hostname FileHandle leak that test: close the /proc FileHandle in test-fs-promises-file-handle-readFile #34283 closes; fileHandleRegistry is module-scoped so its lifetime is unchanged by this PR
  • test/js/node/test/parallel/test-https-server-connections-checking-leak.js (Alpine aarch64): onGC countdown timing
  • test/js/third_party/es-module-lexer/es-module-lexer.test.ts (Windows x64): 90 s load timeout
  • test/js/bun/spawn/spawn.test.ts (Windows aarch64): timeout
  • test/js/node/fs/fs.test.ts (macOS x64): FIFO read timeout

All are scraped as [flaky] (passed on retry or known). The single re-roll is spent; ready for a maintainer to merge past these.

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