Skip to content

Back bun_jsc::Strong with JSC's StrongSet and remove StrongRootBlock - #37842

Open
robobun wants to merge 1 commit into
mainfrom
farm/af998b00/remove-strong-root-block
Open

Back bun_jsc::Strong with JSC's StrongSet and remove StrongRootBlock#37842
robobun wants to merge 1 commit into
mainfrom
farm/af998b00/remove-strong-root-block

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun carries its own root storage for bun_jsc::Strong (StrongRootBlock, from Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849, about 470 lines), added because JSC's old HandleSet scanned every handle on every eden GC.
  • Upstream JSC now keeps Strong<> slots in a per-heap StrongSet (ff64aee116d4) and deletes HandleSet, so the next WebKit bump has to touch this code anyway and bun's copy becomes a duplicate.
  • Visible on main: after every timer is cleared, one spare StrongRootBlock cell still shows up in heapStats().objectTypeCounts.
  • Requested in Slack by @Jarred-Sumner.

Fix

  • A Strong is now one slot in the VM's StrongSet, the storage JSC::Strong<> uses. C++ only allocates and releases the slot; Rust reads and writes it directly. StrongRootBlock and its marking constraint are deleted.
  • Correct because the GC visits every StrongSet slot on every collection, so a plain store into a slot needs no barrier, the same store JSC::Strong::set() makes. heapStats() and getProtectedObjects() use JSC's protected-cell walks, which cover these slots.
  • Measured trade-off: at 1M live handles eden pauses go from about 3.8 ms to about 10 ms; at 100k and below the difference is noise, and arming or clearing a handle gets slightly faster (tables in the original).
  • Needs a WebKit bump with that upstream commit (Upgrade to upstream WebKit bbc000ae4f3d WebKit#404 or later); on the current pin StrongSet.h does not exist, so CI is expected red until then. Verified on a local WebKit build with the commit cherry-picked: the updated timer-gc-roots test fails on main and passes here, and the jsc, timers, abort and worker suites pass.

Background

  • bun_jsc::Strong is the Rust handle that pins a JS value so GC keeps it alive (an armed timer pins its Timeout this way). Dropping it releases the pin, on the JS thread only.
  • StrongSet is JSC's per-heap table of such pinned slots. It is visited whole on every collection, eden included, which is why a slot store needs no write barrier.
  • Eden collections trace only young objects and rely on write barriers to find changed old ones. StrongRootBlock was a heap cell, so untouched blocks were skipped on eden; that skip is what this PR gives up.
  • The is_shutting_down early return in Strong's drop stays: the VM, and its StrongSet, is freed before the runtime state that still owns Strongs, so releasing a slot during teardown would touch freed memory (the crash class in Release RuntimeState's JSC handles before tearing down the VM #31990).
Original description

What does this PR do?

Removes StrongRootBlock (added in #35849) and backs bun_jsc::Strong with JSC's own strong-handle storage again, now that upstream JSC stores Strong<> slots in StrongBlocks owned by a per-heap StrongSet (upstream ff64aee116d4, "Introduce StrongBlock", which also deletes HandleSet).

Depends on a WebKit bump that contains that refactor (the in-progress oven-sh/WebKit#404 upgrade, or any later one). Against the WebKit main currently pins, <JavaScriptCore/StrongSet.h> does not exist, so CI on this PR is expected to be red until the bump lands; the PR should be rebased onto it. Everything below was verified against main's pin (7b763944) with ff64aee116d4 cherry-picked on top (the only conflicts were include style) and built in local WebKit mode.

Requested in Slack by @Jarred-Sumner.

Changes

  • StrongRef.cpp/.h: Bun__StrongRef__new allocates a slot from vm.heap.strongSet() and returns it; Bun__StrongRef__delete calls StrongSet::deallocate. The slot pointer is the handle, as it was before Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849. Bun__StrongRef__set is gone: like JSC::Strong::set(), a store into a slot needs no barrier (the set is scanned unconditionally), so Strong.rs reads, writes and clears the slot directly and only calls into C++ to allocate and release.
  • Deleted StrongRootBlock.{h,cpp}, its iso subspace entries, the block list / cursor / structure on JSVMClientData, and the "Srb" marking constraint.
  • heapStats() / getProtectedObjects() go back to JSC's protectedObjectTypeCounts() / forEachProtectedCell(), which walk the StrongSet, so they still report every bun_jsc::Strong pinned object.
  • root.h includes StrongSet.h in place of the deleted HandleSet.h (the WebKit bump needs this line either way).
  • The is_shutting_down early-return in Impl::destroy stays: StrongSet dies with the heap in teardown phase C, while deinit_runtime_state (phase E) can still drop Strongs (the crash class described in Release RuntimeState's JSC handles before tearing down the VM #31990), so the release must still be skipped once teardown has started.
  • The AbortSignal.timeout lifetime fix that was also part of Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849 is unaffected.

Net: 94 insertions, 474 deletions.

Trade-off, measured

Upstream's StrongSet::visitAggregate visits every slot on every collection, eden included; it does not have the generational skip that StrongRootBlock got from being a write-barriered cell. So this trades eden pause time at very high live-handle counts for less code and cheaper allocation. Release builds, same locally built JSC for both binaries, only the bun side differs (BUN_JSC_logGC=1, stop-the-world time summed per eden collection, 3 runs each):

live Strongs (armed timers) eden STW, StrongRootBlock (main) eden STW, this PR samples
1,000,000 3.8 ms avg / 3.3 ms median 10.1 ms avg / 10.8 ms median 56 / 62
100,000 3.4 ms avg 3.5 ms avg 68 / 89
10,000 2.9 ms avg 4.0 ms avg (noise; medians 2.8 / 3.6, one 13 ms outlier) 87 / 78

The 1M number is roughly where #35849 measured the old HandleSet (8.8 ms). Below ~100k live handles the scan is within noise.

Per-handle cost (setTimeout + clearTimeout, median of 5, two runs each):

main this PR
bulk: arm 1M then clear 1M 5.02 / 4.89 M ops/s 5.92 / 5.89 M ops/s
steady: interleaved, 2M 7.21 / 6.72 M ops/s 7.53 / 6.70 M ops/s
scattered re-arm into a held 1M 5.50 / 5.47 M ops/s 5.45 / 5.47 M ops/s
bench/snippets/set-timeout.mjs (20M) 12.74 s / 12.64 s 12.10 s / 11.64 s

Tests

test/js/web/timers/timer-gc-roots.test.ts: the first test now checks that heapStats().protectedObjectTypeCounts.Timeout, protectedObjectCount and getProtectedObjects() all see 5000 armed timers and none after they are cleared, and that no Strong* cell type shows up in objectTypeCounts. It fails on main (clearedObjectTypes: ["StrongRootBlock"], the retained spare block) and passes here; the other 8 tests in the file (callbacks reachable across GC, AbortSignal.timeout lifetime) pass unchanged.

Also run against the local StrongSet build with no failures attributable to this change: test/js/bun/jsc/, test/js/web/timers/, test/js/web/abort/, worker.test.ts, worker-terminate-lifetime.test.ts, worker_threads.test.ts, and serve.test.ts / fetch.test.ts / socket.test.ts (the latter have the same sandbox-networking failures on the main binary built the same way).

JSC now stores Strong<> slots in StrongBlocks owned by a per-heap StrongSet
(upstream ff64aee116d4, which also deletes HandleSet). Allocate bun_jsc::Strong
slots from that set instead of the StrongRootBlock cells added in #35849:
Bun__StrongRef__new returns the slot, Rust reads and writes it directly (the
set is scanned unconditionally, so stores need no barrier), and
Bun__StrongRef__delete returns it. Bun__StrongRef__set goes away.

Delete StrongRootBlock, its iso subspace, the per-VM block list on
JSVMClientData and the "Srb" marking constraint. heapStats() and
getProtectedObjects() go back to JSC's own protected-cell walk, which covers
StrongSet slots. The AbortSignal.timeout changes from #35849 are unaffected.

Requires a WebKit that contains the StrongSet refactor.
@coderabbitai

coderabbitai Bot commented Aug 12, 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: 56 seconds

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: 4bd8c8de-04e5-412e-83ab-c3034c00726a

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and 26e085d.

📒 Files selected for processing (15)
  • src/jsc/JSRef.rs
  • src/jsc/Strong.rs
  • src/jsc/bindings/Bindgen/ExternTraits.h
  • src/jsc/bindings/BunClientData.cpp
  • src/jsc/bindings/BunClientData.h
  • src/jsc/bindings/StrongRef.cpp
  • src/jsc/bindings/StrongRef.h
  • src/jsc/bindings/StrongRootBlock.cpp
  • src/jsc/bindings/StrongRootBlock.h
  • src/jsc/bindings/root.h
  • src/jsc/bindings/webcore/DOMClientIsoSubspaces.h
  • src/jsc/bindings/webcore/DOMIsoSubspaces.h
  • src/jsc/modules/BunJSCModule.h
  • src/runtime/webcore/Response.rs
  • test/js/web/timers/timer-gc-roots.test.ts

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:56 AM PT - Aug 12th, 2026

@robobun, your commit 26e085d has 2 failures in Build #93243 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37842

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

bun-37842 --bun

@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. Since it swaps the GC-root storage backing every bun_jsc::Strong, depends on an unlanded WebKit bump (CI is expected red), and carries a measured eden-pause trade-off at high handle counts, a maintainer sign-off is still warranted.

What was reviewed:

  • Strong.rs slot read/write/clear vs. JSC::Strong::set() semantics — plain store is correct (StrongSet is scanned unconditionally, no barrier).
  • Teardown ordering: is_shutting_down guard still needed since StrongSet dies in ~VM before deinit_runtime_state.
  • Grepped for lingering StrongRootBlock / StrongRefImpl / Bun__StrongRef__set references (none) and build-file entries.
  • heapStats/getProtectedObjects now delegate to JSC's own walkers; test asserts both see 5000 armed timers and 0 after clear.
Extended reasoning...

Overview

This PR removes the custom StrongRootBlock GC-cell mechanism (added in #35849) that backed bun_jsc::Strong handles, and reverts to using JSC's own strong-handle storage — now JSC::StrongSet after upstream refactor ff64aee116d4 deleted HandleSet. Net -380 lines. Touches: Strong.rs (direct slot read/write, drops Bun__StrongRef__set FFI), StrongRef.{cpp,h} (allocate/deallocate via vm.heap.strongSet()), deletes StrongRootBlock.{h,cpp} + iso-subspace entries + the "Srb" marking constraint + JSVMClientData block-list state, BunJSCModule.h (heapStats/getProtectedObjects back to JSC's own walkers), root.h include swap, and comment-only edits in JSRef.rs/Response.rs.

Security risks

None. No user-controlled input paths; this is internal GC rooting machinery.

Level of scrutiny

High. This is the mechanism that keeps every bun_jsc::Strong-held JSValue alive across GC — timers, sockets, promises, etc. A mistake here is a use-after-GC or a leak affecting the whole runtime. Additionally:

  • The PR depends on an unlanded WebKit bump (oven-sh/WebKit#404); <JavaScriptCore/StrongSet.h> does not exist against the currently-pinned WebKit, so CI cannot validate it yet. Merging before the bump would break the build.
  • It carries a deliberate performance trade-off the author measured: eden STW at 1M live handles goes from ~3.8 ms to ~10.1 ms (roughly the pre-#35849 HandleSet cost). Below ~100k it's noise. This was requested by a maintainer, but the regression/simplification trade should be acknowledged by a human on the record.

Other factors

  • The no-barrier store in Impl::set mirrors JSC::Strong::set() and is correct because the "Sh" constraint scans every StrongSet slot on every fixpoint; the PR comments this accurately.
  • The is_shutting_down early-return in Impl::destroy is retained with an updated rationale (StrongSet freed in ~VM phase C before deinit_runtime_state phase E) — matches the #31990 crash class.
  • ExternTraits<Bun::StrongRef>::ExternType changed from Bun::StrongRefImpl* to JSC::JSValue*, consistent with the new using StrongRef = std::unique_ptr<JSC::JSValue, StrongRefDeleter>; the Rust adopt side treats it as an opaque NonNull<Impl> either way.
  • I grepped the whole repo for StrongRootBlock, m_strongRootBlock*, StrongRefImpl, and Bun__StrongRef__set — no remaining references, including build files.
  • The updated test asserts protectedObjectTypeCounts.Timeout, protectedObjectCount, and getProtectedObjects() all report 5000 armed timers and 0 after clear, and that no Strong* cell type appears in objectTypeCounts — it fails on main (spare StrongRootBlock retained) and covers the observable contract this PR restores.

Given the WebKit dependency gating CI and the GC-critical surface, this should land with maintainer eyes rather than automated approval.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review, blocked on the WebKit bump.

CI on this PR fails in every build lane with root.h:84: fatal error: 'JavaScriptCore/StrongSet.h' file not found, which is the dependency described above: the prebuilt WebKit main pins (7b763944) predates the StrongSet refactor. Nothing to fix here until a bump containing upstream ff64aee116d4 lands; this branch then needs a rebase (root.h is the only expected conflict) and CI can run for real.

Additional verification since the description was written, using a local debug-local build (bun ASAN + JSC built as Debug, so JSC's own assertions are on, including StrongSet's) against main's pin plus ff64aee116d4:

  • test/js/web/timers/timer-gc-roots.test.ts: 9/9 pass.
  • test/js/bun/jsc/, test/js/web/abort/, worker.test.ts, worker-terminate-lifetime.test.ts, worker_threads.test.ts: 217 pass, 5 fail. A main build made the exact same way fails 4 of the same 5 (a pre-existing LeakSanitizer report for node_fs_binding::Binding in the dns teardown test, one worker-startup timing assertion, and two 5 s timeouts; all artifacts of the much slower JSC Debug build), and the fifth (a SubtleCrypto digest still on the work queue at terminate()) passes in isolation on this branch. No sanitizer report involves StrongSet or Bun__StrongRef__*.
  • Worker teardown after loading Bun.SQL (the Release RuntimeState's JSC handles before tearing down the VM #31990 shape, where Strongs are dropped after ~VM) and BUN_DESTRUCT_VM_ON_EXIT=1 on the main thread both exit cleanly under ASAN, confirming the retained is_shutting_down early return still covers that path with StrongSet as the backing store.

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