Skip to content

[JSC] Stop a heap's local Collector threads when the heap shuts down (cherry-pick WebKit/WebKit#77844) - #882

Open
robobun wants to merge 1 commit into
mainfrom
robobun/ea5af9c6/collector-threads-stop-at-heap-shutdown
Open

robobun wants to merge 1 commit into
mainfrom
robobun/ea5af9c6/collector-threads-stop-at-heap-shutdown

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Cherry-pick of WebKit#77844 (324477@main, https://bugs.webkit.org/show_bug.cgi?id=328418), landed upstream on 2026-10-22. Author and message are unchanged. Close it if a sync brings the commit first.

Problem

  • jsc aborts when a VM dies while its collector thread is in Heap::stopTheMutator() (heap/Heap.cpp:1989): RELEASE_ASSERT(iter != m_mapping.end()) at runtime/JSRunLoopTimer.cpp:150.
  • CI shows it as a flaky stress/waitasync-wait-infinity-notify-half-multi-workers.js.dfg-eager-no-cjit-validate: 27 of 10,800 runs of the lane's jsc of main, 36 at once. The test legs gate no release.
  • Cause: 323479@main (sync Upgrade to upstream WebKit 23b0720b40 #814) moved the collector thread's join from Heap::shutDown() to ~Collector, after ~VM unregisters the VM.

Fix

  • Heap::shutDown() calls the new Collector::shutDown() right after the heap leaves its local Collector, before anything is finalized.
  • ~Collector asserts that both threads are stopped.
  • Verified with the lane's jsc of this branch: 0 of 10,800. With a sleep in the hand-off: 0 of 864 (main: 87).
  • Self-reviewed: 3 concerns raised, 3 addressed (Notes).

Background

  • The conn is the right to conduct a collection. The collector thread gives it to a mutator with heap access, then schedules StopIfNecessaryTimer: the late call.
  • A local Collector belongs to one Heap and owns its collector thread.
  • Not built: a lock around the hand-off, a timer that ignores a dead VM. Both cost every hand-off.

Downsides

  • Not fixed: --useGlobalGC=true (off by default, unfinished upstream). Its collector thread is shared.
  • The lanes skip the new test (memoryHog!). Without the Heap.cpp hunk, 14 of 23 agent tests abort in ~Collector.
  • Per VM destruction: +69 instructions and one lock pair. .text +352 bytes in the LTO lane.
Notes

Why now, and when it is redundant. Upstream reviewed and landed the fix on 2026-10-22, as 324477@main (eaf0efa57b26). The next bun/upgrade-to-* sync brings the same commit. The last two syncs merged on 2026-10-15 (#814) and 2026-10-21 (#860). Until the next one, main has the race (since #814, still at 7961839), and it makes test legs red for changes that do not touch it (run 39351346694 of main 274ebb3). This is a request to merge on review. The commit is the landed one, so the sync merges cleanly over it. If a sync that has 324477@main merges first, this pull request is redundant: close it.

Reach. Only the fork's jsc test legs under load show the crash: 0.06 to 0.25% of the runs of the CI test in dfg-eager-no-cjit-validate, with three processes per core (2 of 3,240, 8 of 10,800 and 27 of 10,800). Those legs do not gate a release. Bun 1.4.4 (2026-10-22) pins WebKit d6375c7, which has the race, so it is the first Bun release with it. Bun was not measured. From the code, default Bun does not reach it: destroyVM() runs a synchronous full collection right before the last deref(), so the collector thread has no collection to start at that point unless BUN_JSC_collectContinuously is set.

Self-review. Three concerns survived. All are about the request, none about the code.

  1. Nobody owns a rule such as "merge it if the sync is cut first". Addressed: the request is to merge on review, and the first paragraph says when to close it.
  2. An unreviewed upstream draft should not go in ahead of upstream's review. Addressed by upstream: the draft (49909f2bfcc0) was reviewed and landed while this was measured. The commit here is the landed one. Its added and removed lines are those of the draft.
  3. The text must not read as a fix for a crash that users hit. Addressed: see "Reach".

The port. git am of upstream's patch on main (7d0c8a7, then rebased on 5cfcac0 and 7961839): no conflict, offsets of at most 21 lines. The added and removed lines are upstream's, byte for byte, and the message is the one of the landed commit. JSTests/stress/many-agents-teardown.js is upstream's file, unchanged. No existing test is edited, so JSTests/BUN-TEST-DIFFERENCES.md has no new entry. Nothing is fenced with USE(BUN_JSC_ADDITIONS).

Reproduce (release jsc, Linux x64, 12 cores. Line numbers are those of main 7961839. The builds that were measured are of 7d0c8a7, before the sync #860, where Heap.cpp:1989 is line 1960. The rebased commit was built and checked again: last table row and "After the rebase".) In JSTests/stress, with the options of the mode dfg-eager-no-cjit-validate:

jsc --validateOptions=true --useFTLJIT=false --useFunctionDotArguments=true --validateExceptionChecks=true --useDollarVM=true --maxPerThreadStackUsage=1572864 --validateGraph=true --validateOptions=true --useConcurrentJIT=false --thresholdForJITAfterWarmUp=100 --scribbleFreeCells=true --validateOptions=true --thresholdForJITAfterWarmUp=10 --thresholdForJITSoon=10 --thresholdForOptimizeAfterWarmUp=20 --thresholdForOptimizeAfterLongWarmUp=20 --thresholdForOptimizeSoon=20 --thresholdForFTLOptimizeAfterWarmUp=20 --thresholdForFTLOptimizeSoon=20 --thresholdForOMGOptimizeAfterWarmUp=20 --thresholdForOMGOptimizeSoon=20 --maximumEvalCacheableSourceLength=150000 --useEagerCodeBlockJettisonTiming=true --repatchBufferingCountdown=0 --collectContinuously=true --useGenerationalGC=false --verifyGC=true waitasync-wait-infinity-notify-half-multi-workers.js

The test starts 20 agents. Each agent is a VM on its own thread, with a collector thread and, in this mode, a thread that requests a collection every millisecond. One process alone never failed (0 of 10). With 36 processes at once:

binary exit 134
bin/jsc of bun-webkit-linux-amd64-lto for main 7d0c8a7 2 of 3,240
local Release build of main 7d0c8a7 8 of 10,800
the same build with this change 0 of 10,800
this branch, rebased on the sync #860 0 of 4,320
bin/jsc of bun-webkit-linux-amd64-lto for main 7961839 27 of 10,800
bin/jsc of bun-webkit-linux-amd64-lto for this branch (autobuild-preview-pr-882-ea3d81da) 0 of 10,800

The last two rows ran back to back on the same machine. All 8 cores of the local build and all 27 of the lane's build of main have this stack, on the collector thread of one agent:

WTFCrashWithInfo
JSC::JSRunLoopTimer::Manager::scheduleTimer   runtime/JSRunLoopTimer.cpp:150   RELEASE_ASSERT(iter != m_mapping.end()); // We don't allow calling this after the VM dies.
JSC::JSRunLoopTimer::setTimeUntilFire         runtime/JSRunLoopTimer.cpp:259
JSC::Heap::stopTheMutator                     heap/Heap.cpp:1989               m_stopIfNecessaryTimer->scheduleSoon();
JSC::Collector::finishChangingPhase           heap/Collector.cpp:756
JSC::Collector::runNotRunningPhase            heap/Collector.cpp:387
JSC::Collector::CollectorThread::work         heap/Collector.cpp:99

In the same cores the agent threads are in ~VM (VM.cpp:734) -> ~Heap -> ~Collector, that is after JSRunLoopTimer::Manager::unregisterVM (VM.cpp:689).

The sequence on main (C is the collector thread of a VM, M its mutator)

  1. C starts a collection. M has heap access, so Heap::stopTheMutator() gives M the conn: the compare-exchange at Heap.cpp:1987 sets mutatorHasConnBit. C has not run line 1989 yet.
  2. M is in VM::~VM -> Heap::shutDown() and waits for its Leave (waitForMembershipChange). It sees the conn, runs the whole collection, and applies its own Leave in Collector::runNotRunningPhase.
  3. Heap::shutDown() returns. ~VM unregisters the VM (VM.cpp:689). Then ~Heap destroys m_stopIfNecessaryTimer (Heap.h:992) before m_localCollector (Heap.h:943), whose destructor is the first thing that joins C.
  4. C runs line 1989: the RELEASE_ASSERT if the VM is only unregistered, a null Ref if ~Heap got further.

Before 323479@main Heap::shutDown() stopped both threads itself before it finalized anything.

A wider window. For the numbers below, both builds got three lines that are not in this change: a sleep, read from an environment variable, between Heap.cpp:1987 and :1989. With it the crash on main is the null Ref: SIGSEGV at StopIfNecessaryTimer.cpp:48, this == nullptr, while the mutator of that VM is in ~Collector -> stopThread() -> join().

program sleep main this change
jsc --destroy-vm --collectContinuously=true --collectContinuouslyPeriodMS=0.05 -e 'var e=Date.now()+3; while(Date.now()<e){}' 30 ms 44 of 600 0 of 600
the CI test, 4 at once 10 ms 8 of 120 0 of 120
the CI test, 4 at once 50 ms 18 of 120 0 of 120
stress/many-agents-teardown.js with its own options 30 ms 17 of 24 0 of 24

After the rebase on the sync #860 (no function of this change differs between the two bases): the stress runner on the agent files, 261 pass and 0 fail. With the sleep: 0 of 600 for the one-VM program at 30 ms, 0 of 120 for the CI test at 50 ms.

The new test in this fork. many-agents-teardown.js is //@ memoryHog!. The lanes pass --memory-limited, so the runner prints Skipping stress/many-agents-teardown.js. Without the sleep it does not tell the two builds apart on this machine: main 0 of 160, this change 0 of 160 (40 alone, 120 at 8 at once).

A lost hunk shows in CI. ~Collector now has RELEASE_ASSERT(!m_collectContinuouslyThread) and RELEASE_ASSERT(m_threadShouldStop). A build of this change without the Heap.cpp hunk: 14 of the 23 JSTests/stress files that start an agent exit 134 in one run each with default options (Collector::~Collector <- Heap::~Heap <- VM::~VM). So a later sync that drops the call in Heap::shutDown() fails in every mode.

Agent tests in all modes. run-jsc-stress-tests --memory-limited on the 24 files that start an agent, 17 modes:

  • One round: 261 pass and 0 fail, on main and on this change.
  • Ten rounds on this change: 2,608 pass, 2 fail. main has both failures at the same rate, so they are not from this change:
    • waitasync-promise-timeout-finite-gc.js.no-cjit-validate-phases hits the shell's timeout. The main thread spins on waiterListSize() after its only agent is gone. Alternating batches, 24 at once: main 18 of 3,600, this change 15 of 3,600.
    • intl-datetimeformat-language-change-mid-construction.js.default: cacheable resolution pinned at ... across 25 turns. main 6 of 960, this change 3 of 960.
  • No run stopped at the new asserts, at RELEASE_ASSERT(!m_threadShouldStop) in Collector::requestCollection, or at the one in Heap::finishRelinquishingConn. The collector is now stopped while finalizers run, as it was before 323479@main.

Cost (Release builds of 7d0c8a7 with and without the change, x64, no LTO. strace, perf and valgrind are not on this machine, so the counts are from gdb.)

per VM destruction main this change
calls of Collector::stopThread, stopCollectingContinuously, AutomaticThread::tryStop 1, 1, 1 1, 1, 1
AutomaticThread::join, Thread::waitForCompletion (a VM that never collected) 0, 0 0, 0
the same with --collectContinuously=true 1, 1 1, 1
instructions in Heap::shutDown() up to finalization (jsc --destroy-vm -e '', single-stepped, 3 runs) 2,579 2,648
lock and unlock operations there 4 6
instructions in ~Collector() (varies by run) 3,975 to 4,260 3,938 to 4,201
lock and unlock operations there 18 18
syscalls and allocations in both 0, 0 0, 0
  • The join is the one ~Collector did. It now happens before the finalizers, not after them.
  • .text of jsc: 48,104,087 to 48,104,439 bytes (+352) in the LTO lane (main 7961839 and the preview build of this branch). Without LTO: 43,813,544 to 43,813,864 (+320). Heap::shutDown() 527 to 585 bytes, ~Collector() 1,208 to 1,394, new Collector::shutDown() 28, stopCollectingContinuously() 376 to 386.
  • Hand-off, poll and timer paths (objdump, addresses removed): Collector::runNotRunningPhase and JSRunLoopTimer::Manager::scheduleTimer have no differing instruction. Heap::stopTheMutator differs in five __LINE__ constants of its RELEASE_ASSERTs.
  • 100 agent VMs made and destroyed in sequence, 12 alternating rounds: 0.604 s (0.551 to 0.737) on main, 0.611 s (0.553 to 0.679) with the change. With --collectContinuously=1: 1.308 s (1.110 to 1.451) and 1.248 s (1.081 to 1.505).

Designs that were weighed and set aside:

  • Collector::m_threadLock held across the compare-exchange and scheduleSoon(). Every Leave is applied under that lock, so it also covers --useGlobalGC. A prototype had 0 crashes of 240 with the sleep. It adds a lock pair to each hand-off of a live VM and a lock order from m_threadLock into the timer locks.
  • A Ref to the timer taken before the compare-exchange, and JSRunLoopTimer::Manager::scheduleTimer returning for an unregistered VM. Not built. The late call still happens, and the assert at JSRunLoopTimer.cpp:150 is gone for every other timer.

A test-only option that sleeps in the hand-off would make a regression test that fails every time on main. It was left out: it is a load and a branch on every hand-off, in code that upstream owns.

Not fixed, not changed

https://bugs.webkit.org/show_bug.cgi?id=328418
rdar://190202961

Reviewed by Marcus Plutowski.

Heap::shutDown() waits for the Heap's Leave request to be processed by the Collector.
However, this can happen before the collector thread has returned from
m_stopIfNecessaryTimer->scheduleSoon():

1. The collector thread is conducting a collection and calls Heap::stopTheMutator().
   The mutator holds heap access, so instead of stopping it, the collector thread hands
   it the conn by setting mutatorHasConnBit.
2. The mutator thread, inside waitForCollector(), sees it has the conn, finishes the
   collection and processes its own Leave request.
3. The collector thread hasn't yet returned from m_stopIfNecessaryTimer->scheduleSoon(),
   which uses Heap state.
4. The mutator thread continues through ~VM, which unregisters the VM from
   JSRunLoopTimer::Manager. ~Heap then destroys m_stopIfNecessaryTimer before
   m_localCollector, so ~Collector joins the collector thread too late.
5. The collector thread accesses the freed m_stopIfNecessaryTimer, or hits the
   RELEASE_ASSERT in JSRunLoopTimer::Manager::scheduleTimer() since the VM has unregistered.

Before 323479@main, Heap::shutDown() joined the collector thread before tearing
anything down. Restore that: Collector::shutDown() stops the collect-continuously
thread and the collector thread, and Heap::shutDown() calls it for a local
Collector right after leaving. ~Collector now asserts that the threads are already
stopped instead of stopping them itself.

This will need to be fixed for the --useGlobalGC=true case too, but that depends on
how stopping the mutators of several heaps will work, so it will come later. For now,
this fixes the default --useGlobalGC=false case.

Test: JSTests/stress/many-agents-teardown.js

* JSTests/stress/many-agents-teardown.js: Added.
(i.setTimeout):
* Source/JavaScriptCore/heap/Collector.cpp:
(JSC::Collector::~Collector):
(JSC::Collector::shutDown):
(JSC::Collector::stopCollectingContinuously):
* Source/JavaScriptCore/heap/Collector.h:
* Source/JavaScriptCore/heap/Heap.cpp:
(JSC::Heap::shutDown):

Canonical link: https://commits.webkit.org/324477@main
@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it (release jsc, Linux x64, 12 cores)

cd JSTests/stress
jsc <the options of the mode dfg-eager-no-cjit-validate> waitasync-wait-infinity-notify-half-multi-workers.js
  • 36 copies at once, with bin/jsc of the lane bun-webkit-linux-amd64-lto: 27 of 10,800 runs exit 134 for main 7961839, 0 of 10,800 for the preview build of this branch. One copy alone: 0 of 10.
  • Each core has JSRunLoopTimer.cpp:150 <- Heap::stopTheMutator() on a collector thread, and the thread of that agent in ~VM.
  • With a 30 ms sleep after the hand-off in Heap::stopTheMutator() (three lines that are not in this change), one VM is enough. This crashes 44 of 600 runs on main and 0 of 600 with this change:
jsc --destroy-vm --collectContinuously=true --collectContinuouslyPeriodMS=0.05 -e 'var e=Date.now()+3; while(Date.now()<e){}'

The full option list and every number are in the Notes of the description.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@github-actions

Copy link
Copy Markdown

Preview build of ea3d81d: autobuild-preview-pr-882-ea3d81da

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: dd1d7c3e-6702-4180-b784-6ea5e9bc6671
📥 Commits

Reviewing files that changed from the base of the PR and between 7961839 and ea3d81d.

📒 Files selected for processing: 4
  • JSTests/stress/many-agents-teardown.js
  • Source/JavaScriptCore/heap/Collector.cpp
  • Source/JavaScriptCore/heap/Collector.h
  • Source/JavaScriptCore/heap/Heap.cpp

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Walkthrough

The change adds explicit collector shutdown handling to heap shutdown and checks collector state during destruction. It also adds a stress test that starts agents and schedules timers during continuous collection.

Changes

Collector teardown

Layer / File(s) Summary
Collector shutdown and stress coverage
Source/JavaScriptCore/heap/Collector.h, Source/JavaScriptCore/heap/Heap.cpp, Source/JavaScriptCore/heap/Collector.cpp, JSTests/stress/many-agents-teardown.js
Heap::shutDown() now shuts down its local collector. The collector destructor checks that its heap set is empty and its threads have stopped. stopCollectingContinuously() clears its thread reference after completion. The stress test starts root and leaf agents during continuous collection; each leaf schedules timers.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to ea3d8

No actionable issue is established before merge. The collector-ownership question remains unverified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the primary change: stopping a heap's local Collector threads during heap shutdown. It is specific and related to the changeset.
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.
Description check Passed The pull request description is complete and relevant. It identifies the Bugzilla issue, explains the race and fix, lists the affected files and test, documents validation results, and notes known lim…
Full details: Description check

Explanation

The pull request description is complete and relevant. It identifies the Bugzilla issue, explains the race and fix, lists the affected files and test, documents validation results, and notes known limitations. It does not use the exact template line "Reviewed by NOBODY (OOPS!)", but this non-critical omission does not prevent review.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants