[3] B3 `LowerToAir` CAS miscompilation — atomic sunk past interfering effects
The canvas save stack hit its cap, and the cap stopped being a resource limit.
High. The lowering sinks a compare-and-swap to its branch consumer using an eligibility test that only checks single-use, never what executes in between — so a CAS can move past memory.grow or a call. The correctness defect stands on the missing interference check alone; the backing-store-reallocation escalation is the inferred-but-consistent downstream.
B3's LowerToAir is the pattern-matching phase that lowers B3 IR into Air, the machine-level IR, for both the FTL JIT and the WebAssembly OMG optimizing tier. The moving parts: a CAS value whose sole user is a branch; canBeInternal, the predicate that asks only whether a value has one use and is not yet materialized; and crossesInterference, the companion predicate that tests a candidate's Effects against every value in between via Effects::interferes. The invariant: a heap-reading-and-writing operation may only be moved to its consumer's position if nothing with conflicting effects executes in the gap.
The angle: WebAssembly atomic memory instructions compiled by OMG can have their CAS sunk past a memory.grow that reallocates and frees the backing store in BoundsChecking mode, so the atomic reads — and conditionally writes — through a stale pointer into memory the engine already released.
Commit message: LowerToAir emitted the CAS at the consumer's position guarded only by canBeInternal, omitting crossesInterference at all five CAS sites. The fix adds !crossesInterference(...) at each.
Source/JavaScriptCore/b3/B3LowerToAir.cpp
Patch Details
The diff adds !crossesInterference(...) as a conjunct to the sinking eligibility at five CAS lowering sites. The case AtomicWeakCAS: arm of the branch switch previously had no condition of its own — it fell straight into commitInternal(branchChild)/appendCAS(branchChild, false), relying on whatever precondition the enclosing case Branch: handler applies. The sibling Equal/NotEqual arm applied canBeInternal to branchChild->child(0) rather than branchChild, which suggests an enclosing canBeInternal(branchChild) gate on the switch at the case Branch: handler. What the arm demonstrably lacked, per the diff, is the interference check.
Downward code motion of a heap read-modify-write gated on single-use only, with no check that interfering effects sit between the value and its consumer.
Background
B3, Air, and OMG. B3 is JSC's SSA optimizing IR; Air is the lower machine-level IR. LowerToAir selects instructions from B3 into Air for the FTL JIT and the WebAssembly OMG tier. OMG is the top Wasm tier, so Wasm atomic instructions reach this lowering.
Internalization. canBeInternal reports whether a value has a single use and has not already been materialized — the precondition for folding it into its consumer. It says nothing about intervening instructions.
crossesInterference and Effects. crossesInterference tests a candidate's Effects against every value between it and the consumer via Effects::interferes. A call carries Effects::forCall() — writes/reads at HeapRange::top() plus writesPinned/readsPinned (per B3Effects.h). This is the predicate the rest of the file pairs with canBeInternal for heap-touching motion.
BoundsChecking memory mode. With --useWasmFaultSignalHandler=false, Wasm uses MemoryMode::BoundsChecking. Growing a memory with no pre-reserved headroom reallocates the backing store and releases the old region.
Analysis
This is a JIT miscompilation: an illegal instruction-scheduling transform producing a stale-pointer (use-after-free class) atomic access.
B3 order: After illegal sink:
CAS @ addr(base0) ... (CAS not yet emitted)
memory.grow ───────► reallocates store, frees region(base0)
Branch(cas) Branch: emit CAS @ addr(base0) ← stale
Only the CAS instruction is relocated; its operands are expected to have been materialized into Air Tmps at their original positions, so for a Wasm atomic access the address operand derives from the memory base as it stood before the motion. If a memory.grow — or any call — executes between the CAS's original position and the branch, that operand no longer describes live memory.
Only the CAS instruction is relocated; its address operand was materialized into an Air Tmp at the pre-grow position, so it still describes the old memory base. In MemoryMode::BoundsChecking a module with no declared maximum reserves no headroom (reservedMaximumBytes == 0), so growing it must obtain a new region and release the old one. Neither detail is load-bearing for the defect: the same missing precondition permits sinking an atomic read-modify-write past an arbitrary call, destroying its ordering with respect to whatever that call wrote — exactly what crossesInterference exists to prevent.
The correctness defect does not depend on either inference: the same missing precondition also permits sinking an atomic read-modify-write past an arbitrary call, destroying the CAS's ordering with respect to whatever that call wrote — exactly what crossesInterference exists to prevent. In BoundsChecking mode the sunk CAS reads and conditionally writes through a stale pointer into reclaimed memory; weaponizing beyond the stale access would require controlling reallocation layout and reclaiming the freed region, a projected direction.
Audit directions
canBeInternalused alone to justify moving a heap-effecting value. Any lowering site that sinks a load, store, or atomic to its consumer must paircanBeInternalwithcrossesInterference. Audit the remainingappendCAS/atomic lowering arms and anycommitInternalon a value with non-emptyEffectsinB3LowerToAir.cpp. In code review, acanBeInternal(x)guard on a memory-touching value with no adjacentcrossesInterference(x, ...)is the visual tell.- Switch arms that inherit their guard from an enclosing case. The
AtomicWeakCASarm relied entirely on the enclosingBranchgate; arms with no local precondition on an effecting value are fragile under refactors — flag any atomic arm that falls through to emission without its own interference test.