Skip to content

StrongRootBlock: relink blocks without the cell-validating write barrier - #38975

Open
robobun wants to merge 4 commits into
mainfrom
farm/46c6acf7/strong-root-block-sweep-relink
Open

StrongRootBlock: relink blocks without the cell-validating write barrier#38975
robobun wants to merge 4 commits into
mainfrom
farm/46c6acf7/strong-root-block-sweep-relink

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Debug builds abort during GC with ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping (JSCell::validateIsNotSweeping, JSCell.cpp:179). Stack: JSCell::classInfo <- validateCell<Bun::StrongRootBlock*> <- WriteBarrierBase<StrongRootBlock>::setMayBeNull <- StrongRootBlock::setNext <- StrongRootBlock::release <- Bun__StrongRef__delete <- Listener::deinit <- Listener::finalize <- MarkedBlock::Handle::specializedSweep.
  • A wrapper's finalizer runs inside the sweep and may drop a bun_jsc::Strong (Listener::deinit drops the data slot that stop() only emptied; ByteStream/FileReader keep their pending_value slot the same way). When that was the last occupied slot of a block, StrongRootBlock::release (src/jsc/bindings/StrongRootBlock.cpp:107) unlinks the block with prev->setNext(vm, block->next()).
  • setNext (src/jsc/bindings/StrongRootBlock.h:86) used WriteBarrier::setMayBeNull, whose GC-validation path (validateCell -> ASSERT_GC_OBJECT_INHERITS -> classInfo()) is what JSC forbids during a sweep. It only fires when the successor is non-null, i.e. when the emptied block sits between two live blocks, which is why it is timing dependent in practice (seen on Windows x64 running several socket test files in one debug bun test process; not platform specific).
  • Only builds with GC validation enabled (debug, bun bd) are affected; release builds compile validateCell to nothing.

Fix

  • setNext stores the link with setWithoutWriteBarrier and then calls vm.writeBarrier(this, next) itself. This drops only the debug-time type check of next; the store and the generational barrier are the same ones setMayBeNull performed. Same approach as Unpin ArrayBuffers without classInfo() so blob finalizers are safe during GC sweep #37008 takes for a different sweep-time classInfo() caller.
  • The type check is redundant: next is always a block taken from this list or the parked spare, and every cell touched (prev, block, next) is live, since the list and spare are rooted by the "Srb" marking constraint.
  • Running the barrier during a sweep is not new: setMayBeNull is validateCell followed by the same raw store and the same vm.writeBarrier(owner, value) (WriteBarrierInlines.h:43-55), so release builds on main already execute exactly this sequence from release() inside the sweep. Heap::writeBarrier reads structure pointers and the cell state and, on the slow path, pushes the cell onto m_mutatorMarkStack, which MarkStackMergingConstraint folds into the next collection; it never reads classInfo(). The first test below drives that slow path under a debug JSC (prev was marked by the collection being swept) with JSC's own assertions on.
  • The barrier cannot be dropped as well: acquire() links the reused spare, which survived a collection and so is old, ahead of blocks allocated since the last collection. The slot store that follows only remembers the block when the value is a cell, so for a non-cell value (listener.data = 1) the m_next barrier is the only thing that keeps the new blocks, and everything they root, alive across the next eden collection. A build with the barrier removed loses all 2880 Timeouts in the second test below.
  • Verified with bun bd test test/js/web/timers/timer-gc-roots.test.ts (the existing StrongRootBlock test file):
    • "a Strong released from a GC finalizer can unlink a block from the middle of the list": fills three blocks with timers, arms a stopped Bun.listen whose data slot opens a fourth block, fills two more blocks, clears the timers sharing the listener's block, then Bun.gc(true). Unfixed debug build: aborts with the assertion above (5/5 runs); fixed: passes, and protectedObjectTypeCounts still reaches every block on both sides of the unlinked one. With the listener's block at the head of the list instead, the unfixed build does not assert, confirming the middle-block relink is the trigger.
    • "re-linking an old block ahead of new blocks keeps them alive across an eden GC": passes on main and with this fix, reads afterEden: 0 on a build whose setNext omits the barrier.
    • Related files pass on the fixed build (test/js/bun/net/socket-retention.test.ts, test/js/bun/jsc/bun-jsc.test.ts, test/js/web/timers/clearImmediate-gc.test.ts).
  • Back bun_jsc::Strong with JSC's StrongSet and remove StrongRootBlock #37842 removes StrongRootBlock altogether but depends on a WebKit bump; this keeps debug builds usable on the current pin and is independent of it.

Background

  • bun_jsc::Strong is the Rust handle that roots a JS value. Since Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849 each handle is one slot in a StrongRootBlock, a JS cell holding 960 slots. Active blocks form a singly linked list through m_next; the head, the one parked spare block and the blocks' structure are rooted by a marking constraint, so blocks and everything in their slots stay alive. Releasing the last slot of a block unlinks it (release), and a new handle that finds every block full re-links the spare at the head (acquire).
  • JSC sweeps lazily on the mutator thread: after marking, dead cells are destroyed block by block, and each block's destructors (Bun wrappers' finalize/deinit) run with the heap in MutatorState::Sweeping. Because a Structure may already have been swept by then, JSCell::classInfo() asserts it is not called in that state. WriteBarrier<T>::set/setMayBeNull call classInfo() on the value in builds with ENABLE(GC_VALIDATION) (debug); WriteBarrier<Unknown> (the slots themselves) does not, which is why only the m_next link was affected.
  • JSC's GC is generational. An eden collection only visits cells allocated since the last collection plus old cells in the remembered set, which is what a write barrier adds a cell to when a pointer to a new cell is stored into it. An old cell that gets such a pointer without a barrier keeps pointing at a cell the eden collection will free.

StrongRootBlock::setNext went through WriteBarrier::setMayBeNull, whose
GC-validation path calls classInfo() on the block being linked. The list
is relinked from Bun__StrongRef__delete, which a wrapper's finalizer
reaches while JSC is sweeping (Listener::deinit dropping its `data`
Strong, for example), and JSC asserts that classInfo() is not read during
a sweep. Releasing the last slot of a block that sits between two live
blocks therefore aborted debug builds with

  ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() =>
    vm().heap.mutatorState() != MutatorState::Sweeping

Store the link with setWithoutWriteBarrier and run vm.writeBarrier()
explicitly. The generational barrier is still needed: acquire() links the
reused old spare ahead of blocks allocated since the last collection, and
the slot store that follows does not remember the block when the value is
not a cell.

Tests: releasing a finalizer-held Strong out of a middle block (fails on
the unfixed debug build), and an eden GC after an old block is relinked
ahead of new ones (fails if the barrier is dropped).
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 706802ce-48c5-49ef-811e-ee99aa50bb7f

📥 Commits

Reviewing files that changed from the base of the PR and between 3247540 and c123264.

📒 Files selected for processing (1)
  • src/jsc/bindings/StrongRootBlock.h

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4125e618-4c75-4db3-a002-212fccfb4a40

📥 Commits

Reviewing files that changed from the base of the PR and between 8437683 and 3247540.

📒 Files selected for processing (2)
  • src/jsc/bindings/StrongRootBlock.h
  • test/js/web/timers/timer-gc-roots.test.ts

Walkthrough

StrongRootBlock::setNext now performs an unchecked link assignment followed by an explicit VM write barrier. Timer GC tests cover middle-block unlinking during finalization and retention of newer blocks after eden GC.

Changes

StrongRootBlock GC behavior

Layer / File(s) Summary
Sweep-safe link updates
src/jsc/bindings/StrongRootBlock.h, test/js/web/timers/timer-gc-roots.test.ts
setNext avoids metadata validation during sweep-time finalization and calls vm.writeBarrier. Tests verify middle-block unlinking and protected timeout accounting.
Eden GC relinking coverage
test/js/web/timers/timer-gc-roots.test.ts
The test relinks an older spare block and verifies that newer blocks and their rooted timeouts survive eden GC.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main StrongRootBlock change.
Description check ✅ Passed The description explains the problem, fix, implementation rationale, and verification results in sufficient detail.
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.

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:13 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit c1232647 (Build #98216) was cancelled — waiting for the next build...

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, review in progress.

Reproduced on an unfixed debug build (bun bd, main at 9fb606f) with the new test in test/js/web/timers/timer-gc-roots.test.ts: three blocks of timers, a stopped Bun.listen whose data slot opens a fourth block, two more blocks of timers, clear the timers sharing the listener's block, Bun.gc(true). The child aborts with ASSERTION FAILED: ... mutatorState() != MutatorState::Sweeping (JSCell.cpp:179) on 5/5 runs; with the listener's block at the head of the list it does not, so the middle-block relink in StrongRootBlock::release is the trigger. Passes with the fix; the eden test in the same file passes on main and here and fails if the barrier is dropped from setNext.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. Because it changes write-barrier semantics on a GC-rooted linked list during sweep — where correctness depends on JSC-internal guarantees about Heap::writeBarrier being sweep-safe and sufficient for the generational remembered set — a human familiar with JSC's GC should confirm.

What was reviewed:

  • setNext decomposition is behaviorally equivalent to setMayBeNull minus the debug-only validateCell — the store and barrier are unchanged, and vm.writeBarrier(this, nullptr) (from release's trailing setNext(vm, nullptr)) is a no-op in JSC.
  • Traced acquire/release in StrongRootBlock.cpp: the "old spare linked ahead of new blocks" scenario the barrier protects is real, and the second test's block arithmetic checks out.
  • Both new tests follow harness conventions (port 0, concurrent pipe drain, stderr-first assertions); the exact-layout dependency on CAP=960 and zero startup Strongs is self-checked via the printed block counts.
Extended reasoning...

Overview

The PR changes one method in src/jsc/bindings/StrongRootBlock.h: setNext now uses m_next.setWithoutWriteBarrier(next) + vm.writeBarrier(this, next) instead of m_next.setMayBeNull(vm, this, next). This drops only the debug-build validateCell type check on next, which calls classInfo() — forbidden while JSC is in MutatorState::Sweeping. The path is reached when a wrapper's finalizer (running during sweep) drops the last bun_jsc::Strong in a block that sits between two other live blocks. Two new subprocess tests are added to test/js/web/timers/timer-gc-roots.test.ts: one reproduces the debug assertion by arranging a middle-block unlink during sweep, and one guards against dropping the barrier entirely by proving an eden GC would collect new blocks linked behind a re-used old spare.

Security risks

None. This is a debug-assertion fix in GC bookkeeping; release builds already compile validateCell to nothing, so the store + barrier sequence is byte-identical there. No user-controlled input reaches this path.

Level of scrutiny

High. StrongRootBlock is the backing store for every bun_jsc::Strong handle in the runtime, and the change reasons about generational GC invariants (remembered set, eden vs full collection, sweep-time restrictions). Getting the barrier wrong in either direction means either a debug crash (status quo) or a use-after-free of everything a block roots (if the barrier were dropped). The PR description is unusually thorough — it names the exact assertion, stack, mechanism, and cites #37008 as precedent for the same pattern — and the second test empirically proves the barrier is load-bearing. But confirming that Heap::writeBarrier(from, to) itself is safe to call during sweep (the PR asserts it "only reads structures and cell states") requires JSC-internal knowledge I'd want a maintainer to sign off on.

Other factors

I verified the decomposition against what WriteBarrierBase<T>::setMayBeNull does: it is exactly validateCell (debug, non-null only) + setEarlyValue (= setWithoutWriteBarrier) + vm.writeBarrier(owner, value), so the new code is semantically identical in release and drops only the problematic debug check. release() calls setNext(vm, nullptr) at the end, and Heap::writeBarrier early-returns on a null to, so that path is unchanged. The test block-count arithmetic (6 blocks armed, 3*CAP + CAP+1 protected Timeouts, blocksOld: 2 / blocksNew: 5) all checks out against capacity = 960 and the acquire/release logic in StrongRootBlock.cpp. The tests are intricate and will break if startup ever allocates a Strong before user code or if capacity changes, but the blocksAtStart: 0 / blocksArmed: 6 assertions make that failure loud rather than silent — which is the right trade-off for a test that needs exact layout control. #37842 is noted as removing this code entirely on a future WebKit bump, so this is a targeted stopgap.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the open question (whether Heap::writeBarrier is safe to run during a sweep), the concrete trace, for whoever confirms:

  • Heap::writeBarrier(from, to) (heap/HeapInlines.h:94) does ASSERT_GC_OBJECT_LOOKS_VALID on both cells, which only compares structure() pointers (heap/GCAssertions.h), then checks from->cellState() against the barrier threshold. The slow path (Heap::writeBarrierSlowPath, heap/Heap.cpp:2855) ends in addToRememberedSet, which asserts the cell is marked, sets its state to PossiblyGrey and appends it to m_mutatorMarkStack; that stack is merged into the next collection by MarkStackMergingConstraint (and simply cleared first when that collection is a full one). Nothing on this path reads classInfo(), which is the one thing validateIsNotSweeping guards.
  • The call is not new. setMayBeNull is validateCell + setEarlyValue, and setEarlyValue is the raw store followed by the same vm.writeBarrier(owner, value) (runtime/WriteBarrierInlines.h:43-55). Release builds compile validateCell away, so on main they already execute exactly this store and barrier from release() during the sweep whenever a middle block empties; this PR only removes the debug check in front of it.
  • The first new test runs that slow path under a debug JSC: prev was marked by the collection that is being swept, so the barrier takes addToRememberedSet with JSC's own assertions enabled, and the test passes on the fixed build.

Comment thread src/jsc/bindings/StrongRootBlock.h Outdated
Comment thread src/jsc/bindings/StrongRootBlock.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. The 5-line setNext change decomposes setMayBeNull into its raw store + explicit vm.writeBarrier, dropping only the debug-time validateCell, and the two new tests pin both the sweep-time relink and the eden-GC barrier requirement. Because this sits in JSC GC write-barrier semantics — and the author flagged whether Heap::writeBarrier is safe mid-sweep as an open question for confirmation — a human look would still be worthwhile.

What was reviewed:

  • Confirmed the new sequence matches what setMayBeNull already executed in release builds (store + vm.writeBarrier(owner, value)), so no behavior change outside dropping the debug classInfo() read.
  • Checked all three setNext call sites in StrongRootBlock.cpp (acquire prepend, release middle-unlink, release null-out) — the null case already went through vm.writeBarrier(this, nullptr) on main, so no new edge.
  • Tests: subprocess pipes drained concurrently, port: 0, assertions on exact block counts self-check the CAP == capacity and no-Strongs-at-start assumptions rather than silently passing.
Extended reasoning...

Overview

The PR changes one method in src/jsc/bindings/StrongRootBlock.h: setNext previously called m_next.setMayBeNull(vm, this, next); it now does m_next.setWithoutWriteBarrier(next) followed by vm.writeBarrier(this, next). This drops the debug-only validateCell<StrongRootBlock*> step (which reads classInfo() and trips validateIsNotSweeping when a finalizer releases the last Strong in a middle block during a sweep) while keeping the identical raw store and generational barrier that release builds already execute. Two regression tests are added to the existing test/js/web/timers/timer-gc-roots.test.ts: one that reproduces the debug assertion via a middle-block unlink from a Listener finalizer, and one that guards the barrier by verifying an old spare block re-linked ahead of new blocks keeps them alive across an eden GC.

Security risks

None. This is a debug-build assertion fix in the GC-root bookkeeping for bun_jsc::Strong; no user-controlled input, parsing, auth, or network surface is touched. The write barrier is preserved, so the generational-GC invariant that prevents old→new pointers from being missed is unchanged.

Level of scrutiny

High. This is JSC garbage-collector write-barrier code, where a mistake produces silent use-after-free rather than a crash. The change is tiny and the PR description traces the exact JSC call paths (WriteBarrierInlines.h, HeapInlines.h, Heap::writeBarrierSlowPath, addToRememberedSet) to argue safety, but the author explicitly left "whether Heap::writeBarrier is safe to run during a sweep" as an open question for a reviewer to confirm. The repo's review guidance treats GC/write-barrier reasoning as the most-blocked category, and #37008 is cited as precedent for the same pattern — a human familiar with that precedent should sign off.

Other factors

  • The three call sites of setNext in StrongRootBlock.cpp (acquire line 88, release lines 107 and 112) all pass either a live listed block, the parked spare, the current head, or nullptr; the dropped type check was redundant for all of them since the "Srb" marking constraint roots every block involved.
  • The tests hard-code CAP = 960 to match StrongRootBlock::capacity and assume a fresh process starts with zero Strongs. Both assumptions are asserted through the printed blocksArmed/blocksAtStart/blocksOld/blocksNew counts, so a future capacity change or a startup-time Strong will fail loudly rather than pass vacuously.
  • The comment-cop feedback about the paragraph-long code comment was addressed (reduced to one line in c123264); those threads are resolved.
  • No CODEOWNERS entry covers these files.
  • #37842 is noted as removing StrongRootBlock entirely on a future WebKit pin, so this fix is a bridge for the current pin — small blast radius either way, but still worth a human confirming the sweep-time barrier reasoning before merge.

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.

1 participant