Skip to content

speculationFromCell: reject misaligned StringImpl* from concurrent zap residue#285

Open
robobun wants to merge 1 commit into
mainfrom
robobun/speculationfromcell-zap-alignment
Open

speculationFromCell: reject misaligned StringImpl* from concurrent zap residue#285
robobun wants to merge 1 commit into
mainfrom
robobun/speculationfromcell-zap-alignment

Conversation

@robobun

@robobun robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Crash

Seen in Bun CI on Windows arm64 (build #72279), segfault at 0x21700000012:

JSC::speculationFromCell (SpeculatedType.cpp:604, inlined)
JSC::speculationFromValue (SpeculatedType.cpp:640)
JSC::ValueProfileBase<1,1>::computeUpdatedPrediction (ValueProfile.h:132)
JSC::DFG::PredictionInjectionPhase::run (DFGPredictionInjectionPhase.cpp:59)
JSC::DFG::Plan::compileInThreadImpl
JSC::JITWorklistThread::work

Faulting instruction: ldrb w8, [x9, #0x10] reading StringImpl::m_hashAndFlags for impl->isAtom(), with x9 = 0x21700000002 loaded from JSString::m_fiber at [cell+8].

Cause

0x0000021700000002 is HeapCell::zap(StopAllocating) residue:

// HeapCell.h
cellWords[0] = 0;          // bytes 0-3  (structureID cleared)
// cellWords[1] preserved  // bytes 4-7  (keeps m_type = StringType at byte 5)
cellWords[2] = reason;     // bytes 8-11 (StopAllocating == 2)
// bytes 12-15 unchanged   // upper half of the previous StringImpl*

So bytes 8-15 as u64 LE = (0x00000217 << 32) | 0x00000002.

The race, on arm64:

  1. GC stopAllocating() zaps free cells in stringSpace (JSString has needsDestruction).
  2. Main thread allocates a JSString from a zapped slot, stores m_fiber = StringImpl*, passes it as an argument to a JS call.
  3. Baseline prologue stores the cell into the argument ValueProfile bucket (plain store).
  4. DFG compiler thread reads the bucket (decodeConcurrent = plain load on JSVALUE64) and then cell->m_fiber (fiberConcurrently() = plain load).
  5. mutatorFence() is a no-op outside concurrent marking, so there is no release fence between step 2 and step 3. arm64 can reorder the two stores; the DFG thread observes the cell pointer before the m_fiber store and reads zap residue.
  6. cell->isString() passes because byte 5 is preserved through zap. isSanePointer(0x21700000002) passes (below 48 bits, above lowestAccessibleAddress). tryGetValueImpl() sees bit 0 clear so returns it as a StringImpl*. impl->isAtom() dereferences [impl + 0x10] and segfaults.

Not reproducible locally (0/120 runs with the exact CI binary and environment), not seen on recent main builds.

Fix

StringImpl is at least 8-byte aligned (it has a pointer member). The zap residue has ZapReason (1 or 2) in the low word, so a misaligned impl distinguishes it from any real pointer. ZapReason::Destruction (1) is already handled (bit 0 = isRopeInPointer, tryGetValueImpl() returns null); this change catches StopAllocating (2).

This extends the existing rdar://69036888 band-aid rather than adding fences to the hot path.

…p residue

On weak-memory-order architectures (observed on Windows arm64 in Bun CI),
the DFG compiler thread can read a JSString from a ValueProfile bucket
before the main thread's m_fiber store becomes visible. With no release
fence between construction and the bucket store (mutatorFence() is a no-op
outside concurrent marking), the compiler thread observes the slot's
previous bytes: HeapCell::zap(StopAllocating) residue.

zap() writes cellWords[2] = ZapReason (= 2 for StopAllocating) and leaves
cellWords[3] intact, so m_fiber reads as {low32=2, high32=prev-hi-bits},
e.g. 0x0000021700000002. isSanePointer() passes (below 48 bits, above
page 0), tryGetValueImpl() sees bit 0 clear so returns it as a StringImpl*,
and impl->isAtom() segfaults at [impl + 0x10].

StringImpl is at least 8-byte aligned (pointer member), so rejecting
misaligned impl pointers catches the zap residue without touching real
values. Extends the existing rdar://69036888 band-aid to cover the case
isSanePointer lets through.
@coderabbitai

coderabbitai Bot commented Jul 13, 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: Pro

Run ID: a708c541-b8a4-437c-9cc0-55664dfbd1a3

📥 Commits

Reviewing files that changed from the base of the PR and between 234d8b3 and f90e282.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/bytecode/SpeculatedType.cpp

Walkthrough

The JSString handling in speculationFromCell now requires an available StringImpl pointer to be both sane and aligned to alignof(StringImpl), returning SpecNone when validation fails.

Changes

String pointer validation

Layer / File(s) Summary
Tighten StringImpl validation
Source/JavaScriptCore/bytecode/SpeculatedType.cpp
speculationFromCell adds an alignment check alongside the existing pointer sanity check and returns SpecNone after ASSERT_NOT_REACHED() when either check fails.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the crash, root cause, and fix, but it doesn't follow the required template or include a Bugzilla bug link. Add the Bugzilla bug reference and format the description as the repository template, including bug title, explanation, and changed file list.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the core change: rejecting misaligned StringImpl* values from concurrent zap residue.
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.

@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 didn't find any bugs — the added alignment check is strictly defensive and cannot reject a valid StringImpl*. Deferring to a human because this is core JIT speculation code and the underlying arm64 memory-ordering analysis (and the band-aid-vs-fence tradeoff) is worth a second pair of eyes.

What was reviewed:

  • Verified alignof(StringImpl) >= 8 (inherits StringImplShape with pointer members), so real pointers always pass.
  • Confirmed ZapReason::StopAllocating == 2 sets bit 1, caught by the mask; Destruction == 1 already handled via isRopeInPointer.
  • Fallback to SpecNone is the existing conservative path; ASSERT_NOT_REACHED matches the pre-existing isSanePointer pattern.
Extended reasoning...

Overview

Single-line change to speculationFromCell in Source/JavaScriptCore/bytecode/SpeculatedType.cpp: adds || (reinterpret_cast<uintptr_t>(impl) & (alignof(StringImpl) - 1)) to the existing isSanePointer guard before dereferencing a concurrently-read StringImpl* from JSString::m_fiber.

Security risks

None introduced. The change only widens a rejection condition on a pointer read from a racy profiling bucket. A valid heap-allocated StringImpl is always at least pointer-aligned (it derives from StringImplShape, which holds a const char*/const char16_t* union), so no legitimate pointer is rejected. Rejecting misaligned garbage that would otherwise be dereferenced is strictly a hardening improvement.

Level of scrutiny

High. This is DFG type speculation — a hot, correctness-critical path in the JIT — and the rationale rests on a detailed arm64 weak-memory-ordering race between the mutator, baseline JIT prologue, and the DFG compiler thread. The code change itself is trivially safe (purely additive guard, conservative fallback), but the root-cause analysis and the decision to extend the rdar://69036888 band-aid rather than add a release fence are architectural judgments a JSC maintainer should sign off on.

Other factors

  • The crash was seen once in CI and is not locally reproducible, so there is no test.
  • ASSERT_NOT_REACHED() will still fire in debug builds when this race occurs, consistent with the existing isSanePointer branch; that is pre-existing behavior, not a regression.
  • No prior reviews or comments on the PR.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
f90e2824 autobuild-preview-pr-285-f90e2824 2026-07-13 01:24:22 UTC

webkit-commit-queue pushed a commit to sosukesuzuki/WebKit that referenced this pull request Jul 14, 2026
…ointers

https://bugs.webkit.org/show_bug.cgi?id=319215

Reviewed by Yusuke Suzuki.

A sane pointer to a T must be aligned like a T. Add a typed overload that
checks this on top of the existing canonical-address check. The void*
overload keeps its old behavior for callers probing arbitrary addresses
(e.g. the disassembler); all typed call sites pick up the stricter check
without change.

This catches the value seen in a real crash: a concurrent DFG thread read
HeapCell::zap() residue (0x21700000002) out of a freshly reused JSString and
dereferenced it as a StringImpl*, because it passed every existing check in
speculationFromCell() (oven-sh#285). The
reordering race itself is fixed in bug 319213; this hardens the checks that
bug 216638 added as a mitigation for bad pointers from profiling data.

* Source/JavaScriptCore/tools/Integrity.h:
(JSC::Integrity::isSanePointer):

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

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another occurrence in Bun CI, Windows arm64, build 74427 on test/js/node/test/sequential/test-net-listen-shared-ports.js: segfault at 0x13800000012.

Disassembly at the faulting IP (0x1416b2300 in bun-profile.exe c3e51b523):

1416b22d0: ldr   x9, [x10, #0x8]   ; m_fiber
...
1416b2300: ldrb  w8, [x9, #0x10]   ; impl->m_hashAndFlags for isAtom()

x9 = 0x13800000002, low word = 2 = ZapReason::StopAllocating, same pattern as the original report. 1 hit in ~1500 recent builds; 0/450 local runs with the exact CI binary.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Two more sightings of this on Bun CI's Windows arm64 lane this week, on unrelated PRs:

  • build #75698: test-http-proxy-request-no-proxy-ip.mjs, fault at 0x21B00000012
  • build #75686: test-http-set-global-proxy-from-env-override-http.mjs, fault at 0x28000000012

Both symbolicate to the same stack (speculationFromValueValueProfileBase<1,1>::computeUpdatedPredictionDFG::PredictionInjectionPhase::run on a JIT worklist thread), and neither reproduces locally (0/200 against the current main canary).

Sentry shows this is the same crash users are hitting in production. Across the four grouped issues (BUN-2V2R, BUN-2X1W, BUN-3JDB, BUN-3K59) there are ~1,200 events since April, effectively 100% on aarch64 (predominantly macOS, also Linux and Windows). The fault address distribution is dominated by 0x100000012 with a tail of 0x{NNN}00000012, i.e. impl + offsetof(StringImpl, m_hashAndFlags) where impl's low 32 bits are ZapReason::StopAllocating, matching the analysis above.

The branch is 12 commits behind oven-sh/WebKit main now (bun currently pins 639550acdcb2); a rebase would let this land via the next WebKit bump.

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.

1 participant