Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Source/JavaScriptCore/llint/InPlaceInterpreter.asm
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,22 @@ macro handleDebuggerTrapIfNeeded()
addp 4 * MachineRegisterSize, sp
end

# Slow path for ipintOp(_loop) when m_trapAwareSoftStackLimit has been poisoned by
# VMTraps::requestStop(). Services the pending async trap; on Termination the call
# throws (operationCallMayThrow unwinds), otherwise PC/MC (restored by
# operationCallMayThrow, still pointing at the loop opcode) are advanced past it and
# the next instruction is dispatched.
op(ipint_loop_check_vm_traps, macro ()
operationCallMayThrow(macro()
move cfr, a1
cCall2(_ipint_extern_handle_vm_traps_at_loop)
end)
loadb IPInt::InstructionLengthMetadata::length[MC], t0
advancePCByReg(t0)
advanceMCByReg(constexpr (sizeof(IPInt::InstructionLengthMetadata)))
nextIPIntInstruction()
end)

op(wasm_ipint_check_debugger_hook_and_throw_trap, macro ()
handleDebuggerTrapIfNeeded()
# r0 == 0 i.e. DebuggerTrapStatus::ResolvedByDebugger i.e. this was purely a debugger trap / breakpoint,
Expand Down
7 changes: 7 additions & 0 deletions Source/JavaScriptCore/llint/InPlaceInterpreter64.asm
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ ipintOp(_loop, macro()
# loop
# We already validateOpcodeConfig in ipintLoopOSR.
ipintLoopOSR(1)
# VMTraps poll: requestStop() sets m_trapAwareSoftStackLimit = UINTPTR_MAX, so a pure
# Wasm loop (no calls) observes termination/watchdog requests at each back-edge instead of
# only at the next function prologue. sp as the first comparand (not second) because ARM64
# cannot encode sp as Rm in SUBS.
bpaeq sp, JSWebAssemblyInstance::m_stackMirror + StackManager::Mirror::m_trapAwareSoftStackLimit[wasmInstance], .ipint_loop_no_trap
jmp _ipint_loop_check_vm_traps
.ipint_loop_no_trap:
loadb IPInt::InstructionLengthMetadata::length[MC], t0
advancePCByReg(t0)
advanceMCByReg(constexpr (sizeof(IPInt::InstructionLengthMetadata)))
Expand Down
5 changes: 5 additions & 0 deletions Source/JavaScriptCore/runtime/StackManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ class StackManager {
return OBJECT_OFFSETOF(Mirror, m_softStackLimit);
}

static constexpr ptrdiff_t offsetOfTrapAwareSoftStackLimit()
{
return OBJECT_OFFSETOF(Mirror, m_trapAwareSoftStackLimit);
}

private:
Atomic<void*> m_trapAwareSoftStackLimit { nullptr };
void* m_softStackLimit { nullptr };
Expand Down
18 changes: 18 additions & 0 deletions Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3648,6 +3648,24 @@ void BBQJIT::emitLoopTierUpCheckAndOSREntryData(const ControlData& data, std::sp
ASSERT(enclosingStack.size() >= args.size());
auto enclosingWithoutArgs = enclosingStack.first(enclosingStack.size() - args.size());
emitLoopTierUpCheckAndOSREntryData(result, enclosingWithoutArgs, loopIndex);

// VMTraps poll: requestStop() (e.g. VM::notifyNeedTermination) poisons
// m_trapAwareSoftStackLimit so a pure-Wasm loop observes termination/watchdog requests at
// each back-edge instead of never. The slow path runs under jit.probe so all live state is
// preserved for the resume case (non-termination async traps like NeedStopTheWorld).
JIT_COMMENT(m_jit, "Loop VMTraps poll");
static_assert(GPRInfo::nonPreservedNonArgumentGPR0 == wasmScratchGPR);
// sp as the left comparand: matches the offsetOfSoftStackLimit() checks above and avoids
// ARM64's extra mov when sp is Rm.
Jump needTrapHandling = m_jit.branchPtr(CCallHelpers::Below, MacroAssembler::stackPointerRegister, CCallHelpers::Address(GPRInfo::wasmContextInstancePointer, JSWebAssemblyInstance::offsetOfTrapAwareSoftStackLimit()));
MacroAssembler::Label trapResume = m_jit.label();
addLatePath(origin(), [needTrapHandling, trapResume](BBQJIT& bbq, CCallHelpers& jit) {
needTrapHandling.link(&jit);
jit.probe(tagCFunction<JITProbePtrTag>(operationWasmHandleTrapsAtLoop), nullptr);
bbq.recordJumpToThrowException(ExceptionType::Termination, jit.branchTestPtr(CCallHelpers::NonZero, GPRInfo::nonPreservedNonArgumentGPR0));
jit.jump().linkTo(trapResume, &jit);
});

return { };
}

Expand Down
16 changes: 16 additions & 0 deletions Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,22 @@ WASM_IPINT_EXTERN_CPP_DECL(check_stack_and_vm_traps, void* candidateNewStackPoin
IPINT_THROW(Wasm::ExceptionType::StackOverflow);
}

// Reached from the loop back-edge when m_trapAwareSoftStackLimit has been poisoned by
// VMTraps::requestStop(). Services whatever async trap is pending (NeedTermination /
// NeedWatchdogCheck / NeedStopTheWorld / NeedDebuggerBreak); resumes the loop when it
// wasn't a termination. Without this a pure-Wasm loop never observes VMTraps at all.
WASM_IPINT_EXTERN_CPP_DECL(handle_vm_traps_at_loop, CallFrame* callFrame)
{
UNUSED_PARAM(callFrame);
VM& vm = instance->vm();
if (vm.traps().handleTrapsIfNeeded()) {
if (vm.hasPendingTerminationException())
IPINT_THROW(Wasm::ExceptionType::Termination);
ASSERT(!vm.exceptionForInspection());
}
IPINT_END();
}

#if ENABLE(WEBASSEMBLY_DEBUGGER)
static UNUSED_FUNCTION void displayWasmDebugState(JSWebAssemblyInstance* instance, Wasm::IPIntCallee* callee, CallFrame* callFrame, IPIntStackEntry* sp)
{
Expand Down
1 change: 1 addition & 0 deletions Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ WASM_IPINT_EXTERN_CPP_HIDDEN_DECL(memory_atomic_wait64, IPIntStackEntry*);
WASM_IPINT_EXTERN_CPP_HIDDEN_DECL(memory_atomic_notify, IPIntStackEntry*);

WASM_IPINT_EXTERN_CPP_HIDDEN_DECL(check_stack_and_vm_traps, void* candidateNewStackPointer, Wasm::IPIntCallee*, CallFrame*);
WASM_IPINT_EXTERN_CPP_HIDDEN_DECL(handle_vm_traps_at_loop, CallFrame*);
WASM_IPINT_EXTERN_CPP_DECL(handle_debugger_trap_if_needed, CallFrame*, Register*);


Expand Down
43 changes: 43 additions & 0 deletions Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4619,6 +4619,49 @@
}

m_currentBlock = body;

// VMTraps poll: requestStop() (e.g. VM::notifyNeedTermination) poisons
// m_trapAwareSoftStackLimit to UINTPTR_MAX so a pure-Wasm loop observes termination /
// watchdog requests at each back-edge instead of never. Match FTL compileCheckTraps: lower
// the poll as plain B3 IR (Load + Above + Branch) so the hot path carries no clobber set,
// and confine the probe patchpoint to a Rare block. fp stands in for sp (B3 has no SP value);
// fp > sp so fp < limit only under the poisoned-limit, which is the branch we want.
{
BasicBlock* slowPath = m_proc.addBlock();
BasicBlock* continuation = m_proc.addBlock();

Value* limit = m_currentBlock->appendNew<MemoryValue>(m_proc, Load, pointerType(), origin(), instanceValue(), safeCast<int32_t>(JSWebAssemblyInstance::offsetOfTrapAwareSoftStackLimit()));
m_currentBlock->appendNewControlValue(m_proc, B3::Branch, origin(),
m_currentBlock->appendNew<Value>(m_proc, Above, origin(), limit, framePointer()),
FrequentedBlock(slowPath, FrequencyClass::Rare), FrequentedBlock(continuation));
slowPath->addPredecessor(m_currentBlock);
continuation->addPredecessor(m_currentBlock);

m_currentBlock = slowPath;
// The probe preserves all live state for the resume case (non-termination async traps
// like NeedStopTheWorld); clobbers apply only to this rare block.
B3::PatchpointValue* handle = m_currentBlock->appendNew<B3::PatchpointValue>(m_proc, B3::Void, origin());
Effects effects = Effects::none();
effects.reads = B3::HeapRange::top();
effects.exitsSideways = true;
handle->effects = effects;

Check failure on line 4647 in Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

View check run for this annotation

Claude / Claude Code Review

OMG trap-poll patchpoint lacks effects.writes, letting B3 CSE fold away inner-loop limit loads

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).
Comment on lines +4644 to +4647

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.

RegisterSet clobbers = RegisterSet::macroClobberedGPRs();
clobbers.add(GPRInfo::nonPreservedNonArgumentGPR0, IgnoreVectors);
handle->clobber(clobbers);
handle->append(instanceValue(), ValueRep::reg(GPRInfo::wasmContextInstancePointer));
handle->setGenerator([this, origin = this->origin()](CCallHelpers& jit, const B3::StackmapGenerationParams&) {
AllowMacroScratchRegisterUsage allowScratch(jit);
jit.probe(tagCFunction<JITProbePtrTag>(operationWasmHandleTrapsAtLoop), nullptr);
CCallHelpers::Jump resume = jit.branchTestPtr(CCallHelpers::Zero, GPRInfo::nonPreservedNonArgumentGPR0);
this->emitExceptionCheck(jit, origin, ExceptionType::Termination);
resume.link(&jit);
});
m_currentBlock->appendNewControlValue(m_proc, Jump, origin(), continuation);
continuation->addPredecessor(m_currentBlock);

m_currentBlock = continuation;
}

return { };
}

Expand Down
14 changes: 14 additions & 0 deletions Source/JavaScriptCore/wasm/WasmOperations.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,20 @@ JSC_DEFINE_NOEXCEPT_JIT_OPERATION(operationWasmLoopOSREnterBBQJIT, void, (Probe:
context.gpr(GPRInfo::nonPreservedNonArgumentGPR0) = std::bit_cast<UCPURegister>(callee.loopEntrypoints()[loopIndex].taggedPtr());
}

// Reached from BBQ/OMG loop heads when m_trapAwareSoftStackLimit has been poisoned by
// VMTraps::requestStop() (e.g. VM::notifyNeedTermination()). Services whatever async trap
// is pending and reports whether a TerminationException was raised so the caller can unwind;
// otherwise all registers are restored by the probe and the loop resumes. Without this a
// pure-Wasm loop that never calls into JS (e.g. `loop { br 0 }`) never observes VMTraps and
// cannot be preempted by worker.terminate() / the watchdog.
JSC_DEFINE_NOEXCEPT_JIT_OPERATION(operationWasmHandleTrapsAtLoop, void, (Probe::Context& context))
{
JSWebAssemblyInstance* instance = context.gpr<JSWebAssemblyInstance*>(GPRInfo::wasmContextInstancePointer);
VM& vm = instance->vm();
vm.traps().handleTrapsIfNeeded();
context.gpr(GPRInfo::nonPreservedNonArgumentGPR0) = static_cast<UCPURegister>(vm.hasPendingTerminationException());
}

#endif

#if ENABLE(WEBASSEMBLY_BBQJIT)
Expand Down
1 change: 1 addition & 0 deletions Source/JavaScriptCore/wasm/WasmOperations.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ JSC_DECLARE_NOEXCEPT_JIT_OPERATION(operationWasmTriggerTierUpNow, void, (CallFra
#if ENABLE(WEBASSEMBLY_OMGJIT) || ENABLE(WEBASSEMBLY_BBQJIT)
JSC_DECLARE_NOEXCEPT_JIT_OPERATION(operationWasmTriggerOSREntryNow, void, (Probe::Context&));
JSC_DECLARE_NOEXCEPT_JIT_OPERATION(operationWasmLoopOSREnterBBQJIT, void, (Probe::Context&));
JSC_DECLARE_NOEXCEPT_JIT_OPERATION(operationWasmHandleTrapsAtLoop, void, (Probe::Context&));
#endif
#if ENABLE(WEBASSEMBLY_BBQJIT)
JSC_DECLARE_NOEXCEPT_JIT_OPERATION(operationWasmMaterializeBaselineData, void, (CallFrame*, JSWebAssemblyInstance*));
Expand Down
1 change: 1 addition & 0 deletions Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.h
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ class JSWebAssemblyInstance final : public JSNonFinalObject {
using FunctionWrapperMap = UncheckedKeyHashMap<uint32_t, WriteBarrier<Unknown>, IntHash<uint32_t>, WTF::UnsignedWithZeroKeyHashTraits<uint32_t>>;

static constexpr ptrdiff_t offsetOfSoftStackLimit() { return OBJECT_OFFSETOF(JSWebAssemblyInstance, m_stackMirror) + StackManager::Mirror::offsetOfSoftStackLimit(); }
static constexpr ptrdiff_t offsetOfTrapAwareSoftStackLimit() { return OBJECT_OFFSETOF(JSWebAssemblyInstance, m_stackMirror) + StackManager::Mirror::offsetOfTrapAwareSoftStackLimit(); }

Wasm::Module& module() const { return m_module.get(); }
SourceTaintedOrigin taintedness() const { return m_sourceProvider->sourceTaintedOrigin(); }
Expand Down
Loading