wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated - #372
wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated#372robobun wants to merge 3 commits into
Conversation
…nated
A Worker running a WebAssembly function whose body is a tight loop with no
JS re-entry (e.g. (loop (br 0))) could not be preempted by
VM::notifyNeedTermination(): the terminate() promise never settled and the
thread spun at 100% CPU until the process died. JS for(;;){} and Wasm
loops that call an imported JS function per iteration were already
preemptible because they reach a JS-side trap check.
notifyNeedTermination() fires VMTraps::NeedTermination, which calls
StackManager::requestStop() to set m_trapAwareSoftStackLimit on every
registered Mirror (including each JSWebAssemblyInstance's m_stackMirror) to
UINTPTR_MAX. The only Wasm-side consumer of that field was the IPInt
function prologue (InPlaceInterpreter.asm checkStackOverflow); BBQ/OMG
prologues read the non-trap-aware m_softStackLimit, and no tier reads it at
loop back-edges. The VMTraps SignalSender path only patches JS DFG/FTL
CodeBlocks (tryInstallTrapBreakpoints returns early on a Wasm PC), so a
pure-Wasm loop never observed the termination request.
Add a trap-aware stack-limit poll at each Wasm loop head:
- IPInt (_loop op): one bpbeq against Mirror::m_trapAwareSoftStackLimit in
the dispatch slot; an out-of-slot trampoline ipint_loop_check_vm_traps
services the trap via a new ipint_extern_handle_vm_traps_at_loop slow
path (handleTrapsIfNeeded() then IPINT_THROW(Termination) or resume).
- BBQ (addLoop): branchPtr against offsetOfTrapAwareSoftStackLimit(); the
late path runs handleTrapsIfNeeded under jit.probe so all live state is
preserved, resumes on a non-termination async trap (NeedStopTheWorld /
NeedWatchdogCheck / NeedDebuggerBreak), and emitThrowException(Termination)
otherwise.
- OMG (addLoop): a B3 PatchpointValue with exitsSideways emits the same
branch + probe + late-path throw/resume, declaring macroClobberedGPRs +
nonPreservedNonArgumentGPR0.
Expose Mirror::offsetOfTrapAwareSoftStackLimit() and
JSWebAssemblyInstance::offsetOfTrapAwareSoftStackLimit(); add
operationWasmHandleTrapsAtLoop (Probe::Context&) for the JIT tiers.
Hot-path cost is one load + one predicted-not-taken branch per back-edge,
matching the JS op_check_traps cost.
|
Warning Review limit reached
Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?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 reviews. How do review 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 refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (10)
Comment |
…ps poll
ARM64 cannot encode sp as Rm in SUBS (only as Rn), so
'bpbeq mem, sp, label' lowers to 'subs xzr, <scratch>, sp' which the
assembler rejects. Swap to the logically equivalent 'bpaeq sp, mem, label'
('subs xzr, sp, <scratch>; b.hs'), matching the existing 'bpa sp, mem'
pattern used elsewhere for stack-limit checks.
Preview Builds
|
…BQ; shared throw sink - OMG addLoop: lower the poll as plain B3 IR (Load + Above + Branch) into a FrequencyClass::Rare block, matching FTL compileCheckTraps. The probe patchpoint and its clobber set (macroClobberedGPRs + nonPreservedNonArgumentGPR0) are now confined to the rare block, so the hot loop body keeps its full register budget. Compare against framePointer() (B3 has no SP value; the poisoned limit is UINTPTR_MAX so fp works identically for the trigger). - BBQ addLoop: branchPtr(Below, sp, Address(...)) instead of branchPtr(Above, Address(...), sp), matching the offsetOfSoftStackLimit checks in the same file and avoiding ARM64's extra mov when sp is Rm. - BBQ late path: record the throw jump via recordJumpToThrowException so functions with multiple loops share one Termination sink in endTopLevel() instead of emitting per-loop move+jumpThunk. - InPlaceInterpreter.asm: fix ipint_loop_check_vm_traps comment (it advances past the loop opcode on resume, it doesn't re-dispatch it).
There was a problem hiding this comment.
Thanks for addressing the comment-wording nit in bff0b81. I didn't find functional issues, but this touches hot-path codegen across all three Wasm tiers (IPInt dispatch-slot sizing, BBQ late-path + probe, OMG B3 patchpoint effects/clobbers), so it warrants a human reviewer familiar with the Wasm JIT.
What was reviewed:
- IPInt
_looppoll: confirmed the addedbpaeq+jmpmirrors thecheckStackOverflowpattern and the out-of-slot trampoline restores PC/MC before advancing. ipint_extern_handle_vm_traps_at_loop/operationWasmHandleTrapsAtLoop: match the existingcheck_stack_and_vm_trapstrap-servicing shape.- OMG: the slow-path patchpoint is now confined to a Rare block so the hot path carries no clobbers;
exitsSideways+reads=toplooks right for the throw edge.
Extended reasoning...
Overview
The PR adds a trap-aware stack-limit poll at every Wasm loop head so that VM::notifyNeedTermination() (e.g. worker.terminate(), watchdog) can preempt pure-Wasm loops that never re-enter JS. It spans all three execution tiers: IPInt (offlineasm _loop opcode + new out-of-slot trampoline + C++ slow path), BBQ (addLoop late-path with jit.probe), and OMG (addLoop B3 Load/Branch into a Rare block containing a PatchpointValue that probes and either throws or resumes). Supporting changes expose Mirror::offsetOfTrapAwareSoftStackLimit() and add operationWasmHandleTrapsAtLoop(Probe::Context&).
Since my earlier inline nit, commit bff0b81 reworded the ipint_loop_check_vm_traps comment, moved the OMG clobber set into a dedicated Rare block (so the hot path is a plain B3 Load+Above+Branch with no clobbers), flipped BBQ to use sp as the left comparand, and pointed the BBQ throw edge at the shared recordJumpToThrowException sink.
Security risks
None identified. The change adds a read of an existing per-instance field and a rarely-taken slow path that calls handleTrapsIfNeeded() — the same routine already reachable from the IPInt prologue. No new attacker-controlled inputs, no auth/crypto/permission surfaces.
Level of scrutiny
High. This is hot-path JIT code generation that runs on every Wasm loop back-edge across three tiers, each with tier-specific correctness constraints:
- IPInt: fixed-size aligned dispatch slots (
alignIPInt) — the addedbpaeq+jmpmust not overflow the_loopslot on any target. The out-of-slotop(...)trampoline sidesteps this, but the in-slot delta still needs a size check per architecture. - BBQ: the late-path uses
jit.probeto preserve live state, then readsnonPreservedNonArgumentGPR0(asserted== wasmScratchGPR) after the probe restores everything else. Register discipline here interacts with BBQ's own scratch-register conventions. - OMG: B3 effects modeling (
exitsSideways,reads = top, clobber set ofmacroClobberedGPRs+nonPreservedNonArgumentGPR0) must be sufficient for the probe +emitExceptionChecksequence. The generator lambda capturesthis(theOMGIRGenerator), which relies on the generator running before the IR generator is destroyed.
Any of these being subtly wrong would produce miscompiles or register corruption that only manifest under specific register-pressure / trap-timing conditions — exactly the kind of thing that needs eyes from someone who owns these tiers.
Other factors
- The bug-hunting pass found no functional issues; the one prior finding was a comment-wording nit, now addressed.
- The PR description includes a concrete before/after repro and notes hot-path cost matches JS
op_check_traps. - No test is added in-tree; verification is via the repro script and preview builds.
- The commit message on bff0b81 references "review" feedback beyond my nit (OMG rare-block confinement, sp-as-left, shared throw sink), suggesting there is already an ongoing human review conversation that should continue.
Given the breadth (three JIT tiers), the subtlety of B3 effects/clobber declarations, and the per-back-edge performance implications, this should be signed off by a human familiar with the Wasm JIT rather than auto-approved.
A Worker running a WebAssembly function whose body is a tight loop with no JS re-entry (e.g.
(loop (br 0))) could not be preempted byVM::notifyNeedTermination(): theworker.terminate()promise never settled and the thread spun at 100% CPU until the process died. JSfor(;;){}and Wasm loops that call an imported JS function per iteration were already preemptible because they reach a JS-side trap check.Cause
notifyNeedTermination()firesVMTraps::NeedTermination, which callsStackManager::requestStop()to setm_trapAwareSoftStackLimiton every registeredMirror(including eachJSWebAssemblyInstance'sm_stackMirror) toUINTPTR_MAX. The only Wasm-side consumer of that field was the IPInt function prologue (InPlaceInterpreter.asmcheckStackOverflow); BBQ/OMG prologues read the non-trap-awarem_softStackLimit, and no tier reads it at loop back-edges. TheVMTraps::SignalSenderpath only patches JS DFG/FTL CodeBlocks (tryInstallTrapBreakpointsreturns early on a Wasm PC), so a pure-Wasm loop never observed the termination request.Fix
Add a trap-aware stack-limit poll at each Wasm loop head:
_loopop): onebpbeqagainstMirror::m_trapAwareSoftStackLimitin the dispatch slot; an out-of-slot trampolineipint_loop_check_vm_trapsservices the trap via a newipint_extern_handle_vm_traps_at_loopslow path (handleTrapsIfNeeded()thenIPINT_THROW(Termination)or resume).addLoop):branchPtragainstoffsetOfTrapAwareSoftStackLimit(); the late path runshandleTrapsIfNeededunderjit.probeso all live state is preserved, resumes on a non-termination async trap (NeedStopTheWorld / NeedWatchdogCheck / NeedDebuggerBreak), andemitThrowException(Termination)otherwise.addLoop): a B3PatchpointValuewithexitsSidewaysemits the same branch + probe + late-path throw/resume, declaringmacroClobberedGPRs+nonPreservedNonArgumentGPR0.Expose
Mirror::offsetOfTrapAwareSoftStackLimit()andJSWebAssemblyInstance::offsetOfTrapAwareSoftStackLimit(); addoperationWasmHandleTrapsAtLoop(Probe::Context&)for the JIT tiers.Hot-path cost is one load + one predicted-not-taken branch per back-edge, matching the JS
op_check_trapscost.Repro
Before:
HUNG >15s(worker thread remains Running at ~100% CPU). After:terminated(code 1)in ~140ms. Node.js terminates all three shapes (js / wasm-with-JS-call / pure-wasm) in ≤4ms.Branch is based on 5491700 (Bun's current
WEBKIT_VERSION) for a minimal-diff preview build; happy to rebase ontomain.