Skip to content

JIT: read single-character string constants without a GCOwnedDataScope on the compiler thread - #544

Open
robobun wants to merge 2 commits into
mainfrom
robobun/9d587bb9/gc-owned-data-scope-mutator-only
Open

JIT: read single-character string constants without a GCOwnedDataScope on the compiler thread#544
robobun wants to merge 2 commits into
mainfrom
robobun/9d587bb9/gc-owned-data-scope-mutator-only

Conversation

@robobun

@robobun robobun commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Debug and ASan builds can abort while the mutator is idle: ASSERTION FAILED: !m_topGCOwnedDataScope in Heap::clearConcurrentRetainedDataIfPossible (Heap.cpp:1303), from IncrementalSweeper::doSweep. Seen once in a Bun ASan CI lane. It is a rare timing race.
  • Heap::m_topGCOwnedDataScope is an unsynchronized ASSERT_ENABLED field that tracks the GCOwnedDataScope objects on the mutator's stack. The concurrent baseline JIT writes it: JIT::emit_compareImpl (JITArithmetic.cpp:247) reads a one-character string constant with JSString::tryGetValue(), which constructs a GCOwnedDataScope, on the JITWorker thread. LOLJIT::emitCompareImpl (LOLJIT.cpp:974) has the same code.

Fix

  • Both compare paths read the constant through tryGetValueImpl() and StringImpl::at(0), at once. That is how the other compiler-thread readers of string constants work (the DFG, the atom string fast paths in JITOpcodes.cpp). The emitted code is unchanged. The rope RELEASE_ASSERT stays, as a null check.
  • setTopGCOwnedDataScopeIfNeeded and clearTopGCOwnedDataScopeIfNeeded return early on a compilation thread or a GC thread. Those threads have their own guards in clearConcurrentRetainedDataIfPossible. Same thread predicate that JSString::swapToAtomString asserts. Debug-only code.
  • Verified with a local Debug+ASan jsc (Linux x64): a temporary log line for scopes built on a compilation thread fired 32000 times before the JIT change and 0 times after, on a script with 32000 s < "a" style compare sites. JSTests/stress/string-compare.js and 40 other compare tests pass with the baseline JIT, the LOL JIT, and --useJIT=false.

Background

  • GCOwnedDataScope<T> wraps data that a JSCell owns, here the String inside a JSString. Its destructor calls ensureStillAliveHere(owner). In debug builds it also maintains Heap::m_topGCOwnedDataScope.
  • When the mutator swaps a JSString to its atom, the old StringImpl goes into Heap::m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope. clearConcurrentRetainedDataIfPossible drops that list when no JS runs, no compile is in flight, and the mutator is not fenced. The assertion covers the one reader those checks miss: a scope on the mutator's stack.
  • tryGetValueImpl() returns the raw StringImpl*, or null for a rope.

Upstream

Nothing here is Bun-specific. Upstream main has the same code. The GCOwnedDataScope.cpp hunk applies there as is. Upstream has since edited the two compare lambdas (LOLJIT's takes a GPRReg), so the two JIT hunks need a small re-resolution at the next upgrade merge.

Notes
  • Release builds do not change. The field, the assertion, and the set/clear hooks exist only under ASSERT_ENABLED, and the JIT hunks emit the same code.
  • Self-reviewed: 7 concerns raised, 2 addressed (the character is now read as soon as the impl is loaded, and the body no longer says the JIT hunks apply upstream as is). The others asked for a larger change to how the string list is pruned, see the last note.
  • The race was not reproduced natively. A fuzz ledger pinned it with lldb: park the JITWorker thread right after setTopGCOwnedDataScopeIfNeeded ran for its scope, let the main thread's IncrementalSweeper timer fire, and the exact assertion fires with the stack seen in CI (clearConcurrentRetainedDataIfPossible <- IncrementalSweeper::doSweep <- JSRunLoopTimer::timerDidFire). An unaided stress loop (baseline compiles plus gc() every 350 ms) did not hit it in 6 runs of 2 to 3 minutes.
  • Probe details: the log line sat in setTopGCOwnedDataScopeIfNeeded, active when isCompilationThread(), with --thresholdForJITAfterWarmUp=10 --thresholdForJITSoon=10. The probe script gives total=14800 under every tier configuration.
  • With --useConcurrentJIT=false the log line also fired 32000 times: JITPlan::compileInThread enters a CompilationScope on the mutator too. That case has no race, and the early return only skips tracking inside the synchronous compile, where the sweeper cannot run.
  • Only these two call sites call tryGetValue() from compile code. The other callers run on the mutator (bytecode generator, runtime functions, inspector).
  • Pre-existing and out of scope: BaselineJITPlan::compileInThreadImpl runs the whole compile inside a Safepoint, and Heap::runCollectionEpilogue (Heap.cpp:2406) prunes the string list without a check for ongoing compilations. So a baseline compile that reads a non-atom constant's impl while the mutator atomizes it, and a GC runs before the read completes, reads a freed impl. The window is the few instructions between the fiber load and the read, the same as before this PR. The atom string fast paths in JITOpcodes.cpp have the same window on isAtom(). In this fork the bytecode cache can create such non-atom one-character constants (DecoderStringTable::jsStringFor, code units above 0xFF). A fix belongs in the prune: skip entries, or the whole prune, while totalOngoingCompilations() is non-zero.
  • The local jsc was linked against the ICU from the prebuilt Bun WebKit tarball. Number.prototype.toLocaleString fails in it, so the three missing-exception-check-in-string-*compare.js tests fail at their first line in that build, with and without the JIT.

…e on the compiler thread

JIT::emit_compareImpl and LOLJIT::emitCompareImpl read a one-character
string constant through JSString::tryGetValue(), which constructs a
GCOwnedDataScope. In debug builds that writes Heap::m_topGCOwnedDataScope,
an unsynchronized field that tracks the scopes on the mutator's stack.
When the mutator's IncrementalSweeper fires while the JIT thread is inside
that scope, Heap::clearConcurrentRetainedDataIfPossible hits
ASSERTION FAILED: !m_topGCOwnedDataScope (Heap.cpp:1303).

Read the constant through tryGetValueImpl() instead, like the other
compiler-thread readers of string constants do. Also make
setTopGCOwnedDataScopeIfNeeded and clearTopGCOwnedDataScopeIfNeeded
ignore compilation and GC threads, so the tracker only ever reflects the
mutator's stack.

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

I reviewed this PR and didn't find any bugs. Because it touches JIT codegen and GC-thread interaction in a CODEOWNER-covered path (/Source/JavaScriptCore), a jsc-reviewer look is still worthwhile.

What was reviewed:

  • tryGetValueImpl() + impl->at(0) is semantically equivalent to the old tryGetValue(false).data[0] path (both yield char16_t), and matches the existing compiler-thread pattern in DFGAbstractInterpreterInlines.h and JITOpcodes.cpp.
  • isOperandConstantChar guarantees length() == 1, and tryGetValueImpl() returns null only for ropes, so RELEASE_ASSERT(impl) preserves the prior rope invariant without weakening it.
  • The GCOwnedDataScope.cpp early-returns are ASSERT_ENABLED-only and only skip writes to the unsynchronized m_topGCOwnedDataScope; clearConcurrentRetainedDataIfPossible still gates on entryScope/ongoing-compilation for those threads.
Extended reasoning...

Overview

This PR touches three files: heap/GCOwnedDataScope.cpp (debug-only mutator-scope tracker), jit/JITArithmetic.cpp (Baseline JIT compare emission), and lol/LOLJIT.cpp (LOL JIT compare emission). The root cause being fixed is that the concurrent Baseline/LOL JIT called JSString::tryGetValue() on a compiler thread, which constructs a GCOwnedDataScope whose debug-build constructor/destructor write to Heap::m_topGCOwnedDataScope — an unsynchronized field intended only for the mutator. When IncrementalSweeper fired on the mutator while a JIT thread was inside such a scope, the ASSERT(!m_topGCOwnedDataScope) in clearConcurrentRetainedDataIfPossible could fail. The fix is two-layered: the JIT paths now read the constant via tryGetValueImpl() (raw StringImpl*, no scope), and the tracker functions early-return on compilation/GC threads as defense in depth.

Security risks

None identified. Release builds are unaffected: m_topGCOwnedDataScope and its assertion exist only under ASSERT_ENABLED, and the underlying StringImpl lifetime was already covered by the totalOngoingCompilations() guard in clearConcurrentRetainedDataIfPossible. The JIT change swaps one accessor for another that returns identical data (char16_t at index 0) without allocation or side effects; the RELEASE_ASSERT(impl) cannot fire because isOperandConstantChar requires a length-1 JSString, which is never a rope (small-string cache). No new untrusted input paths, no memory-safety change to generated code.

Level of scrutiny

Moderate-to-high. The diff is small (~20 net lines) and mechanically follows an established pattern already used on compiler threads elsewhere (DFGAbstractInterpreterInlines.h:1648/2322, JITOpcodes.cpp:730/749). However, it sits at the intersection of concurrent JIT compilation and GC bookkeeping — an area where subtle threading assumptions matter — and /Source/JavaScriptCore is CODEOWNER-covered by WebKit/jsc-reviewers. Per the approval guidelines, CODEOWNER coverage on a critical path means a human reviewer should sign off even when the automated review is clean.

Other factors

The PR description is unusually thorough (repro methodology, log-line verification of 32000→0 scope constructions, JSTests/stress compare tests passing), and both JIT tiers were updated symmetrically as required by the JSC CLAUDE.md convention. The disallowAllocation local is fully removed with no remaining references. Exit reason was dry_streak, so the bug hunt ran to completion with nothing found. Deferring rather than approving solely due to CODEOWNER coverage and the JIT/heap subject matter.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 37ac7d1a-3dd5-46a7-93ce-8f3ba559ca04

📥 Commits

Reviewing files that changed from the base of the PR and between 34644bc and 8d674e6.

📒 Files selected for processing (2)
  • Source/JavaScriptCore/jit/JITArithmetic.cpp
  • Source/JavaScriptCore/lol/LOLJIT.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

The changes prevent non-mutator threads from updating the GC-owned scope tracker. JIT and LOLJIT constant-character comparisons now read characters directly from StringImpl.

Changes

Compiler and runtime safety

Layer / File(s) Summary
Guard GC-owned scope tracking
Source/JavaScriptCore/heap/GCOwnedDataScope.cpp
Compilation and GC threads now return before modifying the mutator-thread scope tracker.
Read constant characters directly
Source/JavaScriptCore/jit/JITArithmetic.cpp, Source/JavaScriptCore/lol/LOLJIT.cpp
Constant-character comparisons now use StringImpl::tryGetValueImpl() and at(0) instead of the allocation-controlled tryGetValue(false) path. Existing checks, slow cases, and comparison emission remain unchanged.

Merge Risk: ⚪ Minimal · up to 8d674

This localized change preserves emitted behavior while removing compiler-thread debug-scope tracking; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the JIT change and its purpose: avoiding GCOwnedDataScope for single-character string constants on compiler threads.
Description check ✅ Passed The description is detailed, on topic, and covers the problem, fix, background, testing, scope, and known limitations. It does not include the required Bugzilla link, review-status line, or template-s…
Full details: Description check

Explanation

The description is detailed, on topic, and covers the problem, fix, background, testing, scope, and known limitations. It does not include the required Bugzilla link, review-status line, or template-style changed-file list, but the core information is substantially complete.

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.


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

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
8d674e63 autobuild-preview-pr-544-8d674e63 2026-09-01 10:58:37 UTC
34644bc4 autobuild-preview-pr-544-34644bc4 2026-08-31 23:51:00 UTC

The mutator can atomize a string constant while the baseline compile
runs, and the whole baseline compile sits inside a Safepoint, so a GC
during the compile can drop the old StringImpl from
Heap::m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope.
Read the character as soon as the impl is loaded, which keeps the window
the old tryGetValue(false).data[0] expression had, instead of holding the
raw pointer across the emission calls.

@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 found no issues

No high-confidence issues detected in this change.

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