Fix UB in double-to-int conversions across JSC#244
Conversation
static_cast<int> of a double outside the int32 range is undefined
behavior. Newer compilers assume the cast is in-range and elide the
following round-trip check, so parseInt results >= 2^31 (e.g.
parseInt("80000000", 16)) are boxed as a negative int32 instead of the
correct double. Use truncateDoubleToInt32() for a defined conversion.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughReplace direct double-to-int/uint casts with truncateDoubleToInt32/truncateDoubleToUint32 and clamp inspector fetch counts with clampTo; keep existing equality and range checks and original behavior paths. ChangesNumeric parsing and validation
Inspector fetch clamping
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Comment |
LiteralParser JSONP index, JSValue::getUInt32, and String.fromCodePoint each cast a double to a narrow int and validate with a round-trip check the compiler may delete (the cast is UB out of range). Make the cast defined / guard the range before narrowing.
The ToInteger argument was cast to int before the range check, so an out-of-range or infinite value hit UB. Range-check the double first.
weakMapEntries/iteratorEntries cast a ToIntegerOrInfinity result to unsigned; +Infinity passes the >= 0 guard and makes the cast UB. clampTo defines it.
truncateDoubleToInt32 makes the conversion defined for all doubles, so the existing v == d round-trip check is enough on its own.
truncateDoubleToInt32 returns an architecture sentinel for d >= 2^31, so the round-trip check would have rejected valid uint32 indices in [2^31, 2^32). truncateDoubleToUint32 returns d exactly for d in [0, 2^32) and is in scope via the existing MathExtras.h include.
JSValue(double) already calls tryConvertToStrictInt32 and boxes int32 iff exactly representable, else as double — this function was duplicating that. The one-liner is bit-identical and removes the UB by deletion.
Restore the original cast-then-round-trip structure; only the cast is made defined via truncateDoubleToUint32 so the value is bit-identical for every input the original cast was defined on.
Preview Builds
|
bfdb24c to
04b3738
Compare
Bumps `WEBKIT_VERSION` from `963f8758` to [`6d586e29`](oven-sh/WebKit@6d586e2), the current head of oven-sh/WebKit main. The prebuilt release [`autobuild-6d586e293f008f0e74e5697611a379b1b24815c9`](https://github.com/oven-sh/WebKit/releases/tag/autobuild-6d586e293f008f0e74e5697611a379b1b24815c9) is published with all platform tarballs. This range is a fast-forward of 6 fork-local commits — no upstream WebKit merge: - [`27f4e7a9`](oven-sh/WebKit@27f4e7a) Fix UB in double-to-int conversions across JSC (oven-sh/WebKit#244) - [`f18cf9c2`](oven-sh/WebKit@f18cf9c) FreeList::forEach: bound the interval assert by MarkedBlock::blockSize (oven-sh/WebKit#247) - [`48460d87`](oven-sh/WebKit@48460d8) Compile out the JIT disassembler in release builds (oven-sh/WebKit#241) - [`6d586e29`](oven-sh/WebKit@6d586e2) windows cross: add the bun-webkit-windows-amd64-asan variant (oven-sh/WebKit#240) - [`c787a5a7`](oven-sh/WebKit@c787a5a) windows cross: add the amd64-baseline ThinLTO variant - [`c591a185`](oven-sh/WebKit@c591a18) windows: update xwin to 0.9.0 (oven-sh/WebKit#242) Checks: - No `Source/JavaScriptCore/runtime/JSType.h` changes in the range, so no `src/jsc/JSType.rs` update needed. - The workflow change (#240) only adds a new `bun-webkit-windows-amd64-asan.tar.gz` asset; all tarball names `prebuiltSuffix()` produces are present in the release.
…bKit LTO flags
parseInt results, Map keys, and switch-immediate scrutinees at or above
2^31 came back as sign-wrapped int32s on official Linux x64 release
builds once the call site tiered up to the DFG: JSC had out-of-range
double->int casts (undefined behavior) in the round-trip checks that
decide int32 boxing/dispatch, and the LLVM 22 LTO backend (rust-lld,
via the cross-language LTO pipeline) folds those checks into bare
integrality tests. parseInt("80000000", 16) === -2147483648 after
~10k hot calls, persisting for the life of the process; Map keys
(issue #31080) and switch dispatch had the same failure mode. Fresh
processes replayed clean because lower tiers box through the hardened
paths.
The source fixes landed in oven-sh/WebKit#244 and reached main with the
WebKit upgrade in #31724. This adds the regression coverage: tests that
hammer all three paths past JIT tier-up (they fail in ~130 ms on the
last broken canary and run on CI's Linux x64 release lanes, which build
with LTO — debug builds cannot exhibit the fold).
Also forward the artifact builders' LTO flags (-flto=full
-fwhole-program-vtables -fforce-emit-vtables) to local Linux/FreeBSD
WebKit builds: with plain -flto=thin, --webkit=local --lto=on fails to
link with 'inconsistent LTO Unit splitting' because bun-profile links
with -fwhole-program-vtables. This is how the fix was validated
end-to-end before prebuilt artifacts existed.
Fixes #31080
parseInt results, and switch-immediate scrutinees, at or above 2^31 must not come back as wrapped int32s once out-of-range double->int casts are involved. #244 fixed the undefined behavior (newer LLVM LTO backends folded the round-trip overflow guards into bare integrality tests, so bun's linux-x64 release builds boxed parseInt("80000000", 16) as -2147483648 after DFG tier-up); these tests pin the behavior: - dfg-parseIntResult-should-not-wrap-above-int32.js: hex/decimal/ negative parses around the int32 boundary plus the Infinity-overflow case, with and without radix. - switch-imm-scrutinee-above-int32.js: dense jump-table and sparse switches with out-of-int32 scrutinees (2^31, 2^32, Infinity), which land exactly on a case label if the scrutinee range check is folded. Both passed on a jsc shell built with the equivalent fixes under -flto=full -fwhole-program-vtables.
Converting a double to a narrow integer with
static_cast/C-cast is undefined behavior when the value is NaN, infinite, or outside the target range. Newer compilers exploit this — they assume the value is in range and delete a following round-trip/range check, yielding a wrong (often sign-wrapped) result. Each site below takes an unbounded, caller-controlled double straight to the cast.DFGOperationsparseIntResult—parseInt("80000000", 16)boxed as a negative int32 in optimized buildsLiteralParserJSONP array indexJSValue::getUInt32— property-subscript fast pathString.fromCodePointcode pointNumber.prototype.toExponential/toPrecisiondigit count (range check ran after the cast)weakMapEntries/iteratorEntriesfetch count (+Infinitypassed the>= 0guard)slow_path_switch_immscrutinee —switch (2**31)could match acase -2147483648:label once the wrapped cast result passed the folded range checkEach is made defined via
truncateDoubleToInt32(),clampTo<>(), or a range check on the double before narrowing. Same class as #179 and #232.