Bug 312682

Summary: LLInt/BBQ JIT Discrepancy in WebAssembly div/rem Constant Folding
Product: WebKit Reporter: parkjuny
Component: WebAssemblyAssignee: Nobody <webkit-unassigned>
Status: RESOLVED FIXED    
Severity: Minor CC: webkit-bug-importer
Priority: P2 Keywords: InRadar
Version: WebKit Nightly Build   
Hardware: PC   
OS: Linux   
Attachments:
Description Flags
poc.js none

parkjuny
Reported 2026-04-18 09:00:40 PDT
Created attachment 479198 [details] poc.js ## Summary `checkConstantDivision<IntType>` in the BBQ JIT constant-folding path misapplies the signed division overflow trap to `rem_s` and unsigned `div_u`/`rem_u` operations. The LLInt interpreter computes correct results; after BBQ JIT tier-up, the same constant-operand operations throw spurious `IntegerOverflow` exceptions across 6 operation classes. ## Bug ### Summary Two defects in `checkConstantDivision<IntType>` produce the LLInt/BBQ JIT discrepancy: 1. **`rem_s` incorrectly traps**: The function is shared between `div_s` and `rem_s` callers with no distinction. The register path (`emitModOrDiv<int32_t, true>`) correctly returns 0 for `rem_s(INT_MIN, -1)`; the constant-fold path unconditionally emits a throw. 2. **Unsigned callers use signed template type**: `div_u`/`rem_u` callers instantiate the function with `int32_t`/`int64_t` instead of `uint32_t`/`uint64_t`. Since `std::is_signed<int32_t>()` is `true`, the overflow trap fires for unsigned constant operands where no overflow exists. In both cases, `emitThrowException` (called at JIT-compile time) emits an unconditional branch to the exception thunk in the JIT code stream, making the constant result unreachable. The LLInt has no such check and evaluates these operations correctly. ### Detail ```cpp // Source/JavaScriptCore/wasm/WasmBBQJIT.cpp:2072-2087 template<typename IntType> Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) { constexpr bool is32 = sizeof(IntType) == 4; if (!(is32 ? int64_t(rhs.asI32()) : rhs.asI64())) { emitThrowException(ExceptionType::DivisionByZero); return is32 ? Value::fromI32(1) : Value::fromI64(1); } if ((is32 ? int64_t(rhs.asI32()) : rhs.asI64()) == -1 && (is32 ? int64_t(lhs.asI32()) : lhs.asI64()) == std::numeric_limits<IntType>::min() && std::is_signed<IntType>()) { emitThrowException(ExceptionType::IntegerOverflow); // BUG: fires for rem_s and unsigned ops return is32 ? Value::fromI32(1) : Value::fromI64(1); } return rhs; } ``` The function is called from all eight `add*` methods. The `rem_s` callers (lines 2163, 2180) and unsigned callers (lines 2129, 2146, 2197, 2214) all pass `int32_t`/`int64_t`, making `std::is_signed<IntType>()` always `true`. The register-based `emitModOrDiv` correctly distinguishes these cases using `IsMod` and unsigned types: ```cpp emitModOrDiv<int32_t, true>(...); // rem_s register path (line 2166): correct emitModOrDiv<uint32_t, false>(...); // div_u register path (line 2132): correct ``` The constant-fold path is inconsistent with the register path — the discrepancy surfaces only after BBQ JIT tier-up. ### Trigger Conditions 1. Wasm function contains `i32.rem_s`, `i64.rem_s`, `i32.div_u`, `i64.div_u`, `i32.rem_u`, or `i64.rem_u` with **both operands as constants** 2. Constants match the signed overflow pattern: `lhs == INT_MIN`, `rhs == -1` (signed interpretation) 3. Function executes enough to trigger BBQ JIT tier-up (50–2000 calls depending on build type) ## Version ### Reproduced Version - `main` branch latest commit (2026/04/17): `a4390137a403` - JavaScriptCore (WebKit trunk) ### Bisect Bisect not performed. Bug present in current `main`. ## Reproduction Case Each test loops the Wasm function up to 10000 times. Iterations before BBQ JIT tier-up succeed; the first iteration after tier-up throws, exposing the LLInt/JIT discrepancy. ### Release Build ```bash jsc poc.js ``` Result: ``` [BUG] i32.rem_s(INT_MIN,-1)==0: Integer overflow (evaluating 'inst.exports.f()') at iter 841 (expected 0) [BUG] i64.rem_s(INT64_MIN,-1)==0: Integer overflow (evaluating 'inst.exports.f()') at iter 1495 (expected 0) [BUG] i32.div_u(INT_MIN,NEG1)==0: Integer overflow (evaluating 'inst.exports.f()') at iter 1440 (expected 0) [BUG] i32.rem_u(INT_MIN,NEG1)==0x80000000: Integer overflow (evaluating 'inst.exports.f()') at iter 1890 (expected -2147483648) [BUG] i64.div_u(INT64_MIN,NEG1)==0: Integer overflow (evaluating 'inst.exports.f()') at iter 1220 (expected 0) [BUG] i64.rem_u(INT64_MIN,NEG1)==INT64_MIN: Integer overflow (evaluating 'inst.exports.f()') at iter 1003 (expected -9223372036854775808) [OK] i32.div_s(INT_MIN,-1) correctly traps ``` Debug build reproduces identically; tier-up iteration count differs but all 6 bugs trigger in both. ### PoC Code ```js // BBQ JIT constant-folding discrepancy with LLInt for rem_s and unsigned div/rem. // LLInt returns correct values; after BBQ JIT tier-up the same ops throw IntegerOverflow. function buildWasmModule(resultType, bodyBytes) { const header = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; const typeSection = [0x01, 0x05, 0x01, 0x60, 0x00, 0x01, resultType]; const funcSection = [0x03, 0x02, 0x01, 0x00]; const exportSection = [0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00]; const funcBody = [0x00, ...bodyBytes, 0x0b]; const funcBodyWithSize = [funcBody.length, ...funcBody]; const codeSectionContent = [0x01, ...funcBodyWithSize]; const codeSection = [0x0a, codeSectionContent.length, ...codeSectionContent]; return new Uint8Array([...header, ...typeSection, ...funcSection, ...exportSection, ...codeSection]); } const I32_MIN = [0x80, 0x80, 0x80, 0x80, 0x78]; const NEG1 = [0x7f]; const I64_MIN = [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x7f]; function check(name, resultType, bodyBytes, expected) { const inst = new WebAssembly.Instance(new WebAssembly.Module(buildWasmModule(resultType, bodyBytes))); for (let i = 0; i < 10000; i++) { try { const r = inst.exports.f(); if (r !== expected) { print("[BUG] " + name + ": wrong=" + r + " at iter " + i); return; } } catch(e) { print("[BUG] " + name + ": " + e.message + " at iter " + i + " (expected " + expected + ")"); return; } } print("[OK] " + name); } // rem_s(INT_MIN, -1): LLInt returns 0, BBQ JIT throws IntegerOverflow check("i32.rem_s(INT_MIN,-1)==0", 0x7f, [0x41,...I32_MIN,0x41,...NEG1,0x6f], 0); check("i64.rem_s(INT64_MIN,-1)==0", 0x7e, [0x42,...I64_MIN,0x42,...NEG1,0x81], 0n); // unsigned div/rem: LLInt returns correct values, BBQ JIT throws IntegerOverflow check("i32.div_u(INT_MIN,NEG1)==0", 0x7f, [0x41,...I32_MIN,0x41,...NEG1,0x6e], 0); check("i32.rem_u(INT_MIN,NEG1)==0x80000000", 0x7f, [0x41,...I32_MIN,0x41,...NEG1,0x70], -2147483648); check("i64.div_u(INT64_MIN,NEG1)==0", 0x7e, [0x42,...I64_MIN,0x42,...NEG1,0x80], 0n); check("i64.rem_u(INT64_MIN,NEG1)==INT64_MIN",0x7e, [0x42,...I64_MIN,0x42,...NEG1,0x82], -9223372036854775808n); // Control: i32.div_s(INT_MIN,-1) must trap — BBQ JIT handles this correctly { const inst = new WebAssembly.Instance(new WebAssembly.Module(buildWasmModule(0x7f, [0x41,...I32_MIN,0x41,...NEG1,0x6d]))); for (let i = 0; i < 5000; i++) try { inst.exports.f(); } catch(e) {} try { inst.exports.f(); print("[FAIL] i32.div_s(INT_MIN,-1) should trap"); } catch(e) { print("[OK] i32.div_s(INT_MIN,-1) correctly traps"); } } ``` ## Suggested Patch ### Patch ```diff diff --git a/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp b/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp --- a/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp +++ b/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp @@ -2069,7 +2069,7 @@ void BBQJIT::recordJumpToThrowException(ExceptionType type, const JumpList& jump m_exceptions[static_cast<unsigned>(type)].append(jumps); } -template<typename IntType> +template<typename IntType, bool IsMod> Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) { constexpr bool is32 = sizeof(IntType) == 4; @@ -2077,11 +2077,13 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) emitThrowException(ExceptionType::DivisionByZero); return is32 ? Value::fromI32(1) : Value::fromI64(1); } - if ((is32 ? int64_t(rhs.asI32()) : rhs.asI64()) == -1 - && (is32 ? int64_t(lhs.asI32()) : lhs.asI64()) == std::numeric_limits<IntType>::min() - && std::is_signed<IntType>()) { - emitThrowException(ExceptionType::IntegerOverflow); - return is32 ? Value::fromI32(1) : Value::fromI64(1); + if constexpr (std::is_signed<IntType>()) { + if ((is32 ? int64_t(rhs.asI32()) : rhs.asI64()) == -1 + && (is32 ? int64_t(lhs.asI32()) : lhs.asI64()) == std::numeric_limits<IntType>::min()) { + if constexpr (!IsMod) + emitThrowException(ExceptionType::IntegerOverflow); + return is32 ? Value::fromI32(1) : Value::fromI64(1); + } } return rhs; } @@ -2092,7 +2094,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I32DivS", TypeKind::I32, BLOCK( - Value::fromI32(lhs.asI32() / checkConstantDivision<int32_t>(lhs, rhs).asI32()) + Value::fromI32(lhs.asI32() / checkConstantDivision<int32_t, false>(lhs, rhs).asI32()) ), @@ -2109,7 +2111,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I64DivS", TypeKind::I64, BLOCK( - Value::fromI64(lhs.asI64() / checkConstantDivision<int64_t>(lhs, rhs).asI64()) + Value::fromI64(lhs.asI64() / checkConstantDivision<int64_t, false>(lhs, rhs).asI64()) ), @@ -2126,7 +2128,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I32DivU", TypeKind::I32, BLOCK( - Value::fromI32(static_cast<uint32_t>(lhs.asI32()) / static_cast<uint32_t>(checkConstantDivision<int32_t>(lhs, rhs).asI32())) + Value::fromI32(static_cast<uint32_t>(lhs.asI32()) / static_cast<uint32_t>(checkConstantDivision<uint32_t, false>(lhs, rhs).asI32())) ), @@ -2143,7 +2145,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I64DivU", TypeKind::I64, BLOCK( - Value::fromI64(static_cast<uint64_t>(lhs.asI64()) / static_cast<uint64_t>(checkConstantDivision<int64_t>(lhs, rhs).asI64())) + Value::fromI64(static_cast<uint64_t>(lhs.asI64()) / static_cast<uint64_t>(checkConstantDivision<uint64_t, false>(lhs, rhs).asI64())) ), @@ -2160,7 +2162,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I32RemS", TypeKind::I32, BLOCK( - Value::fromI32(lhs.asI32() % checkConstantDivision<int32_t>(lhs, rhs).asI32()) + Value::fromI32(lhs.asI32() % checkConstantDivision<int32_t, true>(lhs, rhs).asI32()) ), @@ -2177,7 +2179,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I64RemS", TypeKind::I64, BLOCK( - Value::fromI64(lhs.asI64() % checkConstantDivision<int64_t>(lhs, rhs).asI64()) + Value::fromI64(lhs.asI64() % checkConstantDivision<int64_t, true>(lhs, rhs).asI64()) ), @@ -2194,7 +2196,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I32RemU", TypeKind::I32, BLOCK( - Value::fromI32(static_cast<uint32_t>(lhs.asI32()) % static_cast<uint32_t>(checkConstantDivision<int32_t>(lhs, rhs).asI32())) + Value::fromI32(static_cast<uint32_t>(lhs.asI32()) % static_cast<uint32_t>(checkConstantDivision<uint32_t, true>(lhs, rhs).asI32())) ), @@ -2211,7 +2213,7 @@ Value BBQJIT::checkConstantDivision(const Value& lhs, const Value& rhs) EMIT_BINARY( "I64RemU", TypeKind::I64, BLOCK( - Value::fromI64(static_cast<uint64_t>(lhs.asI64()) % static_cast<uint64_t>(checkConstantDivision<int64_t>(lhs, rhs).asI64())) + Value::fromI64(static_cast<uint64_t>(lhs.asI64()) % static_cast<uint64_t>(checkConstantDivision<uint64_t, true>(lhs, rhs).asI64())) ), diff --git a/Source/JavaScriptCore/wasm/WasmBBQJIT.h b/Source/JavaScriptCore/wasm/WasmBBQJIT.h --- a/Source/JavaScriptCore/wasm/WasmBBQJIT.h +++ b/Source/JavaScriptCore/wasm/WasmBBQJIT.h @@ -1648,7 +1648,7 @@ public: - template<typename IntType> + template<typename IntType, bool IsMod> Value checkConstantDivision(const Value& lhs, const Value& rhs); ``` #### Explanation 1. **`IsMod` parameter** gates the overflow trap to `div_s` only (`!IsMod`), consistent with the register path. For `rem_s(INT_MIN, -1)`, returning `1` instead of emitting a throw lets the caller compute `INT_MIN % 1 = 0` — the correct result — without C++ undefined behavior (`INT_MIN % -1` is UB). 2. **Correct unsigned types** make `std::is_signed<IntType>()` false for unsigned callers, so the overflow block is eliminated entirely at compile time via `if constexpr`. This aligns the constant-fold path with `emitModOrDiv`, which already uses the correct types and `IsMod` flag. ### Credit Information Reporter credit: Junyoung Park(@candymate) of KAIST Hacking Lab
Attachments
poc.js (2.65 KB, text/javascript)
2026-04-18 09:00 PDT, parkjuny
no flags
Radar WebKit Bug Importer
Comment 1 2026-04-19 13:22:10 PDT
anand_srinivasan
Comment 2 2026-04-21 13:37:15 PDT
EWS
Comment 3 2026-04-23 15:12:09 PDT
Committed 311898@main (787be9470e19): <https://commits.webkit.org/311898@main> Reviewed commits have been landed. Closing PR #63266 and removing active labels.
Note You need to log in before you can comment on or make changes to this bug.