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

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

What does this PR do?

Removes StrongRootBlock (added in #35849) and backs bun_jsc::Strong with JSC's own strong-handle storage again. Upstream JSC now keeps Strong<> slots in StrongBlocks owned by a per-heap StrongSet (upstream ff64aee116d4, which also deleted HandleSet), and the WebKit that main pins since #39371 ships it (root.h already includes StrongSet.h).

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 object a bun_jsc::Strong pins.
  • The is_shutting_down early return in Impl::destroy stays: teardown destroys the VM (and with it the StrongSet) before deinit_runtime_state drops the Strongs it still owns (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: 77 insertions, 475 deletions.

Trade-off, measured

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 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; also reproduced with USE_SYSTEM_BUN=1) and passes with bun bd test against the pinned WebKit. The other 8 tests in the file pass unchanged.

Also run with bun bd (ASAN) against the pinned WebKit: test/js/bun/jsc/, test/js/web/timers/, test/js/web/abort/, worker-terminate-lifetime.test.ts. The only failures are ones a main build made the same way also has: the RSS-delta timer leak tests (their fixture only widens the threshold for a binary named bun-asan; with ASAN's quarantine disabled the delta on this branch is 1.5 MB / -0.6 MB), a 30 s timeout in setInterval doesn't leak memory, and a pre-existing LeakSanitizer report for node_fs_binding::Binding in the dns teardown test. Worker teardown after loading Bun.SQL (the #31990 shape) and BUN_DESTRUCT_VM_ON_EXIT=1 on the main thread both exit cleanly under ASAN.

Notes
  • The benchmarks above were taken before the WebKit bump landed, against main's then-current pin (7b763944) with ff64aee116d4 cherry-picked on top and JSC built from source, so both binaries ran the identical engine. The pinned prebuilt's StrongSet.h has the same unconditional forEachSlot walk, so the numbers still describe the current pin.
  • Before the bump this PR failed to build in CI (StrongSet.h not found); it was rebased once the bump landed. The only conflict was BunClientData.h, where process.memoryUsage: report heapUsed from the most recent collection #39593 had added HeapSizeAfterLastCollection to the same namespace Bun block that held the StrongRootBlock forward declaration; the block is kept and only the forward declaration is removed.
  • Second rebase, after Trim ~2 MB from the release binary without touching hot paths #39770: that PR turned the two iso-subspace tables into raw owned pointers and rewrote StrongRootBlock::subspaceForImpl to use BUN_SUBSPACE_SLOTS. Resolved by deleting the file as before and removing the one StrongRootBlock entry from each of the new-style tables; the table destructors added there still hold since each table stays a plain array of pointers.
  • Bump WebKit: assert the VM's API lock is held on HandleSet mutation #36958 adds API-lock assertions to HandleSet and to Bun__StrongRef__* for the StrongRootBlock path; both halves are obsoleted by the upstream refactor plus this change and would need to be redone against StrongSet if still wanted.
  • Benchmark scripts: 1M setTimeout(noop, 600000) held in an array, two full GCs, then 100k-object allocation rounds to drive eden collections, parsing the p= pauses of each EdenCollection from BUN_JSC_logGC=1; the throughput numbers are clearTimeout(setTimeout(...)) loops of the listed shapes.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 26 days. After that, they cost $0.25 per reviewed file.

Or wait 6 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef67c2b6-6641-41bb-b07a-d4c9036e969b

📥 Commits

Reviewing files that changed from the base of the PR and between 06820dc and 6e3d3fe.

📒 Files selected for processing (14)
  • 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/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 11:11 PM PT - Aug 25th, 2026

✅ @robobun, your commit 6e3d3fe2496229a808ad365b03453a04de0f14ef passed in Build #106103! 🎉


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

Comment thread src/jsc/JSRef.rs Outdated
Comment on lines +97 to +98
/// slot backing `Strong` lives in the VM's `JSC::StrongSet` and must be
/// dropped on the JS thread.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +13 to +14
// Strong must be dropped on the JS thread (the slot belongs to the VM's
// `JSC::StrongSet`, which is only touched under the JSLock).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +32 to +34
/// Set a new value for the strong reference. The slot already exists, so
/// `_global` is only taken to keep the signature interchangeable with
/// [`Optional::set`], which needs it to allocate one.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +166 to +168
/// Opaque FFI handle: points at the `JSC::JSValue` slot that
/// `Bun__StrongRef__new` allocated in the VM's `JSC::StrongSet` (the same
/// storage `JSC::Strong<>` uses); see StrongRef.cpp.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +173 to +174
/// The slot holds exactly the `EncodedJSValue` bits (`JSC::JSValue` is one
/// 64-bit word), which is what [`JSValue`] is on this side.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +192 to +193
/// Plain store, like `JSC::Strong::set()`: the GC's strong-handle
/// constraint scans every slot of the set, so there is no barrier to run.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +216 to +222
// The slot belongs to the VM's StrongSet, which ~VM frees (teardown
// phase C, see `VirtualMachine::teardown`), while runtime state that
// still owns `Strong`s is torn down after that (`deinit_runtime_state`,
// phase E). `is_shutting_down` is set before teardown starts, and from
// then on the slot simply dies with the set: the final collection is
// followed by ~VM's lastChanceToFinalize, so nothing it roots outlives
// the VM. The Rust VM TLS outlives ~VM.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +235 to +237
// leak until that VM's teardown. StrongSet::deallocate requires
// the JSLock, so it cannot be called from here. Flag in debug so
// the owning wrapper can queue the drop back to the JS thread.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs Outdated
Comment on lines +251 to +254
// `JSGlobalObject` is an opaque `UnsafeCell`-backed ZST handle, so
// `&JSGlobalObject` is ABI-identical to a non-null `*const T`. `new` hands out
// a slot inside the VM's StrongSet (no heap allocation of its own); `delete`
// returns it and so stays `unsafe fn`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/StrongRef.cpp Outdated
Comment on lines +8 to +9
// Plain store, like JSC::Strong::set(): the "Sh" marking constraint scans
// every StrongSet slot, so there is no barrier to run.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/StrongRef.h Outdated
Comment on lines +5 to +7
// One slot in the VM's JSC::StrongSet, the storage JSC::Strong<> itself uses.
// The slot pointer is the handle: bun_jsc::Strong (Strong.rs) reads and writes
// the JSValue through it directly and only comes back here to release it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun
robobun force-pushed the farm/af998b00/remove-strong-root-block branch from f9d69d5 to 736c3df Compare August 19, 2026 00:30
Comment thread src/jsc/JSRef.rs
Comment on lines +96 to +97
/// `JsRef` is `!Send + !Sync` (transitively via `JSValue` and `Strong`): a
/// `Strong` must be dropped on the JS thread.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs
Comment on lines +162 to +163
/// The `JSC::JSValue` slot that `Bun__StrongRef__new` allocated in the VM's
/// `JSC::StrongSet`; see StrongRef.cpp.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs
Comment on lines +184 to +185
/// A plain store, like `JSC::Strong::set()`: the GC scans every slot of
/// the set, so there is no write barrier.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs
Comment on lines +207 to +209
// `VirtualMachine::teardown` destroys the JSC VM, and the StrongSet
// with it, before it drops the runtime state that still owns
// `Strong`s; once shutdown has begun the slot dies with the set.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs
Comment on lines +219 to +222
// Off the JS thread (an `unsafe impl Send` wrapper dropped us on
// a pool thread). StrongSet::deallocate needs the JSLock, so the
// slot leaks until the VM's teardown; the wrapper should queue
// the drop back to the JS thread instead.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/Strong.rs
Comment on lines +236 to +237
// `&JSGlobalObject` is ABI-identical to a non-null `*const T` (opaque ZST
// handle), so `new` is a safe fn; `delete` consumes the slot and stays unsafe.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +5 to +6
// The handle behind bun_jsc::Strong (Strong.rs): a slot in the VM's
// JSC::StrongSet, which Rust reads and writes directly.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main now that the WebKit bump with the StrongSet refactor (#39371) has landed; the one conflict was the namespace Bun block in BunClientData.h (#39593 added HeapSizeAfterLastCollection there; only the StrongRootBlock forward declaration is removed). The description is updated to the current state: the change builds against the pinned prebuilt with bun bd, and the verification section now reflects runs against that build. Comments in the touched code were also trimmed.

@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 rewires bun_jsc::Strong's GC-rooting backing store and carries a measured eden-pause trade-off that was requested via Slack, a maintainer sign-off would still be worthwhile.

What was reviewed:

  • Impl::get/set/clear now read/write the JSC::JSValue slot directly — Rust JSValue is #[repr(transparent)] over an 8-byte word, layout-identical to the 64-bit C++ JSC::JSValue, so the raw slot access is ABI-correct and no write barrier is needed since StrongSet scans every slot.
  • Grepped for remaining StrongRootBlock / Bun__StrongRef__set / StrongRefImpl references — none; all consumers (iso-subspace tables, BunJSCModule.h, ExternTraits.h, JSVMClientData) are updated in this diff.
  • The is_shutting_down guard in Impl::destroy is retained, so the #31990 teardown-after-~VM path is still covered.
Extended reasoning...

Overview

Removes the custom StrongRootBlock GC-cell machinery (added in #35849) and backs bun_jsc::Strong with JSC's new per-heap StrongSet (upstream ff64aee116d4, shipped in the WebKit pin since #39371). Net -398 lines: deletes StrongRootBlock.{h,cpp}, its iso-subspace entries, the JSVMClientData list/cursor/structure fields, and the "Srb" marking constraint; simplifies StrongRef.cpp to a two-function wrapper over StrongSet::allocate/deallocate; drops Bun__StrongRef__set so Strong.rs reads/writes the slot directly; reverts heapStats()/getProtectedObjects() to JSC's own walkers; updates one test and a handful of comments.

Security risks

None. This is internal GC-root plumbing with no user-controlled input, parsing, or trust boundary. The relevant risk class is memory safety (use-after-free if a rooted value is not visited), not security in the auth/injection sense.

Level of scrutiny

High. bun_jsc::Strong is the primary mechanism by which native code keeps JS values alive across GC — every timer, socket, promise callback, etc. depends on it. A missed root here is a runtime-wide UAF. The change is well-argued (mirrors JSC::Strong::set()'s barrier-free store, since StrongSet::visitAggregate scans every slot on every collection) and extensively tested under ASAN and JSC-Debug assertions, but this is exactly the category where a maintainer should confirm the reasoning independently.

Other factors

  • The PR documents a deliberate performance trade-off: eden STW pause at 1M live handles goes from ~3.8 ms to ~10.1 ms (roughly the pre-#35849 HandleSet number), while per-handle throughput improves ~15–20%. Jarred requested the change, so they should confirm the trade-off is acceptable.
  • I verified Rust JSValue is #[repr(transparent)] over a 64-bit word (src/jsc/JSValue.rs:29-34), so casting the HandleSlot (JSC::JSValue*) to NonNull<JSValue> and doing raw .read()/.write() is layout-correct; the previous DecodedJSValue indirection was equivalent.
  • Grepped the tree for lingering StrongRootBlock/StrongRefImpl/Bun__StrongRef__set references: none remain.
  • The comment-cop bot flagged long comments in two rounds; the author trimmed them (00:35 comment) and the current diff's comments are 1–3 lines each.
  • Not covered by CODEOWNERS.

@robobun
robobun force-pushed the farm/af998b00/remove-strong-root-block branch from 736c3df to 946c34c Compare August 21, 2026 00:29
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased again after #39770, which modified StrongRootBlock.cpp and rewrote the two iso-subspace tables this PR removes an entry from. The resolution is mechanical (file still deleted, one line removed from each new-style table); the diff is otherwise unchanged and timer-gc-roots.test.ts, test/js/bun/jsc/ and test/js/web/abort/ pass on the rebased bun bd build. Details added to the Notes in the description.

@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. Given that this swaps the backing store for every bun_jsc::Strong in the runtime (core GC-rooting infrastructure) and carries a measured eden-pause trade-off at high handle counts, a maintainer sign-off would still be worthwhile.

What was checked:

  • No stale references to StrongRootBlock, StrongRefImpl, Bun__StrongRef__set, or the m_strongRootBlock* fields remain anywhere in src/.
  • Rust JSValue is #[repr(transparent)] over the 64-bit encoded word, so casting the returned JSC::JSValue* slot to NonNull<JSValue> and reading/writing it directly is layout-sound.
  • The is_shutting_down early return in Impl::destroy is retained, covering the #31990 teardown ordering.
  • heapStats() / getProtectedObjects() revert cleanly to the JSC walkers now that the slots live in StrongSet; the updated test asserts both paths and the absence of any Strong* cell type.
Extended reasoning...

Overview

This PR removes Bun's custom StrongRootBlock GC-cell implementation (added in #35849 to avoid O(N) HandleSet scans on eden collections) and re-backs bun_jsc::Strong with JSC's new upstream StrongSet, which replaced HandleSet in ff64aee116d4. Net -398 lines. Bun__StrongRef__new/delete become thin wrappers over StrongSet::allocate/deallocate; Bun__StrongRef__set is deleted and Rust writes the slot directly (no write barrier, matching JSC::Strong::set()). The iso-subspace entries, JSVMClientData list/cursor/structure fields, and the "Srb" marking constraint are all removed. heapStats() and getProtectedObjects() drop their block-walk merge and go back to the plain JSC accessors.

Security risks

None. This is internal GC-root plumbing with no user-controlled input, parsing, auth, or network surface.

Level of scrutiny

High. bun_jsc::Strong is the mechanism by which timers, sockets, SQL connections, fetch bodies, and dozens of other native objects keep their JS wrappers alive. A mistake here is a use-after-free or a leak across the whole runtime. Two claims in particular deserve a maintainer's eyes:

  1. No write barrier on set: the PR asserts (correctly per upstream JSC::Strong::set()) that StrongSet slots need no barrier because the set is scanned unconditionally on every collection. This is the whole reason StrongRootBlock existed — its slots were WriteBarrier<Unknown> precisely so eden could skip old-gen blocks. Dropping the barrier is only sound because the backing store changed; someone who knows JSC's collector should confirm.
  2. Perf trade-off: eden STW at 1M live handles goes from ~3.8 ms to ~10.1 ms (roughly the pre-#35849 number). Below ~100k handles it's noise, and per-handle throughput improves. Whether that regression at the extreme is acceptable is a product call the requester (Jarred) presumably already made, but it should be acknowledged on the record.

Other factors

  • The change was explicitly requested by a maintainer, and the PR description is unusually thorough (benchmarks, ASAN + JSC-Debug verification, worker-teardown check for the #31990 shape).
  • I grepped for every deleted symbol across src/ — no stragglers; the build-system references to StrongRootBlock.cpp would fail the build if any remained.
  • The Rust ↔ C++ ABI is sound: JSC::HandleSlot is JSValue*, and Rust's JSValue is #[repr(transparent)] over the same 8-byte encoded word, so the direct .read()/.write() in Impl::get/set is layout-correct.
  • Strong::set now ignores its global parameter (renamed _global); call sites are unchanged so this is a harmless compat shim.
  • The comment-cop bot flagged long comments on an earlier revision; the author trimmed them in the final push and the current diff's comments are concise.
  • The updated test in timer-gc-roots.test.ts fails on main (clearedObjectTypes: ["StrongRootBlock"]) and passes with the change, satisfying the fails-without-fix rule.

Given the blast radius and the explicit perf trade-off, this warrants a human approval rather than an automated one.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for review at 946c34c: every build lane in CI compiles this head and 170+ jobs have passed so far with no failures. One note for anyone looking at automated with/without-change rebuilds of this PR: this change deletes StrongRootBlock.{h,cpp}, and a rebuild that restores the base copies of deleted files while keeping the rest of the diff fails in StrongRootBlock.cpp (no member named 'm_clientSubspaceForStrongRootBlock' and friends), because that file references the members this PR removes. A clean checkout of the branch builds and passes test/js/web/timers/timer-gc-roots.test.ts.

@robobun
robobun force-pushed the farm/af998b00/remove-strong-root-block branch from 946c34c to 62c84f9 Compare August 24, 2026 04:47
@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again (head 62c84f9). The only conflict was a two-line comment in Response.rs that both #40238 and this PR had reworded; no code conflicts. timer-gc-roots.test.ts, test/js/bun/jsc/ and test/js/web/abort/ pass on the rebased bun bd build.

@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 backing store for every bun_jsc::Strong (core GC rooting) and accepts a measured eden-pause regression at high handle counts, a human sign-off on the trade-off and the StrongSet interaction would still be worthwhile.

What was reviewed:

  • Bun__StrongRef__new/delete against StrongSet::allocate/deallocate; direct slot read/write from Rust matches JSC::Strong::set() (no barrier, set is scanned unconditionally) and Rust JSValue is #[repr(transparent)] over the 64-bit encoded word so the raw slot store is ABI-correct.
  • No remaining references to StrongRootBlock, StrongRefImpl, or Bun__StrongRef__set anywhere in the tree (build files included); ExternTraits<Bun::StrongRef> updated to the new JSC::JSValue* handle type.
  • The retained is_shutting_down early return in Impl::destroy still covers the #31990 teardown ordering with StrongSet as the store; the off-JS-thread branch leaks the slot rather than touching StrongSet::deallocate without the lock.
  • heapStats() / getProtectedObjects() reverted to JSC's own walkers, and the updated timer-gc-roots.test.ts asserts both see the armed timers and that no Strong* cell type remains.
Extended reasoning...

Overview

This PR removes Bun's custom StrongRootBlock GC-cell backing for bun_jsc::Strong and returns to JSC's own strong-handle storage, now JSC::StrongSet (upstream ff64aee116d4, which replaced the old HandleSet). The handle is once again the raw JSC::JSValue* slot; Rust reads/writes it directly and only crosses FFI to allocate/release. Net -398 lines: two source files deleted, the "Srb" marking constraint and four JSVMClientData members removed, iso-subspace table entries dropped, and heapStats()/getProtectedObjects() reverted to JSC's own walkers. Comment-only edits in JSRef.rs and Response.rs.

Security risks

None. This is internal GC-root plumbing with no user-facing surface, no parsing of untrusted input, and no auth/crypto involvement.

Level of scrutiny

High. bun_jsc::Strong is the primitive that keeps timers, sockets, promise reactions and dozens of other native objects alive across GC; a mistake here is a runtime-wide UAF or leak. The PR also knowingly gives up the generational skip StrongRootBlock provided (eden STW at 1M live handles goes from ~3.8 ms to ~10.1 ms per the PR's own measurements) in exchange for less code and cheaper allocation. That is a maintainer-level trade-off — the description says Jarred requested it, so Jarred (or another maintainer) confirming the numbers and the direction is the right approval path, not an automated one.

Other factors

The bug-hunting pass found nothing. I confirmed no stale references to the removed symbols remain anywhere in the repo, the Rust JSValue layout (#[repr(transparent)] over the encoded 64-bit word) makes the direct slot read/write bit-identical to JSC::JSValue on 64-bit, and the ExternTraits<Bun::StrongRef> bindgen glue was updated to the new JSC::JSValue* extern type. The updated test asserts both protectedObjectTypeCounts.Timeout and getProtectedObjects() see 5000 armed timers and none after clearing, and that no Strong* cell type appears — it fails on main and passes here. CI on the current head compiles in every lane with 170+ jobs green. All of that is reassuring, but the change sits squarely in REVIEW.md's most-blocked category (native memory safety / GC rooting) and carries a deliberate performance trade-off, so I'm deferring rather than approving.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 62c84f9 had one red lane: test/bake/deinitialization.test.ts segfaulted on windows-aarch64 (Segmentation fault at address 0x29c0000000a). I reproduced it on a Windows 11 arm64 debug build of current main (aeb1905), without this change, at 0xDFDFDFDFDFDFDFF9 (mimalloc's freed-memory fill): about 1 run in 6, in the closeActiveConnections cases with an open HMR WebSocket. The symbolized stack is a libuv poll completion dispatching into a freed uSockets socket (us_internal_dispatch_ready_poll, packages/bun-usockets/src/loop.c:729, via poll_cb in eventing/libuv.c); it does not involve Strong handles. This branch shows the same address and a similar rate (2 in 6), so the crash is a pre-existing Windows bug that GC timing exposes; it is reported separately. The same test passes 3/3 under ASAN on Linux on this branch. Pushed one empty commit to rerun CI.

@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 swaps the backing storage for every bun_jsc::Strong GC root and carries a documented eden-pause trade-off at high handle counts, a maintainer sign-off would still be worthwhile.

What was reviewed:

  • StrongRef.cpp / Strong.rs: slot allocate/read/write/deallocate against StrongSet; the barrier-free Impl::set matches JSC::Strong::set()'s contract that the set is scanned unconditionally.
  • Confirmed no remaining references to StrongRootBlock, StrongRefImpl, Bun__StrongRef__set, or the removed m_strongRootBlock* members anywhere in src/.
  • ExternTraits.h / StrongRef.h: Bun::StrongRef now wraps JSC::JSValue*; unique_ptr::release() in convertToExtern still hands the same pointer shape to Strong::adopt.
  • The retained is_shutting_down guard in Impl::destroy still covers post-~VM drops; the unused _global on Strong::set was noted as an intentional API-compat stub.
Extended reasoning...

Overview

This PR removes the custom StrongRootBlock JSCell-based backing for bun_jsc::Strong (added in #35849) and reverts to using JSC's own per-heap StrongSet, which upstream introduced in ff64aee116d4 (replacing HandleSet) and which the pinned WebKit now ships. Net −398 lines: StrongRootBlock.{h,cpp} deleted, its iso-subspace entries and JSVMClientData bookkeeping removed, the "Srb" marking constraint dropped, and BunJSCModule.h's heapStats()/getProtectedObjects() reverted to plain vm.heap.protectedObject*() calls. The Rust Strong handle is now the raw JSC::JSValue* slot; reads/writes/clears are direct pointer ops in Rust with FFI only for allocate/deallocate.

Security risks

None in the traditional sense (no user-input parsing, auth, or network surface). The risk class is memory safety: this is the mechanism by which every strong GC root in the Rust runtime pins a JS value. A bug here would be a runtime-wide UAF or leak. The specific claims audited: (1) StrongSet::allocate() returns a stable HandleSlot that lives until deallocate, so Rust's raw-pointer read/write is sound; (2) no write barrier is needed because StrongSet::visitAggregate scans every slot on every collection — the PR mirrors JSC::Strong::set()'s own plain store; (3) the is_shutting_down early return still prevents deallocating into a freed StrongSet during VM teardown.

Level of scrutiny

High. This is core GC-rooting infrastructure with FFI ABI changes (Bun__StrongRef__new now returns JSC::JSValue*, Bun__StrongRef__set is removed, Bun::StrongRef in ExternTraits.h changed extern type). It also encodes a design decision — accepting a ~2.6× eden-pause regression at 1M live handles in exchange for less code and cheaper per-handle allocation — that was requested by a maintainer in Slack but should be confirmed on the record.

Other factors

The bug-hunting system found nothing; the one candidate raised (unused _global on Strong::set) was correctly ruled out as an API-compatibility stub. Grep confirms all references to the deleted subsystem are gone. The updated timer-gc-roots.test.ts asserts protectedObjectTypeCounts, protectedObjectCount, and getProtectedObjects() all reflect StrongSet-backed handles, and asserts no Strong* cell type appears in objectTypeCounts (which fails on main). ASAN verification of worker teardown and BUN_DESTRUCT_VM_ON_EXIT=1 is documented. The one CI failure (windows-aarch64 deinitialization.test.ts) reproduces on main without this change and traces to a uSockets/libuv UAF unrelated to Strong handles. Given the blast radius, a human look is the right call even with no findings.

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.
@robobun
robobun force-pushed the farm/af998b00/remove-strong-root-block branch from ac7f99d to 6e3d3fe Compare August 26, 2026 05:39
@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again (head 6e3d3fe). One conflict in BunClientData.h: #40509 added sharedModuleInfoIdentifiers next to the StrongRootBlock members this PR removes; the new field is kept, the members are removed. The empty retrigger commit is dropped. timer-gc-roots.test.ts, test/js/bun/jsc/ and test/js/web/abort/ pass on the rebased bun bd build.

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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