Skip to content

wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated - #372

Open
robobun wants to merge 3 commits into
mainfrom
farm/002e8c8f/wasm-loop-vm-traps
Open

wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated#372
robobun wants to merge 3 commits into
mainfrom
farm/002e8c8f/wasm-loop-vm-traps

Conversation

@robobun

@robobun robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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

Cause

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.

Fix

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.

Repro

import { Worker } from 'node:worker_threads';
// (module (func (export "spin") (loop (br 0))))
const bytes = new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,7,8,1,4,115,112,105,110,0,0,10,9,1,7,0,3,64,12,0,11,11]);
const w = new Worker(`
  const { parentPort } = require('worker_threads');
  const inst = new WebAssembly.Instance(new WebAssembly.Module(Buffer.from(${JSON.stringify([...bytes])})));
  parentPort.postMessage('go');
  inst.exports.spin();
`, { eval: true });
await new Promise(r => w.on('message', r));
await new Promise(r => setTimeout(r, 300));
console.log(await Promise.race([
  w.terminate().then(c => `terminated(code ${c})`),
  new Promise(r => setTimeout(() => r('HUNG >15s'), 15000)),
]));
process.exit(0);

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 onto main.

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

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5027ee03-9a0d-41a8-8a1f-c36b24d758d5

📥 Commits

Reviewing files that changed from the base of the PR and between 34c01d1 and bff0b81.

📒 Files selected for processing (10)
  • Source/JavaScriptCore/llint/InPlaceInterpreter.asm
  • Source/JavaScriptCore/llint/InPlaceInterpreter64.asm
  • Source/JavaScriptCore/runtime/StackManager.h
  • Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
  • Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp
  • Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.h
  • Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
  • Source/JavaScriptCore/wasm/WasmOperations.cpp
  • Source/JavaScriptCore/wasm/WasmOperations.h
  • Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.h

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

…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.
Comment thread Source/JavaScriptCore/llint/InPlaceInterpreter.asm
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
bff0b81e autobuild-preview-pr-372-bff0b81e 2026-07-29 13:13:41 UTC
3cb341c2 autobuild-preview-pr-372-3cb341c2 2026-07-29 12:05:28 UTC

…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).

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

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 _loop poll: confirmed the added bpaeq+jmp mirrors the checkStackOverflow pattern and the out-of-slot trampoline restores PC/MC before advancing.
  • ipint_extern_handle_vm_traps_at_loop / operationWasmHandleTrapsAtLoop: match the existing check_stack_and_vm_traps trap-servicing shape.
  • OMG: the slow-path patchpoint is now confined to a Rare block so the hot path carries no clobbers; exitsSideways + reads=top looks 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 added bpaeq + jmp must not overflow the _loop slot on any target. The out-of-slot op(...) trampoline sidesteps this, but the in-slot delta still needs a size check per architecture.
  • BBQ: the late-path uses jit.probe to preserve live state, then reads nonPreservedNonArgumentGPR0 (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 of macroClobberedGPRs + nonPreservedNonArgumentGPR0) must be sufficient for the probe + emitExceptionCheck sequence. The generator lambda captures this (the OMGIRGenerator), 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.

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