Skip to content

Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback - #36420

Merged
Jarred-Sumner merged 8 commits into
mainfrom
farm/760d54c2/buffer-indexof-linear
Jul 30, 2026
Merged

Jarred-Sumner merged 8 commits into
mainfrom
farm/760d54c2/buffer-indexof-linear

Conversation

@robobun

@robobun robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

What

Buffer#indexOf / includes / lastIndexOf had an O(haystack × needle) worst case on adversarial input. This replaces the search kernel with a rare-byte two-anchor SIMD filter backed by a guaranteed-linear Two-Way fallback, for all four paths (uint8_t/uint16_t × forward/reverse).

Repro

const hay = Buffer.alloc(4 << 20, 0x61);
const fw = m => { const n = Buffer.alloc(m, 0x61); n[m - 1] = 0x62; return n; };
const bw = m => { const n = Buffer.alloc(m, 0x61); n[0] = 0x62; return n; };
for (const m of [250, 1000, 4000]) {
  console.time(`indexOf m=${m}`);     hay.indexOf(fw(m));     console.timeEnd(`indexOf m=${m}`);
  console.time(`lastIndexOf m=${m}`); hay.lastIndexOf(bw(m)); console.timeEnd(`lastIndexOf m=${m}`);
}
m bun 1.4.0 indexOf bun 1.4.0 lastIndexOf this PR node v26
250 28 ms 525 ms < 1 ms 16 / 19 ms
1000 74 ms 1.9 s < 1 ms 16 / 19 ms
4000 254 ms 7.4 s < 1 ms 16 / 19 ms

With the 'b' in the middle of the needle instead of at an end, Node's Boyer-Moore (capped at a 250-byte shift table) is also quadratic: indexOf takes 7.2 s at m=4000 and times out at m=16000. The rare-byte anchor picker here anchors on that 'b' directly, so the same case is under a millisecond.

Cause

  • highway_memmem (MemMemImpl) SIMD-scanned for the needle's first byte only, then memcmp'd every candidate. When the first byte is the haystack's dominant byte, that's N candidates × up-to-m compare each. The same symbol is aliased as libc memmem on Linux and macOS, so every in-process memmem inherited this.
  • lastIndexOf was std::find_end and the utf16le paths were std::search / std::find_end, all naive.

Fix

highway_strings.cpp

  • MemMemImpl and new MemRMemImpl / MemMem16Impl / MemRMem16Impl share a two-anchor SIMD kernel (MemMemForward / MemMemReverse, templated on lane type): load a vector at haystack + i + anchor_a and at haystack + i + anchor_b, AND the equality masks, and only memcmp lanes where both match. Reverse uses FindKnownLastTrue + FirstN to iterate candidates from the top.
  • MemMemPickAnchors chooses anchor_a / anchor_b as the needle's two least-frequent bytes via a full-needle histogram (low byte for uint16_t), so any distinguishing byte anywhere in the needle prunes the candidate set.
  • A false-positive budget of 2*|haystack|/|needle| + 32 caps total memcmp work at ~2·|haystack|; when it trips, the remaining range goes to MemMemTwoWayFallback.
  • New C exports highway_memrmem / highway_memmem16 / highway_memrmem16 dispatch the per-target kernels.

BufferStringSearch.h (new)

Two-Way string matching (Crochemore & Perrin, the algorithm glibc and musl memmem use): O(n + m) worst case, O(1) extra space. Templated on Char and driven through an index-reversing Vector so the same code serves forward and reverse search without copying.

JSBuffer.cpp

lastIndexOf now calls highway_memrmem; indexOf16 / lastIndexOf16 call highway_memmem16 / highway_memrmem16. std::find_end / std::search are gone.

Tests

test/js/node/buffer-indexof-worstcase.test.ts:

  • Tail / head / mid-mismatch adversarial needles stay within a fixed budget at m up to 16384, both directions.
  • x16 needle growth ratio bounded below 4.
  • Uniform-byte and period-2 needles exercise the Two-Way fallback.
  • 1500-trial deterministic randomized cross-check vs a naive reference (1..4 distinct bytes, random slices for guaranteed-present needles, offsets across the full range).
  • Every-boundary match sweep across the SIMD / scalar-remainder handoff for eleven needle lengths.
  • utf16le and presence checks.

Existing coverage still green: test/js/node/buffer.test.js (617), test-buffer-indexof.js, test-buffer-includes.js, buffer-indexOf-detach.test.ts, stringWidth.test.ts.


[review] gate passed · iteration 2 · 7 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/buffer-indexof-worstcase.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/26] gen JS modules (bundle-modules)
Preprocess modules (8808ms)
Bundle modules (63ms)
Postprocesss modules (25ms)
Bundle Functions (702ms)
Generate Code (12ms)

[9.63s] Bundled "src/js" for development
  2759 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[1/10] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[7/10] cxx obj/src/jsc/bindings/BunProcess.cpp.o
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_rust.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (2d636760b)

test/js/node/buffer-indexof-worstcase.test.ts:
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (tail-mismatch) [1.60ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (tail-mismatch) [1.07ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (head-mismatch) [1.07ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (head-mismatch) [0.94ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (mid-mismatch) [1.11ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (mid-mismatch) [0.95ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf / lastIndexOf growth ratio is bounded [2.65ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > includes on adversarial needle [0.27ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > uniform-byte needle forces the Two-Way fallback [
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/buffer-indexof-worstcase.test.ts
bun test v1.4.0 (b01343b36)

test/js/node/buffer-indexof-worstcase.test.ts:
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (tail-mismatch) [13.88ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (tail-mismatch) [9.83ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (head-mismatch) [7.50ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (head-mismatch) [5.97ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf stays sublinear in needle length (mid-mismatch) [7.53ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > lastIndexOf stays sublinear in needle length (mid-mismatch) [6.04ms]
(pass) Buffer#indexOf / lastIndexOf adversarial worst case > indexOf / lastIndexOf growth ratio is bounded [21.54ms]
(pass) Buffer#indexOf / lastIndexOf adv
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 704ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/84] gen ErrorCode+*.h
[2/39] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/39] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/39] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[5/39] gen cpp.rs (cppbind)
[6/39] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/b
... (truncated)
diff hotspot
.../verify-baseline-static/allowlist-aarch64.txt   |  12 +
 .../allowlist-x64-windows.txt                      |  16 +
 scripts/verify-baseline-static/allowlist-x64.txt   |  16 +
 src/jsc/bindings/BufferStringSearch.h              | 153 ++++++++++
 src/jsc/bindings/JSBuffer.cpp                      |  45 +--
 src/jsc/bindings/highway_strings.cpp               | 322 ++++++++++++++++-----
 test/js/node/buffer-indexof-worstcase.test.ts      | 238 +++++++++++++++
 7 files changed, 715 insertions(+), 87 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
scripts/verify-baseline-static/allowlist-aarch64.txt          1      4      0
scripts/verify-baseline-static/allowlist-x64-windows.txt      3      6      0
scripts/verify-baseline-static/allowlist-x64.txt              2      5      0
src/jsc/bindings/BufferStringSearch.h                         0      9      0
src/jsc/bindings/JSBuffer.cpp                                 4      7      0
src/jsc/bindings/highway_strings.cpp                         10     12      0
test/js/node/buffer-indexof-worstcase.test.ts                 1      6      0

Buffer#indexOf/includes went through highway_memmem, which SIMD-scanned
for the needle's first byte and memcmp'd every candidate; lastIndexOf
and the utf16le paths used std::find_end / std::search. All four are
O(haystack * needle) on adversarial input: a 4 MiB 'a' haystack with a
4000-byte 'a…ab' needle took ~250 ms forward and ~7 s backward, a
1500x gap vs Node.

highway_memmem, and new highway_memrmem / highway_memmem16 /
highway_memrmem16, now use a two-anchor SIMD filter on the needle's
two least-frequent bytes (full-needle histogram) so any distinguishing
byte anywhere in the needle prunes to zero candidates. A false-positive
budget caps total memcmp work at ~2*|haystack| and hands the remainder
to a Two-Way (Crochemore-Perrin) search, which is O(n+m) worst case
with O(1) space and handles reverse search via index-reversed views.
The utf16le paths share the same kernel over uint16_t lanes.

The libc memmem alias on Linux/macOS is the same symbol, so every
in-process memmem call inherits the linear bound.
Comment thread src/jsc/bindings/BufferStringSearch.h Outdated
Comment thread src/jsc/bindings/BufferStringSearch.h
Comment thread src/jsc/bindings/BufferStringSearch.h Outdated
Comment thread src/jsc/bindings/BufferStringSearch.h Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp
Comment thread src/jsc/bindings/highway_strings.cpp Outdated
@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:36 PM PT - Jul 29th, 2026

⏳ @robobun, your commit b01343b is still building in Build #85551, but has 1 failures so far (All Failures):

Comment thread src/jsc/bindings/highway_strings.cpp Outdated
Comment thread src/jsc/bindings/highway_strings.cpp Outdated
Comment thread src/jsc/bindings/BufferStringSearch.h
Comment thread src/jsc/bindings/BufferStringSearch.h
Comment thread src/jsc/bindings/highway_strings.cpp Outdated
Comment thread src/jsc/bindings/highway_strings.cpp
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

The remaining comment-cop flags are on algorithm documentation (Two-Way attribution, anchor-selection rationale, the SIMD kernel section header) and a two-line contract. They explain non-obvious design, not workarounds; I trimmed what could go in 847e7ea and am keeping the rest.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a Two-Way fallback search, SIMD forward and reverse searches for byte and UTF-16 buffers, Buffer integration, and adversarial performance and correctness tests.

Changes

Buffer substring search

Layer / File(s) Summary
Two-Way fallback search
src/jsc/bindings/BufferStringSearch.h
Adds forward/reverse indexing, maximal-suffix factorization, the Two-Way search kernel, and the public bun::SearchString entry point.
SIMD search kernels
src/jsc/bindings/highway_strings.cpp
Adds two-anchor SIMD filtering and verification for byte and UTF-16 searches, with Two-Way fallback handling for forward and reverse searches.
Dispatch and Buffer integration
src/jsc/bindings/highway_strings.cpp, src/jsc/bindings/JSBuffer.cpp
Exports the new Highway APIs and routes byte and UTF-16 indexOf/lastIndexOf operations through them.
Adversarial and correctness validation
test/js/node/buffer-indexof-worstcase.test.ts
Tests worst-case performance, randomized results, boundaries, includes, UTF-16 offsets, and guaranteed matches.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly names the affected APIs and the main change: rare-byte SIMD filtering with a Two-Way fallback.
Description check ✅ Passed It covers the problem, fix, reproduction, performance data, and test evidence, though the headings differ from the template.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/highway_strings.cpp`:
- Around line 928-934: Update the comments above kNotFound and kFallback to
identify kFallback as the sentinel requesting Two-Way fallback, and remove the
reference to the nonexistent BM template. Ensure the wording accurately
describes how MemMemImpl and MemRMemImpl use these sentinels.
- Around line 169-203: Avoid the unconditional 256-entry histogram setup in
MemMemPickAnchors for short needles by using the existing two-end anchor
selection below the intended length threshold, while preserving histogram-based
ranking for longer needles. Add a brief comment near bucket() documenting that
UTF-16 values are intentionally ranked by their low byte while matching remains
full-lane safe.

In `@src/jsc/bindings/JSBuffer.cpp`:
- Around line 1572-1614: Export the shared not-found sentinel used by
highway_memmem16 and highway_memrmem16 from a header alongside their
declarations, rather than keeping kNotFound private to highway_strings.cpp.
Update indexOf16, lastIndexOf16, and lastIndexOf to use that shared symbol at
all three sentinel checks, preserving the existing -1 return behavior.

In `@test/js/node/buffer-indexof-worstcase.test.ts`:
- Around line 156-158: Update the coverage comment near the deterministic
pseudo-random cross-check to remove references to the Boyer-Moore fallback and
kBMMinPatternLength. Describe only the durable input cases exercised by the
test—SIMD anchor filtering, short needles, utf16le, and byteOffset
handling—without asserting a specific search algorithm path.
- Around line 212-219: Update the test “utf16le indexOf / lastIndexOf still
correct” to pass UTF-16 strings as the needle arguments instead of Buffers,
including the missing-needle cases, so it exercises the indexOfString path
through indexOf16 and lastIndexOf16 while preserving the existing expected
offsets and -1 results.
- Around line 13-20: Update refLastIndexOf’s reverse-search start calculation to
clamp the result at zero by applying Math.max(0, ...) around the existing
upper-bound calculation. Preserve the empty-needle handling and reverse
iteration behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b44dabad-ea51-4890-bfa9-e2c78ef3bbd4

📥 Commits

Reviewing files that changed from the base of the PR and between 716d736 and 4d2f384.

📒 Files selected for processing (4)
  • src/jsc/bindings/BufferStringSearch.h
  • src/jsc/bindings/JSBuffer.cpp
  • src/jsc/bindings/highway_strings.cpp
  • test/js/node/buffer-indexof-worstcase.test.ts

Comment thread src/jsc/bindings/highway_strings.cpp
Comment thread src/jsc/bindings/highway_strings.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp
Comment thread test/js/node/buffer-indexof-worstcase.test.ts
Comment thread test/js/node/buffer-indexof-worstcase.test.ts Outdated
Comment thread test/js/node/buffer-indexof-worstcase.test.ts
Comment thread src/jsc/bindings/highway_strings.cpp
Comment thread src/jsc/bindings/highway_strings.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/jsc/bindings/highway_strings.cpp (3)

2180-2183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Attribute the Two-Way fallback implementation.

This wrapper delegates to bun::SearchString, but the fallback’s role is not documented at the handoff site. Add a short attribution identifying it as the guaranteed-linear Two-Way fallback used after the SIMD verification budget is exhausted.

Based on the supplied search-layer contract, this is the correctness and complexity backstop for the SIMD filter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/highway_strings.cpp` around lines 2180 - 2183, Add a short
attribution comment immediately above MemMemTwoWayFallback identifying
bun::SearchString as the guaranteed-linear Two-Way fallback used after the SIMD
verification budget is exhausted. Leave the wrapper’s delegation and behavior
unchanged.

154-157: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the SIMD-to-Two-Way resume contract.

start_index and is_forward are the cross-boundary handoff contract between the SIMD kernels and bun::SearchString, but their direction-specific resume semantics and miss sentinel are undocumented. Add a concise comment here so future changes cannot accidentally recheck or skip candidates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/highway_strings.cpp` around lines 154 - 157, Document the
MemMemTwoWayFallback declaration’s SIMD-to-Two-Way handoff contract, specifying
how start_index resumes searches for forward and reverse is_forward modes and
identifying the miss sentinel returned by the fallback. Keep the comment concise
and colocated with the declaration.

937-1018: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a concise SIMD-kernel section header.

This block contains the two-anchor filter, verification budget, and Two-Way handoff. A durable header immediately before MemMemForward would make the algorithm boundary clear without requiring implementation-history comments.

As per coding guidelines, comments should contain durable algorithm rationale rather than workaround narration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/highway_strings.cpp` around lines 937 - 1018, Add a concise
durable algorithm-section header immediately before MemMemForward, identifying
this block as the SIMD two-anchor filtering and verification-budget path,
including its Two-Way fallback handoff. Keep the comment focused on algorithm
boundaries and rationale; do not add implementation-history or workaround
details.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/highway_strings.cpp`:
- Around line 159-173: Update MemMemPickAnchors so its comments explicitly
distinguish the short-needle first/last-anchor policy from the least-frequency
histogram policy. Introduce a named constant for the short-needle threshold and
use it in the needle_len check, replacing the literal 16 while preserving the
existing behavior.

---

Outside diff comments:
In `@src/jsc/bindings/highway_strings.cpp`:
- Around line 2180-2183: Add a short attribution comment immediately above
MemMemTwoWayFallback identifying bun::SearchString as the guaranteed-linear
Two-Way fallback used after the SIMD verification budget is exhausted. Leave the
wrapper’s delegation and behavior unchanged.
- Around line 154-157: Document the MemMemTwoWayFallback declaration’s
SIMD-to-Two-Way handoff contract, specifying how start_index resumes searches
for forward and reverse is_forward modes and identifying the miss sentinel
returned by the fallback. Keep the comment concise and colocated with the
declaration.
- Around line 937-1018: Add a concise durable algorithm-section header
immediately before MemMemForward, identifying this block as the SIMD two-anchor
filtering and verification-budget path, including its Two-Way fallback handoff.
Keep the comment focused on algorithm boundaries and rationale; do not add
implementation-history or workaround details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53f9385e-1a51-45f6-b41b-f818e8e37c20

📥 Commits

Reviewing files that changed from the base of the PR and between 847e7ea and b9c9414.

📒 Files selected for processing (3)
  • src/jsc/bindings/JSBuffer.cpp
  • src/jsc/bindings/highway_strings.cpp
  • test/js/node/buffer-indexof-worstcase.test.ts

Comment thread src/jsc/bindings/highway_strings.cpp
Comment thread src/jsc/bindings/highway_strings.cpp Outdated
@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build #85551: all verify-baseline lanes pass, buffer-indexof-worstcase.test.ts and the existing Buffer/indexOf suites pass on every lane. The only red test is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts SIGABRT (JSC::ExceptionScope::assertNoException) on debian 13 x64-asan, which also fired on the previous build before the empty retrigger. This diff touches only the Buffer search kernels and allowlists; it has no path into worker termination or exception-scope handling. Reported that test to main-break triage. The remaining annotations (security-scanner matrix, fetch-tls-abortsignal-timeout, fastutf8stream-reopen) are marked flaky and passed on retry. Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this rewrites the process-wide memmem (aliased as libc memmem on Linux/macOS) plus three new SIMD kernels and a from-scratch Two-Way search — worth a human look.

What was reviewed:

  • SIMD load bounds in MemMemForward/MemMemReverse — both anchor loads stay within [haystack, haystack+len) for every lane.
  • kFallback → SearchString resume-index handoff for the reverse path — resume ≤ diff, so relative_start_index doesn't wrap.
  • WTF::reverseFind's notFound sentinel round-trips to -1 via the int64_t return.
  • All prior CodeRabbit / comment-cop threads are resolved at HEAD.
Extended reasoning...

Overview

Replaces Buffer#indexOf/lastIndexOf/includes's search kernel with a two-anchor rare-byte SIMD filter plus a Two-Way (Crochemore-Perrin) O(n+m) fallback. Touches: highway_strings.cpp (~320 lines: rewritten MemMemImpl, new MemMemForward/MemMemReverse templates, MemMemPickAnchors, three new MemRMem/MemMem16/MemRMem16 impls with HWY_EXPORT + C wrappers), new BufferStringSearch.h (153-line Two-Way implementation), JSBuffer.cpp (rewires indexOf16/lastIndexOf16/lastIndexOf to the new kernels), three verify-baseline allowlists, and a 238-line test file.

Security risks

The change operates on caller-supplied buffer bounds only — no parsing of untrusted headers, no allocation sized from external input. The main risk class is out-of-bounds reads in the SIMD anchor loads and memcmp verification. I traced the loop invariants for both directions and both remainder loops: with anchor_b ≤ needle_len-1 and the loop conditions i+N ≤ last_start+1 (forward) / i ≥ N before decrement (reverse), the furthest byte read is haystack_len-1. MemMemVerify is only called with pos ≤ last_start. No injection/auth surface.

Level of scrutiny

High. highway_memmem is aliased as libc memmem on Linux and macOS, so a regression here affects every in-process substring search, not just Buffer#indexOf. The Two-Way algorithm is subtle (critical factorization, periodic vs non-periodic branches, index-reversing Vector for reverse search) and is a fresh implementation rather than a vendored copy. The verify-baseline allowlist edits declare which ISA features each new per-target symbol may use — these need to match what the compiler actually emits across six x64 target tiers plus four aarch64 SVE tiers.

Other factors

Test coverage is strong (adversarial timing budgets branched on isASAN/isDebug, 1500-trial randomized cross-check, boundary sweep, utf16le for both string- and Buffer-needle paths, Two-Way periodic/non-periodic forcing) and the PR reports the existing buffer.test.js / test-buffer-indexof.js / test-buffer-includes.js suites still pass. The timing-based tests use best-of-3 and generous debug budgets, but ratio assertions on wall-clock time are inherently a flake risk on noisy CI. All CodeRabbit and comment-cop threads are resolved; my earlier stale-comment nit was fixed in 847e7ea/b9c9414. Given the blast radius (libc override, per-target SIMD codegen across all platforms) and algorithm complexity, this should get human eyes before merge.

@Jarred-Sumner
Jarred-Sumner merged commit 012a916 into main Jul 30, 2026
51 of 52 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/760d54c2/buffer-indexof-linear branch July 30, 2026 02:02
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...

# Conflicts:
#	src/jsc/bindings/BunDebugger.cpp
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...

# Conflicts:
#	src/js/internal/debugger.ts
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants