Repository navigation
Fix cross-thread WeakPtr destruction in AbortSignal.any()'s GC reachability callback - #32785
Conversation
JSAbortSignalOwner::isReachableFromOpaqueRoots() runs on JSC's parallel marker threads. For a dependent (AbortSignal.any) signal with an abort listener it probed the source set with WeakListHashSet::isEmptyIgnoringNullReferences(), which clear()s the set once every source signal has died. That destroys WeakPtrs whose impls use a single-threaded refcount, so assert-enabled builds crash with "m_creationThread == currentThreadID()" and release builds race the JS thread (node frees and WeakPtrImpl derefs off-thread). Replace the probe with a read-only one (begin() != end()); pruning keeps happening on the JS thread via the container's amortized cleanup.
|
Updated 4:40 PM PT - Jun 26th, 2026
❌ @robobun, your commit 5d86042 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 32785That installs a local version of the PR into your bun-32785 --bun |
|
Status: reproduced on main with a debug build ( CI on the merged head (build 65144): nothing red involves this change or its test.
These need a job retry or a main-side fix rather than changes here. |
|
Warning Review limit reached
More reviews will be available in 3 minutes and 2 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the 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 credits. 🚦 How do rate 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 see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughAbortSignal now exposes a GC-thread probe for alive source signals, and the opaque-root reachability check uses it. A regression test exercises AbortSignal.any() dependent signals under repeated parallel GC after their sources are collected. ChangesAbortSignal GC reachability
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Checked both issues before adding The crash fixed here needs a specific trigger: a dependent signal created by
If #27312's suite does use |
There was a problem hiding this comment.
Thanks for condensing the test comment. I didn't find any correctness issues, but this is a concurrency fix in a GC marker-thread callback whose safety hinges on WeakListHashSet::begin() being truly read-only under parallel marking — worth a quick look from someone who knows the WTF/JSC weak-container internals.
Extended reasoning...
Overview
Three files touched: a new const helper AbortSignal::hasAliveSourceSignals() in AbortSignal.h, a one-line swap in JSAbortSignalOwner::isReachableFromOpaqueRoots() (JSAbortSignalCustom.cpp) to call it instead of sourceSignals().isEmptyIgnoringNullReferences(), and a new regression test in abort-controller-gc-reason.test.ts that forces parallel GC markers via BUN_JSC_numberOfGCMarkers=8. The boolean semantics are preserved (!isEmpty… ↔ begin() != end()), and the only behavioral change is that the marker-thread probe no longer prunes dead WeakPtr entries.
Security risks
None in the conventional sense (no auth/input handling). The change removes a cross-thread data race / use-after-free vector rather than introducing one. The residual question is whether the replacement probe is itself fully safe on a non-owning thread — i.e., that WeakListHashSet's const iterator skip-dead path only reads the impl pointer and never touches the single-threaded refcount or amortized-cleanup counters. The PR description asserts this and it matches my understanding of WTF's weak iterators, but I couldn't verify against the vendored WTF header from this repo.
Level of scrutiny
High. The diff is tiny, but isReachableFromOpaqueRoots runs on JSC's parallel HeapHelper threads during weak-handle visiting, and the correctness argument depends on internal guarantees of WTF::WeakListHashSet iteration. Concurrent-GC liveness callbacks are exactly the kind of code where a subtle misread of container semantics produces silent corruption only in release builds. This deserves sign-off from someone familiar with WebCore/WTF weak containers, not bot auto-approval.
Other factors
- The bug-hunting pass found no issues.
- My only prior feedback (a style nit on the 5-line test comment) was addressed in c34c700 and the thread is resolved.
- The PR includes a targeted regression test that the author reports fails on unfixed source and passes with the fix; CI is still building.
- No CODEOWNERS entry covers these paths.
- The author flags an expected trivial conflict with #32777 in the same function — whoever merges should be aware.
|
On the one open question (is the replacement probe actually read-only on a marker thread): I traced it through the WTF headers before making the change. References are to What
What it cannot reach:
Reading One pre-existing exposure is unchanged by this PR: a marker thread can read the set while the JS thread structurally mutates it ( |
|
@robobun fix merge conflict |
|
Merged main and resolved the conflict in 5d86042. #32777 rewrote JSAbortSignalOwner::isReachableFromOpaqueRoots, so I kept its structure (the !aborted() wrapper and the opaque-root fallthrough) and applied this PR's change inside the isDependent() block: the GC-thread probe is now hasAliveSourceSignals() instead of sourceSignals().isEmptyIgnoringNullReferences(). The test file auto-merged with both PRs' tests. All 5 tests in test/js/web/abort/abort-controller-gc-reason.test.ts pass on the merged debug build, including #32777's new one. |
Symptom
AbortSignal.any()under GC pressure aborts debug/ASAN builds on a JSC parallel marker thread ("HeapHelper"), several threads at once:ASAN-symbolized stack of the aborting helper thread:
Repro (crashes in about a second on a debug or ASAN build of main; release builds hit the same code path but have no assertion, so the race is silent there):
Cause
JSAbortSignalOwner::isReachableFromOpaqueRoots()runs on JSC's parallel marker threads. For a dependent signal with an abort listener it calledsourceSignals().isEmptyIgnoringNullReferences(). The source set is aWTF::WeakListHashSet, and that method is not read-only: when every entry is dead itconst_casts andclear()s the set.Running that on a marker thread destroys
WeakPtrs off their owning thread.WeakPtrImplWithEventTargetDatauses a non-atomic, thread-asserted refcount (SingleThreadIntegralWrapper), so assert-enabled builds crash, and release builds get an unsynchronized cross-thread deref that candeleteimpl objects (which also host the signal'sEventTargetData) and free hash-table nodes while the JS thread is still using them.(
WeakHashSet::isEmptyIgnoringNullReferences()is read-only; theWeakListHashSetflavor used here is the one that prunes, which is easy to miss at the call site.)Fix
AbortSignal::hasAliveSourceSignals(): const, read-only emptiness probe (begin() != end()skips dead entries without destroying them).isReachableFromOpaqueRoots()uses it instead ofisEmptyIgnoringNullReferences().The liveness rule is unchanged: a dependent signal with an abort listener stays alive while any source signal is alive. Dead entries are still pruned on the JS thread by the container's amortized cleanup on add/remove and by
markAborted()'sclear(). No other GC visitor insrc/jsc/bindingstouches a weak container (greppedisReachableFromOpaqueRoots/visitAdditionalChildrenimplementations).Verification
New test in
test/js/web/abort/abort-controller-gc-reason.test.tsspawns the repro withBUN_JSC_numberOfGCMarkers=8(one-shotbun -edefaults to a single marker, which hides the bug by running the weak-handle visit on the JS thread).Without the fix (
bun bd test, debug+ASAN):With the fix, all 4 tests in the file pass (new test 3/3 runs), and the larger repro above runs to completion.
Note: #32777 (a different AbortSignal GC bug, wrapper liveness once aborted) edits the same function, so whichever lands second needs a trivial rebase.