Skip to content

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

Open
robobun wants to merge 1 commit 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 1 commit 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.

@coderabbitai

coderabbitai Bot commented Jul 29, 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: 0357c61b-3d10-4ba4-9759-474c6b4d824a

📥 Commits

Reviewing files that changed from the base of the PR and between 78d45d3 and a50d1e5.

📒 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

Walkthrough

The change adds VM-trap polling at WebAssembly loop back-edges for IPInt, BBQJIT, and OMGJIT. Trap handling resumes execution when possible and propagates termination through existing exception paths.

Changes

WebAssembly loop VM-trap handling

Layer / File(s) Summary
Trap-aware stack limit contracts
Source/JavaScriptCore/runtime/StackManager.h, Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.h, Source/JavaScriptCore/wasm/WasmOperations.h
The stack mirror exposes the trap-aware soft stack limit offset. The JIT trap-handling operation is declared.
IPInt loop polling
Source/JavaScriptCore/llint/InPlaceInterpreter.asm, Source/JavaScriptCore/llint/InPlaceInterpreter64.asm, Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.h, Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp
IPInt polls the trap-aware soft stack limit at loop back-edges. The slow path handles pending traps, resumes execution, or unwinds termination.
JIT loop polling and termination
Source/JavaScriptCore/wasm/WasmOperations.cpp, Source/JavaScriptCore/wasm/WasmBBQJIT.cpp, Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
BBQJIT and OMGJIT emit loop polls. The trap operation handles pending traps and records termination exceptions before loop resumption or unwinding.

Suggested reviewers: constellation, kmiller68

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, cause, fix, reproduction, and results, but it omits the required Bugzilla link, review line, and template-style changed-file list. Add the Bugzilla issue link, “Reviewed by NOBODY (OOPS!).” line, and a template-style list of changed paths and functions.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: polling VM traps at WebAssembly loop back-edges to enable termination.
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.

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
a50d1e50 autobuild-preview-pr-372-a50d1e50 2026-08-09 14:27:42 UTC
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

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

…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
worker.terminate(): VM::notifyNeedTermination() poisons
m_trapAwareSoftStackLimit via StackManager::requestStop(), but the only
Wasm-side consumer was the IPInt function prologue, so a pure-Wasm loop
never observed the request.

Add a trap-aware stack-limit poll at each Wasm loop head:

- IPInt: compare sp against m_trapAwareSoftStackLimit after ipintLoopOSR
  (sp as first comparand: ARM64 cannot encode sp as Rm in SUBS), slow path
  ipint_extern_handle_vm_traps_at_loop services the trap and resumes or
  throws ExceptionType::Termination.
- BBQ: branchPtr(Below, sp, limit) late path runs
  operationWasmHandleTrapsAtLoop under jit.probe so all live state is
  preserved for the resume case; termination routes through
  recordJumpToThrowException so multiple loops share one sink.
- OMG: lower the poll as plain B3 IR (Load + Above + Branch) into a
  FrequencyClass::Rare block, matching FTL compileCheckTraps; fp stands in
  for sp since B3 has no SP value. The probe patchpoint's clobber set is
  confined to the rare block so the hot loop keeps its register budget.

Non-termination async traps (NeedStopTheWorld / NeedWatchdogCheck /
NeedDebuggerBreak) are serviced and the loop resumes with all live state
intact; termination unwinds via the existing Wasm exception machinery.

Rebased onto 78d45d3 (resolves drift from the upstream merge and
worker-lifetime changes).
@robobun
robobun force-pushed the farm/002e8c8f/wasm-loop-vm-traps branch from bff0b81 to a50d1e5 Compare August 9, 2026 13:47
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (78d45d3); the previous base predated the 3722912 upstream merge and the worker-lifetime changes on the Bun side. The diff applied without conflicts, squashed to one commit (a50d1e5) since the intermediate revisions were review iterations.

Validated against oven-sh/bun main (9008ae7ab) using this branch as the local WebKit (linux x64, debug+ASAN):

  • worker.terminate() on a pure-Wasm loop (loop { br 0 }): promise resolves, exit event fires, process exits. Unpatched: hangs forever at 100% CPU.
  • Nested shape: main -> worker -> worker spinning in Wasm, middle worker calls process.exit(5): exits cleanly 3/3. Unpatched: never exits. This shape got worse after the worker-lifetime rework because teardown now joins child workers, so one unkillable Wasm loop wedges the whole tree.
  • js / wasmcall / wasm terminate matrix (bun-side worker-terminate-wasm-loop.test.ts): 3/3 pass.
  • Wasm sanity: wasm-streaming.test.ts (33) and bun-build-compile-wasm.test.ts pass.
  • worker.test.ts: 33 pass, same 3 timing-sensitive failures as an unpatched debug build of the same tree (pre-existing, not from this change).

Comment on lines +4644 to +4647
Effects effects = Effects::none();
effects.reads = B3::HeapRange::top();
effects.exitsSideways = true;
handle->effects = effects;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The slow-path patchpoint's effects leave writes empty (only reads/exitsSideways are set), so B3 CSE forwards an outer loop's limit Load to an inner loop's — the inner back-edge then tests a value read once and never re-loaded, and a nested pure-Wasm loop in OMG never observes requestStop(). Add effects.writes = HeapRange::top(); (matching Effects::forCall(), which FTL's lazySlowPath() keeps by default).

Extended reasoning...

What the bug is

In OMGIRGenerator::addLoop(), the slow-path patchpoint's effects are built as:

Effects effects = Effects::none();
effects.reads = B3::HeapRange::top();
effects.exitsSideways = true;
handle->effects = effects;

This leaves effects.writes at its default HeapRange() (the empty range). The m_trapAwareSoftStackLimit load is a plain MemoryValue (no fence), so B3's eliminateCommonSubexpressions is free to forward one loop's Load to another's whenever no block on any path between them has a writes set that overlaps HeapRange::top().

The code path that triggers it

B3EliminateCommonSubexpressions.cpp:findMemoryValue() walks the CFG backward from a Load, bailing only on data.writes.overlaps(range) (line 1028). It never consults exitsSideways, fence (of other blocks), or reads. Per-block data.writes is populated at line ~349 via if (HeapRange writes = effects.writes) clobber(...); an empty HeapRange is falsy, so the slow-path block contributes nothing and does not stop the walk.

Both loads share the same ptr key: instanceValue() returns the single cached m_instanceValue, and both are Load pointerType() at offsetOfTrapAwareSoftStackLimit(), so the Load-case filter (offset + opcode + type) matches.

Why existing code doesn't prevent it

  • LICM doesn't hoist the load (hoistLoopInvariantValues bails on control-dependent loads when the loop has side exits), but that's a different pass — CSE has no such guard.
  • exitsSideways on the patchpoint prevents the patchpoint itself from being moved/eliminated, but CSE's predecessor walk for other loads only inspects data.writes.
  • FTL compileCheckTraps — which the comment cites as the model — avoids this because lazySlowPath() creates a PatchpointValue with the default constructor effects (B3PatchpointValue.cpp:54Effects::forCall()), which has writes = HeapRange::top(). That write barrier on the FTL slow-path block is exactly what makes CSE bail. The OMG code explicitly discards it.

Step-by-step proof

Take (loop $outer (loop $inner (br $inner))). addLoop produces (per loop) body → Load, Above, Branch → {slowPath (Rare), continuation}; slowPath → patchpoint(writes=∅), Jump → continuation. br 0 targets the Loop's special = body, so the inner back-edge is inner_continner_body.

CSE processes Load₂ in inner_body:

  1. No local match; m_data.writes is empty → walk predecessors {outer_cont, inner_cont}.
  2. outer_cont: no writes, no match → push preds {outer_body, outer_slow}.
  3. inner_cont: no writes, no match → push preds {inner_body, inner_slow}.
  4. outer_body: memoryValuesAtTail contains Load₁ (same ptr/offset/type) → matches = {Load₁}; continue.
  5. outer_slow: patchpoint has writes = ∅ → does not bail; preds already visited.
  6. inner_body: memoryValuesAtTail contains Load₂, but the match != m_value guard (line 1022) skips it; no writes → continue.
  7. inner_slow: writes = ∅ → does not bail.
  8. Worklist exhausts with matches = {Load₁} (never reached the root). replaceMemoryValue sees a single match, RELEASE_ASSERT(outer_body dominates inner_body) passes (every path to inner_body goes through outer_body), and rewrites Load₂ → Identity(Load₁).

The inner loop's Branch now tests Above(Load₁, fp). The inner back-edge cycle inner_bodyinner_cont never re-executes outer_body, so Load₁ is read exactly once. A later requestStop() poisoning m_trapAwareSoftStackLimit is never observed and the OMG-compiled function spins forever — the exact hang this PR fixes, reintroduced for nested pure-Wasm loops (local.set etc. lower to B3 ops with writesLocalState only, so realistic pure-compute inner loops qualify). OMG runs at optLevel = 2 (Options::wasmOMGOptimizationLevel() default), which enables eliminateCommonSubexpressions in B3Generate.cpp, so this fires in the default configuration; the PR's single-loop repro wouldn't catch it.

Fix

Add one line so CSE bails when it walks through the slow-path block via the back-edge:

Effects effects = Effects::none();
effects.reads = B3::HeapRange::top();
effects.writes = B3::HeapRange::top();  // block CSE across the probe (matches Effects::forCall())
effects.exitsSideways = true;

(or equivalently start from Effects::forCall()). This is confined to the Rare slow-path block, so the hot path still carries no clobber set as intended.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 9, 2026
Bump WEBKIT_VERSION to the oven-sh/WebKit#372 preview build (rebased onto
fork main 78d45d3184 as a50d1e5072): JSC now polls the trap-aware stack
limit at every Wasm loop back-edge (IPInt/BBQ/OMG), so a Worker spinning in
a pure-Wasm loop (no JS re-entry, e.g. loop { br 0 }) observes
VM::notifyNeedTermination() instead of never reaching a trap check.

worker-terminate-wasm-loop.test.ts covers the three loop shapes (JS loop,
Wasm loop calling a JS import, pure Wasm loop) and asserts terminate()
resolves with exit code 1 for each.

Before merging, oven-sh/WebKit#372 must land and WEBKIT_VERSION must be
repinned to the merged 40-hex sha; the preview tag is CI-only.
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