Skip to content

[JSC] TypedArray indexOf / lastIndexOf / includes: never wrap, clamp or round the search value - #609

Open
robobun wants to merge 2 commits into
mainfrom
robobun/dd80e18b/typed-array-search-needle
Open

robobun wants to merge 2 commits into
mainfrom
robobun/dd80e18b/typed-array-search-needle

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • %TypedArray%.prototype.indexOf, lastIndexOf and includes match an element that is not there when the search value is outside what the element type can hold. Three paths of the one conversion helper, toNativeFromValueWithoutCoercion<Adaptor>() (runtime/ToNativeFromValue.h), wrap, round or truncate the needle instead of reporting "not representable":
    • BigInt needle: new BigInt64Array([1n]).includes(2n ** 64n + 1n) and new BigUint64Array([2n ** 64n - 1n]).includes(-1n) are true. The path calls JSBigInt::toBigInt64 / toBigUInt64, the modular ToBigInt64 / ToBigUint64 meant for stores.
    • int32 needle, Float32 / Float16 elements: new Float32Array([16777216]).indexOf(16777217) and new Float16Array([Infinity]).indexOf(65536) are 0. toNativeFromInt32WithoutCoercion casts with no round-trip check.
    • double needle, Uint8Clamped elements: u8c.indexOf(-1) finds a 255 and 256, 1e10, 2 ** 31, Infinity find a 0, in bun's release build only. Uint8ClampedAdaptor::toNativeFromDoubleWithoutCoercion (runtime/TypedArrayAdaptors.h:339) does static_cast<uint8_t>(double), undefined behavior outside (-1, 256). Merge upstream/main (013da9aa8cc1) + cherry-pick WebKit#63906 #205 replaced the same cast in IntegralTypedArrayAdaptor but not this one.
  • V8 (Node 26, Chromium 148) returns -1 / false for all of these, as the spec requires.

Fix

  • Add JSBigInt::tryExtractInt64 / tryExtractUInt64 next to tryExtractDouble (they share a new absoluteAsUInt64 helper) and use them for the BigInt path. They return std::nullopt unless the BigInt is in [-2^63, 2^63) or [0, 2^64).
  • FloatTypedArrayAdaptor and Float16Adaptor: the int32 path now goes through the checked double path (an int32 converts to double exactly). The double path does its range check before the narrowing cast instead of after, because converting a finite double beyond FLT_MAX (or 65504) to float (or _Float16) is undefined behavior too.
  • Uint8ClampedAdaptor: convert through truncateDoubleToInt64() and then narrow, the form Merge upstream/main (013da9aa8cc1) + cherry-pick WebKit#63906 #205 gave IntegralTypedArrayAdaptor. The 64-bit truncation is an intrinsic with a defined overflow result, so the optimizer cannot fold the round-trip check away.
  • Verified: JSTests/stress/typed-array-index-of-unrepresentable-needle.js (new) covers all 12 element types with int32, double and BigInt needles at and beyond each type's limits, plus -0, NaN and the double-array source from the original report. With a Release JSCOnly build of this branch it passes under default options, --useJIT=0 and eager tiering. The unpatched engine fails it at Float32Array int32 2^24 + 1 indexOf: expected -1 but got 0 (any build) and at Uint8ClampedArray double -1 indexOf: expected -1 but got 2 (bun 1.4.2 release). The 714 test262 cases under built-ins/TypedArray/prototype/{indexOf,lastIndexOf,includes} and built-ins/TypedArrayConstructors/internals pass (strict and sloppy), as do the 266 typed-array, DataView and BigInt64 stress tests that run without ICU data.

Background

  • The spec compares the search value with each element's Number or BigInt value by IsStrictlyEqual (indexOf, lastIndexOf) or SameValueZero (includes). JSC instead converts the search value to the element type once and scans raw storage with WTF::find8/16/32/64/Float. That is only equivalent when the conversion is exact, so the helper returns std::optional and the callers answer -1 / false on nullopt (with the detached / undefined special case for includes).
  • Why the Uint8Clamped face is build-dependent: LLVM lowers the cast to fptoui double to i8, which is poison out of range, and the check static_cast<double>(integer) != value to uitofp + fcmp. LLVM 22's DAG combiner folds uitofp (fptoui x) to ftrunc x whenever the sign of zero cannot be observed (canIgnoreSignBitOfZero), which an fcmp use satisfies. LLVM 21 only did this under -fno-signed-zeros. The WebKit prebuilt is compiled by clang 21, but bun's release link is ThinLTO through rust-lld (LLD 22.1.8), so the -lto prebuilt's bitcode is code-generated by LLVM 22. The check becomes roundsd $0xb + ucomisd ("is value integral?") and the needle is the low byte of a 32-bit cvttsd2si, which is 0x80000000 for anything outside int32 range. bun 1.4.2's typedArrayViewProtoFuncIncludes has exactly that sequence at +0xcfb.
  • There is no DFG/FTL intrinsic for these three functions on typed arrays, so the C++ built-in is the only implementation and every tier was affected the same way.
Notes
  • Standalone reproduction of the codegen: compiling the old toNativeFromDoubleWithoutCoercion to bitcode with clang 21 (-O3 -flto=thin) and linking with --ld-path=<rustup nightly-2026-07-20>/gcc-ld/ld.lld produces cvttsd2si %xmm0,%eax; roundsd $0xb,%xmm0,%xmm1 and reports 1e10 found; the truncateDoubleToInt64 form produces cvttsd2si %xmm0,%rax; cvtsi2sd %ecx,%xmm1 and is correct. Plain clang 21 -O3 keeps the round trip for both, which is why the non-LTO prebuilt (local bun run build:release, the release-asan build) never showed it.
  • The wrong bytes quoted above are x86-64 (cvttsd2si gives 0x80000000 out of range, so its low byte is 0). Any other release target whose LTO link code-generates with LLVM 22 folds the check the same way but searches for a target-specific byte (arm64 fcvtzu saturates, so a double -1 would match a 0 rather than a 255). Non-LTO builds of every target keep the round trip.
  • tryExtractDouble keeps its behavior; it now calls absoluteAsUInt64 for the digit gathering it used to inline.
  • toBigInt64 / toBigUInt64 are unchanged and still used for stores and Atomics, where the modular conversion is the specified behavior.
  • Upstream WebKit main has the same code in all three places.

…wrap, clamp or round the search value

indexOf and lastIndexOf compare the search value with each element by
IsStrictlyEqual, and includes by SameValueZero. JSC converts the search
value to the element type once (toNativeFromValueWithoutCoercion) and
then scans the raw storage. That conversion must fail when the element
type cannot represent the value exactly. Three of its paths did not:

* A BigInt needle went through JSBigInt::toBigInt64 / toBigUInt64, which
  are the modular ToBigInt64 / ToBigUint64 used for stores. So
  new BigInt64Array([1n]).includes(2n ** 64n + 1n) and
  new BigUint64Array([2n ** 64n - 1n]).includes(-1n) were true.
* An int32 needle for Float32Array / Float16Array was cast to the element
  type with no round-trip check, so new Float32Array([16777216])
  .indexOf(16777217) and new Float16Array([Infinity]).indexOf(65536)
  were 0.
* A double needle for Uint8ClampedArray was converted with
  static_cast<uint8_t>(double), which is undefined behavior outside
  (-1, 256). LLVM 22 (bun links its LTO build with rust-lld) folds the
  round-trip check uint8 -> double == value into trunc(value) == value,
  so every integral double matched the low byte of cvttsd2si(value):
  -1 found 255, and 256, 1e10, 2 ** 31 and Infinity found 0. #205
  fixed the same cast in IntegralTypedArrayAdaptor but not here.

Add JSBigInt::tryExtractInt64 / tryExtractUInt64 next to tryExtractDouble
and use them for the BigInt path. Route the int32 path of the two float
adaptors through their checked double path. Convert the Uint8Clamped
double needle through truncateDoubleToInt64, as IntegralTypedArrayAdaptor
does. Also do the range check before the narrowing cast in the float
adaptors' double path, since converting a finite double beyond FLT_MAX
(or 65504) to float (or _Float16) is undefined behavior as well.

* JSTests/stress/typed-array-index-of-unrepresentable-needle.js: Added.
* Source/JavaScriptCore/runtime/JSBigInt.h:
* Source/JavaScriptCore/runtime/JSBigIntInlines.h:
(JSC::JSBigInt::absoluteAsUInt64):
(JSC::JSBigInt::tryExtractDouble): Share the digit gathering with the new helpers.
(JSC::JSBigInt::tryExtractInt64):
(JSC::JSBigInt::tryExtractUInt64):
* Source/JavaScriptCore/runtime/ToNativeFromValue.h:
(JSC::toNativeFromValueWithoutCoercion):
* Source/JavaScriptCore/runtime/TypedArrayAdaptors.h:
(JSC::FloatTypedArrayAdaptor::toNativeFromInt32WithoutCoercion):
(JSC::FloatTypedArrayAdaptor::toNativeFromDoubleWithoutCoercion):
(JSC::Uint8ClampedAdaptor::toNativeFromDoubleWithoutCoercion):
(JSC::Float16Adaptor::toNativeFromInt32WithoutCoercion):
(JSC::Float16Adaptor::toNativeFromDoubleWithoutCoercion):
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 5 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 14de32bb-c120-4a11-8feb-001e261895ea

📥 Commits

Reviewing files that changed from the base of the PR and between 10350dd and 1f82192.

📒 Files selected for processing (5)
  • JSTests/stress/typed-array-index-of-unrepresentable-needle.js
  • Source/JavaScriptCore/runtime/JSBigInt.h
  • Source/JavaScriptCore/runtime/JSBigIntInlines.h
  • Source/JavaScriptCore/runtime/ToNativeFromValue.h
  • Source/JavaScriptCore/runtime/TypedArrayAdaptors.h

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

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Overlap note: #604 (the Float32 / Float16 int32 path) and #607 (the BigInt64 / BigUint64 path) were opened for the two sibling reports while this was in progress. This PR changes the same helper and covers both of those paths plus the Uint8Clamped double path.

Update: #604 and #607 are now closed in favour of this PR, and their Bun bumps oven-sh/bun#42083 and oven-sh/bun#42089 in favour of oven-sh/bun#42093, which carries their test rows. This is the only one of the three to review.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread JSTests/stress/typed-array-index-of-unrepresentable-needle.js Outdated
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
1f821928 autobuild-preview-pr-609-1f821928 2026-09-09 00:51:12 UTC
7a274ac4 autobuild-preview-pr-609-7a274ac4 2026-09-08 23:05:12 UTC

JSTests/README.md asks new stress tests to iterate by testLoopCount so the harness can
scale them per configuration. Each pass makes several hundred builtin calls, so use
testLoopCount / 50 (200 passes by default, 31ms in Release; 8ms with --useJIT=0).
@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Bun-side bump pinned to autobuild-preview-pr-609-7a274ac4: oven-sh/bun#42093 (test/js/bun/jsc/typed-array-search-needle.test.ts, 7/7 pass on a debug+ASAN build; the 1.4.2 release binary fails 5/7, a non-LTO main build 4/7). The sibling bumps for #604 / #607 are oven-sh/bun#42083 / oven-sh/bun#42089; whichever WebKit shape merges, the other Bun PRs can close.

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

Code review found no issues

No high-confidence issues detected in this change.

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