Skip to content

Yarr: lookbehind JIT (incl. unicode), alternation factoring, first-character dispatch, and Boyer-Moore search selection#299

Open
dylan-conway wants to merge 13 commits into
mainfrom
claude/yarr-regex-perf-5197
Open

Yarr: lookbehind JIT (incl. unicode), alternation factoring, first-character dispatch, and Boyer-Moore search selection#299
dylan-conway wants to merge 13 commits into
mainfrom
claude/yarr-regex-perf-5197

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Motivating case: oven-sh/bun#5197 — the isbot user-agent regex (~110 alternatives, four lookbehinds). This makes JSC faster than V8/node on it, improves large alternations generally, JIT-compiles lookbehinds — including unicode (/u, /v) patterns — teaches the Boyer-Moore search to serve alternations and unicode patterns, and fixes several pre-existing Yarr correctness bugs found along the way.

Benchmark (the issue's own script, 361,829 user-agent strings, through the bun binary):

Runtime Time
bun 1.3.14 (released; whole pattern in the bytecode interpreter) 32,283 ms
bun with this branch ~120 ms
node / V8 (same machine) ~150–165 ms

Unicode lookbehinds, which previously always fell back to the interpreter, no longer do: a /iu lookbehind alternation went from ~2.2 s to ~106 ms, an isbot-style negative lookbehind from ~750 ms to ~58 ms.

What changed

Lookbehinds are JIT-compiled. Patterns containing a lookbehind previously fell back entirely to the interpreter. Each lookbehind body is now compiled as a term-reversed mirrored copy driven through the existing forward machinery, with a per-op MatchDirection flipping only the primitives (input claim, character addressing, end-of-input, greedy signs, capture start/end slots, mirrored ^ $ \b). This covers unicode patterns too — a leftward code-point reader (matching the interpreter's tryReadBackward), backward astral literals and character classes in every quantifier form, backreferences, quantified/nested groups, and nested forward assertions inside a mirrored body. The compile-time lookbehind gate is gone; lookbehinds always JIT.

Large alternations are cheaper. Engine-neutral pattern rewrites factor shared prefixes (/aq|bx|ar|by//a(?:q|r)|b(?:x|y)/, recursing into groups) and fold large repeated bodies into one non-capturing group. In the JIT, a group's alternatives dispatch on the first character via a binary decision tree (routing to the ordered chain of alternatives that can start with it), and short literal alternatives compare inline with no frame traffic. Specialized isStringList/isTerminal group shapes are left alone, and small alternations aren't restructured. The rewrites and dispatch are always on (no options); the sizing thresholds are compile-time constants.

The Boyer-Moore search reaches patterns it used to skip. The JIT reads one character ahead of the body alternatives and skips a stride when it cannot start a match. Three things kept that from working:

  • It only scored maximal runs of candidate positions, so a short selective range was discarded whenever an adjacent position widened its union: /zalpha|qbravo|xcharlie/ is led by three rare characters, but merging the positions behind them pulls in most of the alphabet, and the search was dropped altogether instead of testing the leading characters alone. Every sub-range is now scored.
  • It was refused for every unicode pattern, because a stride counts code units and could land inside a surrogate pair. A Latin1 subject holds no surrogates whatever the flags, so it is now gated on whether the compile decodes pairs at all, which enables it for /u and /v patterns over 8-bit subjects and leaves 16-bit unicode subjects, where the hazard is real, as they were.
  • The vector search (bug 307604) was preferred wherever it applied, with nothing measuring whether it should be. Leaving its loop costs a vector-to-general-register transfer, paid once per candidate that fails to match: on arm64 it is 8x faster than the scalar loop on a subject holding no candidate and 6.5x slower once half the characters are candidates. It is now chosen by the frequency the subject sampler already collects, falling back to counting candidates when the sample is too short to rate them, so an incidental first subject cannot decide the search for the whole process.

That in turn replaced the candidate-count limit on which positions the search may test — the sampled frequency measures "matches too often" directly, and the limit rejected the best filters, leaving /([a-z0-9])([A-Z])/ over source text with no search at all because both its positions hold more characters than the limit allowed, though upper case runs at nearly zero frequency there. Positions matching every character are still excluded, for a load-bearing reason the count limit had been hiding: setAll() records them in the count alone and leaves the map empty, so merging one drops every character it matches out of the union and the search skips real match starts.

Measured on 1.5 MB subjects, against the branch without these changes: keyword-alternation scans 1.8–5.9x, /zalpha|qbravo|xcharlie/gu 4.8 ms → 1.0 ms (the unicode penalty on 8-bit subjects disappears entirely, matching the non-unicode compile exactly), case-insensitive alternations 1.7x. Regex compile time grows 4–6% on the longest patterns. No case measured across a 26-benchmark suite, 20 library patterns and a scan suite gets slower.

How much the broad-class case gains depends on how rare that class actually is in the subject, so measured against released bun 1.3.14 with a fresh process per point (the RegExp cache keys on source and flags, so one process would compile /([a-z0-9])([A-Z])/ against whichever subject came first and reuse it): 30x when the subject holds no upper case at all, 2.3x at 1%, 1.4x from 10% up. It is never slower than the released build, but a class the subject is full of is a filter that rejects nothing, and the search then only adds its own cost.

Correctness fixes (mostly pre-existing bugs the new coverage exposed):

  • ^ and \b now test the real input position instead of the "not at position 0" shortcut, which was wrong for an anchor inside a nested body; a ^ inside a lookbehind never marks startsWithBOL, and BOL bubbling from groups matches trunk exactly.
  • A nested forward assertion's body is deep-copied for the mirror, so renumbering never mutates the pattern the interpreter fallback reuses.
  • The lazy-paren "try one more iteration" progress guard is direction-aware.
  • The dotAll any-character class rejects a dangling surrogate in unicode compiles like every other class path.
  • firstCharacterAdditionalReadSize is written only by the match-start read (a stale astral flag could previously over-advance the next start, e.g. /😀|-?a/u.exec("-😀") returned null), and the astral-start skip is disabled for patterns that can match zero-width, since a zero-width alternative can legitimately match mid-surrogate-pair.
  • A quantified split's optional {0,n} copy that ran zero iterations no longer clears capture ids it shares with its mandatory sibling; and forward parens with a non-zero minimum stay a single native term, so deeply nested min>0 quantified groups no longer grow the pattern exponentially.
  • A leftward pair-lead borrow undoes its failed borrow, so a negative frontier is never left live for a sibling op.

Verification

  • test262 built-ins/RegExp: all 3,754 tests pass on both the JIT and the bytecode-interpreter tier (fresh process per test), plus the 676 String.prototype regex-method tests; JSTests regexp stress files and the LayoutTests js/regexp + fast/regex sets pass on both tiers.
  • Differential harnesses: three JIT-vs-interpreter corpora (~18 MB) byte-identical across tiers; a lookbehind/anchor/unicode corpus and a ~7,000-case cross-product matrix agree with node except for one documented pre-existing JIT-only family; the alternation sweeps (342,240 enumerated small-alternation cases, randomized lookaround corpora, string-list/terminal/capture patterns) are green.
  • Randomized fuzzing: tens of thousands of fresh-process cases across quantified groups, captures/backreferences, lookarounds, /v set operations, astral and lone-surrogate subjects, all flag combinations — no JIT-vs-interpreter divergences from this change, no crashes or hangs (including deeply nested quantified groups, forward and inside lookbehinds).
  • JSTests/stress/regexp-lookbehind-jit.js (extended with every review reproducer) passes on both tiers and matches node.
  • Boyer-Moore changes specifically: 52,000 randomized differential cases (non-unicode and /u,/v) byte-identical JIT-vs-interpreter-vs-node; test262 and the regexp stress suite unmoved on both tiers; the isbot match count is asserted, not just its timing — it is the canary that catches a search skipping real match starts, and two of the three corpora miss that failure.

Known follow-ups (deferred)

  • Skip emitting the (dead) sequential bodies of fully-inlined alternatives (code size).
  • Only fold alternations that dispatch can actually use.
  • JIT-only frame layout for the chainResume slot (interpreter frames grow by one slot per group today).
  • Deduplicate the backward-unicode "take" stanzas in the greedy/non-greedy emitters, and merge the mirror/forward-copy disjunction wrappers.
  • Let the Boyer-Moore search see through fixed counts. It bails on any {n} class or character, so /\d{4}-\d{2}/ gets no search even though the pattern layer gives its - the exact offset 4. Hand-expanding the counts measures iso-date 1.7 ms → 0.9 ms and uuid 1.1 → 0.8. Implemented and reverted: the extra positions let the score pick a worse range for /#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/, because the score rewards stride and penalizes hit rate but never models the cost of the match attempt the search exists to avoid. A cutoff sweep has no solution (that pattern needs ≤52 while keyword scans need ≥56); the model needs an attempt-cost term first.
  • An alternative's m_minimumSize ignores a quantified group's repeat count: /(?:ab){3}c/ reports 1 where /abababc/ reports 7. Safe, being an underestimate, but it caps the search's length at 1 for such patterns — the hand-expanded ipv4 is 4.2 ms against 9.4 ms for the {3} form.

Patterns containing a lookbehind previously fell back entirely to the
bytecode interpreter (JITFailureReason::Lookbehind at compile time, and a
pre-check in RegExp::compile that never even attempted the JIT). A single
lookbehind therefore sent an otherwise cheap pattern to the interpreter;
the isbot user-agent regex (~110 alternatives, four of them lookbehinds)
ran ~40x slower than with the assertions JIT'd.

A lookbehind body matches right-to-left ending at the assertion point.
Matching backward over the input is equivalent to matching a term-reversed
copy of the body forward over the reversed prefix, so each body is compiled
as a mirrored copy (terms reversed, input positions reassigned with the same
width rules as setupAlternativeOffsets) driven through the existing forward
machinery. The original YarrPattern is untouched, keeping the bytecode
fallback valid. Each op carries a MatchDirection; in a backward body the
index register is the leftward-moving left frontier, and only the primitives
flip: input claims subtract (branchSub32 on the borrow's sign bit),
characters are read at input[index + (k - 1)], end-of-input is index == 0,
greedy consumption and give-back reverse sign, captures write the real
position with start/end slots swapped (backward captures record end-first,
matching the interpreter), and BOL/EOL/word-boundary anchors get mirrored
edge tests. Nested lookbehinds are mirrored on demand.

Still handled by the interpreter, exactly as before: surrogate-pair
decoding (unicode 16-bit), backreferences inside a body, lookaheads nested
inside a body, and quantified groups with maxCount > 1 inside a body.
Character fusion (multi-char wide loads) is disabled inside backward bodies
since ascending memory holds descending pattern positions.

collectBoyerMooreInfoFromTerm now treats a parenthetical assertion as
zero-width (like BOL/EOL/word-boundary) instead of aborting collection: an
assertion consumes no input, so the leading-character sets remain a valid
over-approximation.
@dylan-conway
dylan-conway marked this pull request as ready for review July 16, 2026 09:35
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The change enables supported lookbehind patterns in the Yarr RegExp JIT through mirrored backward execution, adds direction-aware matching and dispatch infrastructure, updates alternation optimization, and introduces stress coverage comparing JIT and interpreter results.

Changes

Lookbehind JIT support

Layer / File(s) Summary
Backward frame primitives
Source/JavaScriptCore/yarr/Yarr.h, Source/JavaScriptCore/yarr/YarrPattern.h, Source/JavaScriptCore/yarr/YarrJIT.cpp
Adds direction-aware frame positions, input addressing, Unicode decoding, captures, cursor movement, and backtracking metadata.
Backward assertions and matching
Source/JavaScriptCore/yarr/YarrJIT.cpp
Updates assertions, character and character-class matching, backreferences, quantifiers, and backtracking for mirrored execution.
Lookbehind mirroring and compilation
Source/JavaScriptCore/yarr/YarrPattern.cpp, Source/JavaScriptCore/yarr/YarrJIT.cpp, Source/JavaScriptCore/yarr/YarrJIT.h, Source/JavaScriptCore/runtime/RegExp.cpp
Tracks lookbehind parser context, mirrors lookbehind bodies, enables supported JIT compilation, and preserves fallback handling for unsupported cases.
Alternation factoring and dispatch
Source/JavaScriptCore/yarr/YarrPattern.cpp, Source/JavaScriptCore/yarr/YarrJIT.cpp
Factors eligible alternation prefixes, wraps alternatives for dispatch, and adds dispatch-chain generation and backtracking.
Lookbehind stress coverage
JSTests/stress/regexp-lookbehind-jit.js
Compares JIT and interpreter results across lookbehind forms, captures, anchors, iteration, Unicode strings, backtracking, and large alternations.

Suggested reviewers: constellation, sosukesuzuki

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template sections or include the Bugzilla/reviewed-by/file-list fields. Rewrite the PR description to match the repository template: add the bug title, Bugzilla link, Reviewed by line, explanation, and file list.
✅ Passed checks (3 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 accurately summarizes the main changes: lookbehind JIT plus alternation factoring, first-character dispatch, and Boyer-Moore selection.

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

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

Actionable comments posted: 1

🤖 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 `@JSTests/stress/regexp-lookbehind-jit.js`:
- Around line 7-12: Update shouldBe to compare expected and actual values
recursively using Object.is, including each array element, so undefined captures
remain distinct from null. Preserve the existing mismatch error reporting and
message behavior while replacing the JSON.stringify-based equality check.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5f1f314-6f67-4953-aba9-cd18c8087599

📥 Commits

Reviewing files that changed from the base of the PR and between e5f7fc2 and 80c141b.

📒 Files selected for processing (3)
  • JSTests/stress/regexp-lookbehind-jit.js
  • Source/JavaScriptCore/runtime/RegExp.cpp
  • Source/JavaScriptCore/yarr/YarrJIT.cpp
💤 Files with no reviewable changes (1)
  • Source/JavaScriptCore/runtime/RegExp.cpp

Comment thread JSTests/stress/regexp-lookbehind-jit.js

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

I didn't find any bugs, but this is a substantial JIT codegen change — new backward-matching primitives, mirrored-disjunction construction, and direction-aware addressing across many term types — so it warrants a human reviewer familiar with Yarr.

What was reviewed:

  • Backward input-claim/read/end-of-input primitives and the negativePositionOffsetindex + (k-1) addressing transform.
  • Capture start/end slot swapping in backward frames vs. the interpreter's end-first recording.
  • mirrorDisjunctionForLookbehind fallback paths (backrefs, nested lookaheads, quantified groups >1, surrogate decoding) — each still sets a JITFailureReason so the interpreter path remains reachable.
  • The Boyer-Moore change treating parenthetical assertions as zero-width — checked that ignoring the assertion keeps the leading-char sets an over-approximation.
Extended reasoning...

Overview

This PR adds lookbehind-assertion support to the Yarr regex JIT. Previously, any pattern containing a lookbehind was rejected wholesale (both by a pre-check in RegExp::compile/compileMatchOnly and by JITFailureReason::Lookbehind) and fell back to the bytecode interpreter. The change compiles each lookbehind body as a term-reversed mirrored copy driven through the existing forward machinery, with a per-op MatchDirection that flips the input primitives (claim, read, end-of-input, consume/rewind, capture slot order, and BOL/EOL/word-boundary edge tests). It touches ~500 lines in YarrJIT.cpp, removes two guard lines in RegExp.cpp, and adds a new stress test.

Security risks

Regex JIT is security-sensitive: it emits machine code that indexes into string buffers. The new backward addressing (input[index + (k-1)], branchSub32 on the sign bit for input claims) must never read outside [input, input+length). The PR's argument that index and claim counts are both < 2^31 (so a borrowing sub sets the sign bit) looks sound given containsUnsignedLengthPattern() gates the JIT, and the k==0 peek case (input[index-1]) is only reached by anchors that first check index != 0. I did not find an OOB read path, but this is exactly the kind of invariant a Yarr expert should sanity-check across all the term-type call sites.

Level of scrutiny

High. This is new codegen logic in a JIT compiler with a novel coordinate transform (mirrored/virtual input positions) threaded through many existing generate/backtrack functions. The correctness surface is large — every direction-branched primitive (fixed/greedy/non-greedy pattern-char and char-class, anchors, word boundaries, nested-alternative check-adjust, parenthetical-assertion begin/end) has to agree on what index and inputPosition mean in a backward frame. The differential fuzzing (6000 patterns × 25 inputs × all flags, byte-identical vs. interpreter) and test262 lookBehind suite passing in both modes are strong evidence, but not a substitute for a design-level read by someone who owns this code.

Other factors

  • The bug-hunting system found no issues.
  • The mirrored-copy approach deliberately leaves the original YarrPattern untouched, so unsupported bodies (backrefs, nested lookaheads, maxCount > 1 groups, surrogate decoding) cleanly set m_failureReason and fall through to the pre-existing interpreter path — I verified each of those bailout branches is reachable and sets a reason before returning.
  • The Boyer-Moore change (return cursor for ParentheticalAssertion) is independently reasonable: assertions are zero-width, so skipping them keeps the leading-character sets a valid over-approximation.
  • The PR description explicitly flags follow-up refactors (moving mirroring into YarrPatternConstructor, reversed packed constants) as out of scope, which is a design decision worth a maintainer's nod.

Given the scope and the codegen/security surface, deferring to a human reviewer.

…group

A large top-level alternation is matched by attempting each alternative
in turn at every candidate position, so cost grows linearly with the
alternative count regardless of how quickly each alternative fails.
Two pattern-level rewrites cut the number of alternatives entered.

Alternation prefix factoring: alternatives are tried leftmost-first, but
their order is only observable between alternatives that can match at
the same starting position. Alternatives that must begin with different
literal characters have disjoint starting points, so a maximal run of
consecutive alternatives that each begin with a fixed, case-sensitive
literal character is stably sorted by that character (stability keeps
same-first-character alternatives in source order) and each group
sharing its leading literal is merged into one alternative whose common
prefix is hoisted over a non-capturing group of the suffixes:
/aq|bx|ar|by/ becomes /a(?:q|r)|b(?:x|y)/. Any alternative that does
not begin with such a character (class, group, anchor, optional atom,
the empty alternative) is a barrier no reordering crosses, and the
rewrite recurses into the factored suffixes.

Group folding: the body's repeated (non-once-through) alternatives, when
numerous, are folded into a single alternative holding one non-capturing
group -- /X|Y|Z/ is exactly /(?:X|Y|Z)/ -- whose capture range brackets
the captures it absorbs. Nested alternatives claim and release their own
input, giving each alternative an index-neutral entry that a following
change dispatches on the first character.

Both rewrites are engine-neutral pattern equivalences (the interpreter and
the JIT see the same factored form) and run before setupOffsets(), which
lays the synthesized groups out like hand-written ones. Controlled by
useRegExpAlternationFactoring / regExpAlternationGroupThreshold.
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
4d91f2c9 autobuild-preview-pr-299-4d91f2c9 2026-07-17 04:46:00 UTC
5cd184be autobuild-preview-pr-299-5cd184be 2026-07-17 03:30:25 UTC
1e122871 autobuild-preview-pr-299-1e122871 2026-07-16 19:00:11 UTC
b9615a17 autobuild-preview-pr-299-b9615a17 2026-07-16 14:28:30 UTC
4261b012 autobuild-preview-pr-299-4261b012 2026-07-16 11:49:41 UTC
80c141ba autobuild-preview-pr-299-80c141ba 2026-07-16 10:13:38 UTC

Trying each alternative of a nested disjunction in sequence costs an
entry attempt (claim, character load, compare, release, jump) per
alternative at every candidate position, so a large alternation scales
linearly with its alternative count even when each alternative fails on
its first character.

For a once-quantified fixed group whose alternatives all consume at
least one character, compute per alternative a first-character set (an
over-approximation of the characters a match can start with; Latin-1
tracked precisely, wider characters and the empty prefix widen to
"any"). Every alternative's first character sits at the group's start
frame position, which is inside input the enclosing alternative already
claimed, so it is read once with no bounds check. A binary decision tree
over that character's value then jumps to the ordered chain of exactly
the alternatives whose set contains it; an "any" alternative appears in
every chain, so leftmost-alternative-wins ordering is preserved. When no
alternative can start with the character the group fails immediately.

Each chain is a sequence of stubs that store the address of the next
stub into a new frame slot (BackTrackInfoParenthesesOnce::chainResume,
patched at link time) before jumping into the alternative; a failing
alternative releases its claim and continues its own chain through
that slot rather than falling to a fixed successor, and an exhausted
chain joins the group's failure flow. Entry into an alternative is now
a labelled chain target rather than a fallthrough, and backtracking
back into a matched alternative reuses the still-valid resume address.

Combined with the alternation prefix factoring, the isbot user-agent
regex over the 361,829-string corpus goes from ~620 ms (factoring
alone) to ~120 ms; the sequential JIT was ~750 ms and the interpreter
~32,000 ms. Controlled by useRegExpAlternationDispatch /
regExpAlternationDispatchThreshold; interpreter behaviour is unchanged
and JIT results match it byte-for-byte across differential corpora.
The prefix-factoring pass previously ran only over the body's
top-level alternatives, so a hand-written group such as
/\b(?:about|above|after)\b/ kept re-comparing its shared "a" prefix in
every alternative. Recurse into the disjunctions of source-written
(non-copy, forward) groups after factoring each level, so nested
alternations are factored the same way. Groups synthesized by the pass
were already factored when built; revisiting them is a no-op.
Fixes found in review of the factoring/folding passes:

- Do not restructure a nested disjunction whose group was already
  committed to a specialized code shape by checkForTerminalParentheses.
  Factoring an isStringList group turned /^(?:xa|xb|y)/ into
  [x(?:a|b), y]; the JIT's string-list path treats a non-last
  alternative's final character as a full match, and the merged
  alternative's "final character" is only the shared prefix, so
  /^(?:GET|POST|PUT|SEND)/.test("Pzz") wrongly returned true. Terminal
  groups are skipped for the same reason.

- Clear parentheses.isTerminal on every alternative absorbed into a
  synthesized group (prefix suffixes and folded body alternatives). A
  terminal parenthesis is only valid as the last term of a body
  alternative; once nested, TerminalEnd's empty-backtrack-state
  assertion no longer holds. The general once/greedy path is used.

- Compute a synthesized group's capture span from the terms it contains
  rather than per-alternative bookkeeping: sorting reorders which
  alternative is first, and the parser leaves the last body
  alternative's m_lastSubpatternId unset, so the old span could exclude
  captures the group holds (or invert subpatternId/lastSubpatternId).

- Leave small alternations unfactored (runs below the group
  threshold). The sequential JIT path there already has fused compares,
  the two-alternative SIMD scan, and frame-free inlinable code, which
  factoring would forfeit for no gain; measured /agggtaaa|acccttta/
  and /on|off/ regressions are gone.
@dylan-conway dylan-conway changed the title Yarr JIT: compile lookbehind assertions Yarr: lookbehind JIT, alternation factoring, and first-character dispatch Jul 16, 2026
A dispatched alternative that is a fixed-length literal string (only
fixed-count pattern characters) can never be backtracked into: it
matches its exact characters or it does not. Emit such alternatives as
inline compare blocks inside the chain instead of entering them
through the frame-slot resume mechanism: claim the alternative's extra
input, compare its characters at their frame offsets, and on mismatch
release the claim and fall statically to the next chain element. On a
full match the block joins the group's success path after the End op's
own return-address store, having stored a return address pointing at
an unwind thunk (release the claim, continue the chain) so that a later
failure can still backtrack into the matched literal and try the
following alternatives. This removes the per-alternative frame store,
frame load, and indirect jump for literal alternatives; complex
alternatives keep the existing resume mechanism at chain boundaries.

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

Actionable comments posted: 3

🤖 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 `@Source/JavaScriptCore/yarr/YarrJIT.cpp`:
- Around line 5198-5229: Make the dispatch backtrack handling in the
op.m_dispatch branch explicitly enforce the Forward-direction invariant before
adjusting m_regs.index, or replace the unconditional sub32 with the
direction-aware adjustment used by the non-dispatch alternative path. Anchor the
change at the op.m_checkAdjust block and preserve the existing frame-resume jump
behavior.

In `@Source/JavaScriptCore/yarr/YarrPattern.cpp`:
- Around line 2608-2647: Update the repeated-alternative eligibility check
before constructing groupDisjunction so repeatedCount == 0 returns immediately,
while preserving the existing threshold behavior for non-empty ranges. Ensure
the loop that populates groupDisjunction and its final last() access only
execute when at least one repeated alternative exists.
- Around line 2637-2664: Mark the synthesized alternative returned by
body->addNewAlternative(...) as the last alternative by setting
wrapped->m_isLastAlternative to true before appending the group term, while
preserving the existing terminal marker handling for
groupDisjunction->m_alternatives.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: de1af30c-ea73-43b9-8a5c-31469f281caa

📥 Commits

Reviewing files that changed from the base of the PR and between 80c141b and 4261b01.

📒 Files selected for processing (5)
  • Source/JavaScriptCore/runtime/OptionsList.h
  • Source/JavaScriptCore/yarr/Yarr.h
  • Source/JavaScriptCore/yarr/YarrJIT.cpp
  • Source/JavaScriptCore/yarr/YarrPattern.cpp
  • Source/JavaScriptCore/yarr/YarrPattern.h

Comment thread Source/JavaScriptCore/yarr/YarrJIT.cpp
Comment thread Source/JavaScriptCore/yarr/YarrPattern.cpp
Comment thread Source/JavaScriptCore/yarr/YarrPattern.cpp
Comment thread Source/JavaScriptCore/yarr/YarrPattern.cpp Outdated
Comment thread Source/JavaScriptCore/yarr/YarrPattern.cpp Outdated
Comment thread Source/JavaScriptCore/yarr/YarrJIT.cpp
Correctness:
- YarrPattern::atomParenthesesEnd bubbled m_startsWithBOL from a group
  whose alternatives all start with ^ onto the enclosing alternative even
  when the group was not the alternative's first term or was a negative
  assertion; and quantifyAtom kept the flag after a zero minimum count.
  optimizeBOL then marked the body once-through and the JIT tried only
  index 0. Enabling lookbehind in the JIT exposed this: /a(?<!x)(^)?$/
  .exec("xa") went from ["a",null] to null. Set the flag only for a
  first-term, non-inverted group (mirroring assertionBOL) and clear it
  when the first term becomes optional. This also fixes the pre-existing
  JIT-only /(?:^)?a/, /(^)*x/, /\B(?:^)?/ family on trunk.
- Decide the lookbehind interpreter fallback per pattern (eitherUnicode)
  rather than per subject encoding (m_decodeSurrogatePairs), so the
  8-bit and 16-bit specializations of one RegExp always run the same
  engine and give the same captures.
- Cap inlined literal alternatives at 32 characters: the inline path
  unrolls one compare per repeat, so /(?:a{200000000}|b|c|d)/ drove a
  compile-time OOM. Long/counted literals keep the entered path, whose
  fixed-count loop runs at match time.
- Never fold fewer than two repeated alternatives into a group;
  regExpAlternationGroupThreshold=0 previously dereferenced an empty
  synthesized group on all-once-through bodies.

Cleanup:
- Add the missing WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo).
- Drop the unreachable entryBound/entries direct-jump path (every stub
  target is a forward reference), the never-read DispatchInfo::sets
  member, and the provably-empty endOp->m_jumps append under dispatch.
- Correct the regExpAlternationGroupThreshold description (it gates
  prefix factoring at every level as well as the top-level fold).

Adds the reviewer's reproducers to regexp-lookbehind-jit.js.

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

♻️ Duplicate comments (1)
Source/JavaScriptCore/yarr/YarrPattern.cpp (1)

2613-2680: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

wrapped alternative never gets m_isLastAlternative = true.

The prior crash-on-empty-group finding (repeatedCount < 2 || guard) is fixed here. However, the new body-level alternative created at Line 2669 (wrapped) is the only remaining alternative after alternatives.shrink(firstRepeated) appends it, yet nothing sets wrapped->m_isLastAlternative = true. Every absorbed alternative had this flag explicitly cleared (Line 2652), and the codebase's consistent convention (checkForTerminalParentheses, atomParenthesesEnd, factorAlternatives, mergeSharedPrefix, and this same function's own groupDisjunction->m_alternatives.last()->m_isLastAlternative = true at Line 2660) confirms addNewAlternative does not default this to true. Net effect: after this transform, no alternative in body->m_alternatives is marked as last.

🐛 Proposed fix
     PatternAlternative* wrapped = body->addNewAlternative(hasCaptures ? firstCaptureId - 1 : m_pattern.m_numSubpatterns);
     wrapped->m_lastSubpatternId = groupLastSubpatternId;
     wrapped->m_containsBOL = containsBOL;
     wrapped->m_startsWithBOL = startsWithBOLCount == groupDisjunction->m_alternatives.size();
+    wrapped->m_isLastAlternative = true;
     groupDisjunction->m_parent = wrapped;
#!/bin/bash
# Confirm the default value of m_isLastAlternative and how it's consumed for body-level alternatives.
rg -n 'm_isLastAlternative' Source/JavaScriptCore/yarr/YarrPattern.h Source/JavaScriptCore/yarr/YarrJIT.cpp Source/JavaScriptCore/yarr/YarrInterpreter.cpp
🤖 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 `@Source/JavaScriptCore/yarr/YarrPattern.cpp` around lines 2613 - 2680, Mark
the newly created body-level alternative as terminal in
wrapAlternativesForDispatch by setting wrapped->m_isLastAlternative to true
after addNewAlternative. Preserve the existing terminal marking for the nested
group’s final alternative.
🤖 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.

Duplicate comments:
In `@Source/JavaScriptCore/yarr/YarrPattern.cpp`:
- Around line 2613-2680: Mark the newly created body-level alternative as
terminal in wrapAlternativesForDispatch by setting wrapped->m_isLastAlternative
to true after addNewAlternative. Preserve the existing terminal marking for the
nested group’s final alternative.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 159f500d-9431-4417-ae9e-a88a6e336ff1

📥 Commits

Reviewing files that changed from the base of the PR and between 4261b01 and b9615a1.

📒 Files selected for processing (4)
  • JSTests/stress/regexp-lookbehind-jit.js
  • Source/JavaScriptCore/runtime/OptionsList.h
  • Source/JavaScriptCore/yarr/YarrJIT.cpp
  • Source/JavaScriptCore/yarr/YarrPattern.cpp

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

Thanks — the fixup commit addresses the two functional items from the last pass (the repeatedCount < 2 guard in wrapAlternativesForDispatch and WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo)). No new issues found on this revision, but this is a large change to Yarr JIT codegen and pattern rewriting that should get a human review.

Checked this round:

  • Mirrored-frame primitives (claim/release, negativePositionUntilInputBegin addressing, capture start/end swap, BOL/EOL/\b) for forward/backward symmetry.
  • wrapAlternativesForDispatch: the concern that the synthesized body alternative lacks m_isLastAlternative was examined and ruled out — the flag is not load-bearing on the body disjunction path.
  • tryPrepareDispatch gating (Forward-only, FixedCount 1:1, non-terminal/non-string-list, m_minimumSize > 0) vs. the assumptions the dispatch generate/backtrack paths make.
  • The remaining open inline comment (duplicate comment sentence) is cosmetic only.
Extended reasoning...

Overview

This PR adds three substantial features to the Yarr regex engine across ~1,500 lines of new logic in YarrJIT.cpp and YarrPattern.cpp: (1) JIT compilation of lookbehind assertions via term-reversed "mirrored" bodies with a per-op MatchDirection that flips the input-claim / character-addressing / anchor / capture primitives; (2) an engine-neutral pattern rewrite pass (factorAndWrapAlternatives) that stably sorts literal-leading alternatives, hoists shared prefixes into synthesized (?:…) groups, and folds large top-level alternations into a single group; and (3) a first-character dispatch scheme in the JIT that reads one character, routes through a binary decision tree to per-character chains, and threads chain continuation through a new BackTrackInfoParenthesesOnce::chainResume frame slot. It also removes the blanket m_containsLookbehinds JIT bailout in RegExp.cpp, adds four new Options, bumps YarrStackSpaceForBackTrackInfoParenthesesOnce 2→3, and fixes a pre-existing m_startsWithBOL bubbling bug in atomParenthesesEnd/quantifyAtom.

Prior review and current state

My previous run flagged three items. The fixup commit (b9615a1) addressed the two functional ones: wrapAlternativesForDispatch now guards repeatedCount < 2 so --regExpAlternationGroupThreshold=0 on an all-once-through body no longer reaches .last() on an empty vector, and WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo) is now present. The third (a duplicated comment sentence) remains and is purely cosmetic. This run's finder/verifier pass raised and refuted the "body left with no m_isLastAlternative" concern for wrapAlternativesForDispatch — the body-disjunction path in the JIT does not rely on that flag the way nested alternatives do. No other findings.

Security risks

Regex JIT codegen is security-sensitive: it emits machine code that reads attacker-controlled input strings against attacker-controlled patterns. The new backward addressing (input[index + (k-1)] with k==0 mapping to index-1), the signed borrow-based bounds check, the frame-slot indirect jump through chainResume, and the pattern-tree restructuring (reparenting, capture-span recomputation, terminal-mark clearing) all have OOB-read / control-flow-hijack surface if any invariant is off by one. Nothing concrete was found, and the PR's differential harnesses (JIT vs. interpreter over hundreds of thousands of cases) plus the full stress/test262 pass are strong evidence — but this is exactly the kind of change where a second pair of expert eyes on the index arithmetic and frame layout is warranted.

Level of scrutiny

High. This is production-critical engine code (every regex in every Bun program), the diff is large, it introduces new JIT control-flow patterns (patched frame stores driving indirect jumps), and it changes observable regex semantics for a family of BOL-anchored patterns (a bug fix, but still a behavior change). It is well outside the "simple/mechanical" bar for auto-approval.

Other factors

The verification story is unusually thorough (randomized differential fuzzing against both the interpreter and node, enumerated small-alternation coverage, full stress + test262), and everything is behind runtime options with sensible defaults. The design is internally well-documented. Outstanding non-blocking bot comments remain (CodeRabbit's defensive-assert suggestion on the dispatch backtrack path, the test helper's undefined-vs-null comparison, and my comment-cosmetics nit). None of those block merge, but the scope alone means this needs a human reviewer familiar with Yarr.

…f narrowing BOL bubbling

Revert the m_startsWithBOL edits from the previous commit and gate instead.

An alternative's m_startsWithBOL is not just an anchoring hint: optimizeBOL
uses it to exclude the alternative from the loop copy, and copyTerm's
filterStartsWithBOL silently drops BOL-anchored sub-alternatives when
copying. That dropping is only semantics-preserving because the eager
bubbling in atomParenthesesEnd guarantees such alternatives never reach
the loop copy. Narrowing the bubbling (as the previous commit did) let
/.(^)X/ reach the loop copy, where the (^) group was deleted and the
pattern over-matched ("aX" against /.(^)X/) -- worse than the bug it
was addressing. So restore trunk's bubbling exactly.

The lookbehind JIT still must not newly route these patterns into the
JIT's stricter once-through handling (a pre-existing JIT-only defect the
interpreter tolerates). Record when a group's alternatives all start
with BOL (m_containsBOLGroupBubble) and keep lookbehind patterns with
such a bubble on the interpreter, i.e. their exact previous behaviour.
Bare ^ alternatives (as in the isbot pattern) do not set the flag, so
the target case still JITs.

The (?:^)?a family without lookbehind is a pre-existing upstream JIT
defect (interpreter and V8 agree); the stress test now pins its
per-tier output rather than deleting the cases.

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
Source/JavaScriptCore/yarr/YarrPattern.cpp (1)

2665-2673: ⚠️ Potential issue | 🟠 Major

Restore the terminal marker on the synthesized body alternative.

wrapped is created without m_isLastAlternative, while the previous terminal marker was cleared from the moved alternatives. The body can therefore have no terminal alternative. Set wrapped->m_isLastAlternative = true before appending the group.

Proposed fix
 PatternAlternative* wrapped = body->addNewAlternative(hasCaptures ? firstCaptureId - 1 : m_pattern.m_numSubpatterns);
 wrapped->m_lastSubpatternId = groupLastSubpatternId;
+wrapped->m_isLastAlternative = true;
 wrapped->m_containsBOL = containsBOL;
🤖 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 `@Source/JavaScriptCore/yarr/YarrPattern.cpp` around lines 2665 - 2673, Set
wrapped->m_isLastAlternative to true immediately after creating the synthesized
alternative in the surrounding group-wrapping logic, before appending the
ParenthesesSubpattern group, so the synthesized body retains the
terminal-alternative marker.
🤖 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 `@JSTests/stress/regexp-lookbehind-jit.js`:
- Around line 148-160: Update the pre-existing JIT defect comment above the
useRegExpJIT assertions to include a link or identifier for a tracking issue
covering the three divergent regular-expression cases. Keep the existing
per-tier expectations and test logic unchanged.

---

Duplicate comments:
In `@Source/JavaScriptCore/yarr/YarrPattern.cpp`:
- Around line 2665-2673: Set wrapped->m_isLastAlternative to true immediately
after creating the synthesized alternative in the surrounding group-wrapping
logic, before appending the ParenthesesSubpattern group, so the synthesized body
retains the terminal-alternative marker.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: beb7c340-33af-444d-b0b3-9869b71be2aa

📥 Commits

Reviewing files that changed from the base of the PR and between b9615a1 and 1e12287.

📒 Files selected for processing (4)
  • JSTests/stress/regexp-lookbehind-jit.js
  • Source/JavaScriptCore/yarr/YarrJIT.cpp
  • Source/JavaScriptCore/yarr/YarrPattern.cpp
  • Source/JavaScriptCore/yarr/YarrPattern.h

Comment thread JSTests/stress/regexp-lookbehind-jit.js Outdated

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

Thanks for the fixes in b9615a1 — the repeatedCount < 2 clamp and WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo) are both in place now. I didn't find any new issues in this revision, but given the scope (backward-matching JIT primitives, engine-neutral pattern rewrites, first-character dispatch codegen and backtracking) this really wants eyes from someone who knows Yarr well.

Checked this run: the new m_containsBOLGroupBubble fallback keeps BOL-group lookbehind patterns on the interpreter without narrowing which non-lookbehind patterns reach the JIT; the synthesized wrapper alternative's unset m_isLastAlternative was examined and found harmless for body-level consumers; the test's JSON.stringify comparator masking undefined vs null was noted as a test-quality nit only. The remaining open inline notes (duplicate comment line, dispatch-backtrack direction assert) are cosmetic.

Extended reasoning...

Overview

This PR adds ~1,500 lines to YarrJIT.cpp and ~340 to YarrPattern.cpp, spanning three interacting features: (1) JIT compilation of lookbehind bodies via term-reversed mirroring with a per-op MatchDirection that flips input-claim/read/anchor/capture primitives; (2) an engine-neutral pattern rewrite that stably sorts and factors literal-leading alternatives and folds large top-level alternations into a synthetic (?:...) group; and (3) a first-character dispatch JIT path that reads one character, routes through a binary decision tree to per-character chains, and threads failure through a new chainResume frame slot. It also adds four runtime options, grows BackTrackInfoParenthesesOnce by one slot, adds a m_containsBOLGroupBubble pattern flag, and includes a stress test.

Security risks

This is JIT code generation for attacker-controlled input (regex patterns and subject strings) in a JavaScript engine. The backward-matching path introduces new signed-arithmetic index handling and a new BaseIndex addressing mode; the dispatch path stores and indirect-jumps through code addresses in the match frame; the pattern rewrite restructures the AST that downstream passes (setupOffsets, terminal/string-list optimizations, JIT op-compile) consume. Any of these could plausibly harbor OOB reads, index-register corruption, or backtracking-state confusion that only manifests on specific pattern shapes. The differential harnesses described in the PR are strong evidence of correctness, but this class of change is exactly where subtle exploitable bugs hide.

Level of scrutiny

High. This is neither mechanical nor small; it touches production-critical, security-sensitive codegen with many interacting invariants (frame offsets, checked-input accounting, direction state, capture-span bookkeeping, terminal/string-list marks). It is well outside the "approve without human review" bar.

Other factors

Since my previous inline comments the author pushed a fix commit addressing the threshold=0 crash and the missing TZone _IMPL, plus a follow-up (1e122871) that replaces the earlier BOL-bubbling narrowing with a per-pattern m_containsBOLGroupBubble flag so lookbehind patterns of that shape stay on the interpreter — a more conservative fix. The bug-hunting system found nothing this run; verifiers specifically examined and refuted the "wrapper alternative not marked m_isLastAlternative" concern and the test comparator's undefined/null conflation. The remaining open inline items (a duplicated comment sentence in wrapAlternativesForDispatch, and CodeRabbit's suggestion to assert Forward direction in the dispatch backtrack) are non-blocking cosmetics. Deferring to a human Yarr reviewer.

…nery

Lookbehind bodies are compiled as term-reversed mirrored copies through the
forward generator with Backward ops, so unicode (surrogate-pair) lookbehinds
no longer fall back to the interpreter. This adds leftward decoding
(readUnicodeCharBackward, matching the interpreter's tryReadBackward),
backward astral literals and character classes for every quantifier form,
backreferences and quantified/nested groups inside mirrored bodies, and
nested forward assertions renumbered from their mirrored parent position.
The lookbehind compile gate and its options are removed.

Correctness work required to make that sound:
- General real-position tests for ^ and \b (index == checked - pos)
  replace the "not at position 0" shortcuts, which are wrong for an anchor
  whose alternative is numbered from a nonzero origin; ^ inside a lookbehind
  never marks startsWithBOL, and quantifying a BOL group {0} withdraws the
  anchor before the term is elided.
- A nested forward assertion's body is deep-copied for the mirror so
  renumbering never mutates the pattern the interpreter fallback reuses.
- The NonGreedy paren "try one more iteration" guard is direction-aware.
- The dotAll any-character class rejects a dangling surrogate in unicode
  compiles like every other class path.
- The first-non-BMP-character register is written only by the match-start
  read (ambient-off, enabled while emitting that one term), so loop peeks
  and backtrack re-reads can't leave a stale astral flag that over-advances
  the next start; the whole optimization is additionally disabled for
  patterns that can match zero-width, since a zero-width alternative can
  legitimately succeed at a mid-surrogate-pair position that the skip drops.
- Quantified splits (X{min,max} -> mandatory copy + optional {0,max-min}
  copy sharing X's capture ids): the optional copy's abandoned iteration
  clears the shared capture, and control re-emerges through the mandatory
  group's End, so each paren End emitter re-establishes a copy owner's
  entry-side capture endpoint from that iteration's saved start index
  (parentheses.hasCopySibling). Backward parens now take this same path.
- A leftward pair-lead borrow undoes its failed borrow before joining the
  failure jumps so a negative frontier is never left live for a sibling op.

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

Beyond the two inline nits, I also checked the open coderabbit concern about wrapped->m_isLastAlternative in wrapAlternativesForDispatchm_isLastAlternative is only read for {Simple,}Nested/StringList alternative ops (YarrJIT.cpp:4792/4826), never for body alternatives, so leaving it unset on the synthesized body alternative is harmless. The earlier regExpAlternationGroupThreshold=0 and missing WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo) findings are addressed in the current diff.

Extended reasoning...

The two new inline findings are cosmetic (a decimal-vs-hex mismatch in a verboseRegExpCompilation dump, and two empty #if/#endif pairs left after removing their bodies). The separately-raised concern that wrapAlternativesForDispatch leaves the body with no m_isLastAlternative marker was verified not to matter: that flag is consulted only in the nested/string-list alternative codegen paths, and the synthesized alternative is a body alternative compiled via BodyAlternativeBegin/Next/End, which never reads it.

Comment thread Source/JavaScriptCore/yarr/YarrJIT.cpp Outdated
Comment thread Source/JavaScriptCore/yarr/YarrJIT.cpp Outdated
@dylan-conway dylan-conway changed the title Yarr: lookbehind JIT, alternation factoring, and first-character dispatch Yarr: lookbehind JIT (incl. unicode), alternation factoring, and first-character dispatch Jul 17, 2026
- Print non-printable first-set characters in hex in the
  verboseRegExpCompilation dump; the \x prefix was followed by a
  decimal value.
- Remove the two #if/#endif pairs left empty when the ambient
  first-non-BMP-character scopes were deleted, and a duplicated draft
  comment line in wrapAlternativesForDispatch.
- In regexp-lookbehind-jit.js, compare results with a strict deep
  equality that distinguishes an undefined array element (a
  non-participating capture) from null. JSON.stringify maps both to
  null, so a wrongly-nulled capture could have passed the
  capture-clearing assertions.

No-Verification-Needed: comment, verbose-dump, and test-only edits; no product code path changes
Comment on lines +3738 to +3757
if (m_direction == Backward && m_decodeSurrogatePairs) {
// Backward + unicode: read the code point that ends at the term's
// position (its last unit is at frame offset checkedOffset - position).
// The class match runs first and leaves the frontier alone, preserving
// `character` for the width test just as the forward path does;
// beginIndex (stored above) restores the frontier on backtrack.
readUnicodeCharBackward(negativeOffsetIndexedAddress(op.m_checkedOffset - term->inputPosition, character), character, scratch);
matchCharacterClassTermInner(term, op.m_jumps, character, scratch);
// A static-width class was laid out at its true width (2 units when its
// members are all astral) and its claim already covers the whole pair.
// Only a variable-width class was budgeted a single unit, so only it
// grows the leftward claim by one when the code point was a pair
// (failing when the frontier is exhausted).
if (!term->isFixedWidthCharacterClass()) {
MacroAssembler::Jump isBMPChar = m_jit.branch32(MacroAssembler::LessThan, character, MacroAssembler::TrustedImm32(0x10000));
claimBackwardPairLead(op.m_jumps); // claim the pair's lead unit
isBMPChar.link(&m_jit);
}
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: readUnicodeCharBackward is defined inside #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) (L1525–L1715), but the five backward-unicode call sites here and at L3848, L3990, L4089, L4162 are not wrapped in that guard — only the sixth caller (readCharacterForDirection, L1743) is. m_decodeSurrogatePairs is a runtime const bool, so the compiler still resolves the name; on a target with ENABLE(YARR_JIT) but without ENABLE_YARR_JIT_UNICODE_EXPRESSIONS (only ARM64/X86_64/RISCV64 set it) these fail to compile. Bun's x64/arm64 CI is unaffected — same class as the earlier TZONE_MALLOC finding — but wrapping each block in the guard would match the file's convention (the immediately adjacent forward-path m_decodeSurrogatePairs blocks all use it).

Extended reasoning...

What the bug is

readUnicodeCharBackward() is defined at YarrJIT.cpp:1564, inside the #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) block that opens at L1525 and closes at L1715. This PR adds six call sites; one of them — in readCharacterForDirection (L1743–1749) — is correctly wrapped in #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) / #endif. The other five are not:

  • L3744 (generateCharacterClassOnce) — sits between #endif at L3736 and #if at L3763
  • L3848 (generateCharacterClassFixed)
  • L3990 (generateCharacterClassGreedy)
  • L4089 (backtrackCharacterClassGreedy)
  • L4162 (backtrackCharacterClassNonGreedy)

Each is inside a runtime if (m_direction == Backward && m_decodeSurrogatePairs) { ... } block, but m_decodeSurrogatePairs is declared as const bool m_decodeSurrogatePairs : 1; (L9022) — a runtime member bitfield, not a compile-time constant — so the compiler must still perform name lookup on readUnicodeCharBackward regardless of whether the branch is ever taken.

The code path that triggers it

Per PlatformEnable.h:949–951, ENABLE_YARR_JIT_UNICODE_EXPRESSIONS is defined only for CPU(ARM64) || CPU(X86_64) || CPU(RISCV64), whereas ENABLE_YARR_JIT (L924–925) is on for any ENABLE(JIT) port. On a target where ENABLE(YARR_JIT) is on but ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) is off (e.g. ARM_THUMB2 in an upstream WebKit config), readUnicodeCharBackward is preprocessed away, and these five call sites fail to compile with an undeclared-identifier error.

Why nothing prevents it

The runtime early-bail at L8435–8439 (which returns JITFailureReason::DecodeSurrogatePair when m_decodeSurrogatePairs under !ENABLE(YARR_JIT_UNICODE_EXPRESSIONS)) does not help: that guarantees the branch is never taken at runtime, but this is a compile-time name-lookup failure. The file's own convention is to wrap every m_decodeSurrogatePairs block that calls a unicode-only helper in the guard — the immediately adjacent forward-path blocks at L3724–3736, L3763–3770, L3811–3835, L4007–4010, L4102–4105, L4178–4181 all do so, and the sixth backward call site (L1743–1749) does too. The five character-class sites are the only ones that break the convention.

Step-by-step proof

  1. Build with ENABLE(YARR_JIT)=1 and ENABLE(YARR_JIT_UNICODE_EXPRESSIONS)=0 (any CPU other than ARM64/X86_64/RISCV64 with the JIT enabled).
  2. The preprocessor removes L1525–1715, so readUnicodeCharBackward has no declaration.
  3. Compiling generateCharacterClassOnce reaches L3738: if (m_direction == Backward && m_decodeSurrogatePairs) {. Since m_decodeSurrogatePairs is a non-constexpr member, the compiler must compile the body.
  4. L3744 references readUnicodeCharBackward, which is undeclared → compilation error.
  5. The same happens at the other four sites.

Contrast with L1741–1749 in the same PR: readCharacterForDirection wraps its identical if (m_direction == Backward && m_decodeSurrogatePairs) { readUnicodeCharBackward(...); ... } block in #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) / #endif and compiles cleanly.

Impact

None on Bun's shipped builds: Bun's CI targets x64/arm64 only, both of which enable YARR_JIT_UNICODE_EXPRESSIONS; and this fork's PlatformEnable.h forces ENABLE_JIT=0 on 32-bit, so no default configuration in this repo actually breaks. This is the same class of issue as the WTF_MAKE_TZONE_ALLOCATED_IMPL(DispatchInfo) finding already accepted and fixed in this PR — a divergence from the file's defensive-guard convention that breaks a non-default (or upstream) build configuration.

Fix

Wrap each of the five if (m_direction == Backward && m_decodeSurrogatePairs) { ... } blocks in #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) / #endif, matching readCharacterForDirection and the adjacent forward-path blocks. (Alternatively, moving the readUnicodeCharBackward definition outside the guard would also work, but the per-site guard is the file's established convention.)

…t unicode subjects

The JIT's Boyer-Moore search reads one character and skips a stride of positions
when that character cannot start a match. Choosing the range to test only ever
scored maximal runs of positions under a candidate-count limit, so a short and
highly selective range was thrown away whenever an adjacent position widened its
union. /zalpha|qbravo|xcharlie/ is led by three rare characters, but merging the
positions behind them pulls in most of the alphabet; the merged set then matches
almost every character, scores below the 50% cutoff, and the search was dropped
altogether rather than falling back to testing the leading characters alone.

Score every sub-range instead. The candidate-count eligibility is kept, since a
position matching many characters hits far more often than its sampled frequency
suggests once the pattern itself matches often. Extending a range only adds
characters, so a range is abandoned as soon as its match probability turns
negative: a longer stride can only scale an already-negative score further down.
A range whose union is empty is skipped, because the search needs at least one
character to test and a frequency of zero would otherwise outscore every real
range; an 8-bit compile of a class holding only characters above Latin1 produces
exactly that, and no alternative can match there at all.

The search was also refused for every unicode pattern, because a stride counts
code units and could land inside a surrogate pair. A Latin1 subject holds no
surrogates whatever the pattern's flags, and the collection code already keys off
m_decodeSurrogatePairs, so gate the search on that rather than on the flag. This
enables it for /u and /v patterns matched against 8-bit subjects and leaves
16-bit unicode subjects, where the hazard is real, exactly as they were.

Alternation scans over 1.5MB of prose speed up by 2.4-5.9x, and the unicode
penalty on 8-bit subjects disappears: /zalpha|qbravo|xcharlie/gu goes from 4.8ms
to 1.0ms, matching its non-unicode compile. Compile time grows 4-6% on the
longest patterns. Matches are unchanged -- the JIT and the interpreter stay
byte-identical across 52,000 randomized differential cases and three 6MB corpora.
Comment on lines 2596 to 2597

#if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP)
// Prevent word boundary assertion reads from corrupting firstCharacterAdditionalReadSize.
SetForScope useOptimizationScope(m_useFirstNonBMPCharacterOptimization, false);
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: the fixup commit 4d91f2c removed the empty #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP) / #endif pairs, but the blank lines that flanked each guard were both kept, leaving two consecutive blank lines here (between scratch = m_regs.regT1; and MacroAssembler::Jump atBegin;) and again in matchBackreference at ~L2677-2678 (between unsigned duplicateNamedGroupId = ...; and MacroAssembler::Label loop(&m_jit);). Purely cosmetic — drop one blank line at each site.

Extended reasoning...

What

An earlier review comment on this PR pointed out that removing the SetForScope useOptimizationScope(...) bodies had left two empty #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP) / #endif pairs. The fixup commit 4d91f2c deleted those two guard lines at each site — but each guard had been separated from its neighbours by a blank line above and a blank line below, and both of those blank lines were context (unchanged) in the fixup. With the guard gone, the two flanking blanks are now adjacent.

Where

  • generateAssertionWordBoundary, YarrJIT.cpp:2596-2597 — between const MacroAssembler::RegisterID scratch = m_regs.regT1; and MacroAssembler::Jump atBegin;.
  • matchBackreference, YarrJIT.cpp:2677-2678 — between unsigned duplicateNamedGroupId = m_pattern.hasDuplicateNamedCaptureGroups() ? ... : 0; and MacroAssembler::Label loop(&m_jit);.

Step-by-step

  1. Before 4d91f2c, generateAssertionWordBoundary read:
            const MacroAssembler::RegisterID scratch = m_regs.regT1;
    
    #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP)
    #endif
    
            MacroAssembler::Jump atBegin;
  2. The fixup deleted the two #if/#endif lines. The blank line above and the blank line below were both context, so both survive.
  3. Post-fixup source (verified with cat -A) shows lines 2596 and 2597 are both empty:
            const MacroAssembler::RegisterID scratch = m_regs.regT1;$
    $
    $
            MacroAssembler::Jump atBegin;$
    
  4. The identical edit shape appears in matchBackreference; cat -A on lines 2677-2678 likewise shows two consecutive $-only lines.

Why nothing prevents it / impact

Consecutive blank lines are legal C++ and have no effect on compilation or generated code, so no compiler, linter, or test in CI flags them. This is purely cosmetic editing debris introduced by this PR's own fixup commit — no functional impact whatsoever.

Fix

Delete one blank line at each of the two sites (two lines total).

…pear

The JIT has two Boyer-Moore searches: a vector one reading sixteen characters per
iteration, and a scalar one reading a character per iteration. The vector search
was preferred wherever it applies, but leaving its loop costs a vector to general
purpose register transfer, so it pays that price once per candidate that fails to
match. It wins by 8x on a subject holding no candidate at all and loses by 6.5x
once half the characters are candidates, and nothing measured which side of that
it was on.

How often a candidate fails is not known when compiling, so use the frequency the
subject sampler already collects for choosing the search range. The sampler scales
its count to a rate, which a subject shorter than the sample cannot resolve -- one
candidate in four characters reads as a rate of 32 -- and such a subject says
little about the later ones a compile is reused for, since a RegExp keeps the
first one's code. Count the candidates instead in that case, as an absent sample
already does, so an incidental first subject cannot decide the search for the
whole process.

That choice then replaces the candidate-count limit on which positions the range
search may test. The limit stood in for "this set matches too often", but the
sampled frequency measures that directly, and the cost it was really standing in
for was the vector loop's. It also rejected the best filters: /([a-z0-9])([A-Z])/
over source text was left with no search at all because both of its positions hold
more characters than the limit allows, though upper case runs at nearly zero
frequency there and rejects every position at once.

Positions matching every character still have to be excluded, for a different and
load-bearing reason: setAll() records them in the count alone and leaves the map
empty, so merging one drops every character it matches out of the union and the
search skips positions that can start a match. The count limit had been hiding
that, since such positions count as the whole map. Test it directly instead.

/([a-z0-9])([A-Z])/ over source text goes from 2.2ms to 0.1ms, keyword alternation
scans get 1.8-2.4x faster, and no case measured across a 26 benchmark suite, 20
library patterns and a scan suite gets slower. Matches are unchanged: the JIT and
the interpreter stay byte-identical over 34,000 randomized differential cases and
three 6MB corpora, and test262 and the regexp stress tests are unmoved on both
tiers. The crossover was measured on arm64.
@dylan-conway dylan-conway changed the title Yarr: lookbehind JIT (incl. unicode), alternation factoring, and first-character dispatch Yarr: lookbehind JIT (incl. unicode), alternation factoring, first-character dispatch, and Boyer-Moore search selection Jul 17, 2026

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

No new issues found in this pass, but this is a large change to the Yarr JIT — new backward-matching codegen for lookbehinds (including unicode), pattern-level alternation rewrites, first-character dispatch, frame-layout growth, and a Boyer-Moore search rewrite — so it needs a human reviewer.

What was reviewed:

  • Direction-aware input primitives and negativeOffsetIndexedAddress backward mapping — offsets stay within claimed input and the negative-frontier borrow is undone on failure.
  • factorAlternatives / wrapAlternativesForDispatch — stable sort preserves leftmost-first ordering within same-first-character groups; terminal/string-list shapes are left untouched.
  • Frame-size bumps in Yarr.h line up with the new BackTrackInfoParenthesesOnce::chainResume and BackTrackInfoBackReference::backwardSpanEdge slots.
  • findBestCharacterSequence rewrite — empty-union ranges are skipped so an all-above-Latin1 class can't score as the best window.
Extended reasoning...

Overview

The PR spans seven files but the substance is in YarrJIT.cpp (~1.8k new lines) and YarrPattern.cpp (~400 new lines). It: (1) removes the compile-time lookbehind gate in RegExp.cpp and JIT-compiles lookbehind bodies via a term-reversed mirror driven through the existing forward machinery, with a per-op MatchDirection flipping the input-claim / addressing / end-of-input / capture-slot primitives; (2) adds a leftward unicode code-point reader and backward variants of every character-class and pattern-character quantifier form; (3) adds engine-neutral alternation prefix factoring and a body-folding wrapper in the pattern constructor; (4) adds first-character dispatch for once-groups in the JIT (binary decision tree over Latin-1 → per-character chain of applicable alternatives, with a new chainResume frame slot); (5) rewrites the Boyer-Moore sub-range scoring and adds a subject-frequency gate on the SIMD search loop; and (6) fixes several pre-existing correctness bugs (BOL/word-boundary position tests, firstCharacterAdditionalReadSize scoping, quantified-split capture clearing, dotAll dangling-surrogate rejection).

Security risks

Regex JIT is memory-safety–critical. The new backward addressing path computes input[index + (k-1)] from a leftward-moving frontier and relies on signed-branch underflow detection for input claims; an off-by-one in the offset mapping or a missed borrow-undo would be an OOB read against attacker-controlled subject strings. The dispatch machinery stores and later jumps through a code address in the runtime frame (chainResume), which is the same shape as the existing returnAddress slot but is a new indirect-jump surface. The alternation-factoring rewrite mutates the pattern tree (reparenting, capture-range recomputation) before offset assignment; a mis-bracketed capture span would corrupt the output vector. I did not find a concrete flaw in any of these paths, and the PR's differential and fuzz coverage is extensive, but the surface area is exactly the kind that has historically produced exploitable JIT bugs.

Level of scrutiny

High. This is production-critical engine code, not sandbox or tooling, and it removes a safety fallback (lookbehinds previously always ran in the interpreter). The mirroring design, the frame-layout changes shared with the interpreter, and the pattern-rewrite semantics are all architectural decisions a maintainer should sign off on.

Other factors

The verification story is strong — full test262 RegExp on both tiers, three JIT-vs-interpreter differential corpora, a large enumerated small-alternation sweep, and randomized fuzzing across quantifiers/lookarounds/unicode — and the added regexp-lookbehind-jit.js stress file pins the reproducers. All prior review threads are resolved; the two inline nits still open (unguarded readUnicodeCharBackward call sites on non-x64/arm64/riscv64 builds, and a cosmetic double blank line) don't affect Bun's shipped configs. Net: nothing blocking from automated review, but scope and risk class put this well outside auto-approval.

The search may not span a position that matches every character: setAll() records one in its
count alone and leaves its map empty, so merging it drops every character it matches out of
the range's union and the search then skips positions that can start a match. Nothing states
that invariant, and nothing tested it -- all eight existing bm-search tests pass against a
build that violates it, while "bab".match(/.(a)./) returns null.

Every case expects a match, because each failure in this class is a false negative and an
expectation of null would be exactly what a broken search produces. Each is written with
single dots rather than a quantified count, since a count aborts collection before any
position is recorded and would leave nothing to get wrong: of an earlier draft's assertions,
only the unquantified ones failed against the violating build. The remaining cases cover the
search now being available to unicode patterns over Latin1 subjects.

No-Verification-Needed: test-only; the engine change it covers landed with its own verification

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

No new issues found this pass, but this is a ~2,000-line change to the Yarr JIT's core codegen and pattern layer (mirrored lookbehind compilation, direction-aware primitives, alternation prefix factoring/reordering, first-character dispatch chains, Boyer-Moore range scoring), so it needs a human reviewer.

What was reviewed:

  • Direction-aware input-claim/addressing/backtrack primitives and the mirrored-body offset assignment — checked that forward paths are byte-identical when m_direction == Forward.
  • Alternation factoring's stable sort + barrier rules and wrapAlternativesForDispatch — the compile-time alternationFactoringMinRun = 8 guard makes the empty-.last() path unreachable.
  • Boyer-Moore findBestCharacterSequence rewrite — the isAllSet() break and empty-map continue cover the any-character / above-Latin1-only cases the tests pin.
  • Frame-slot growth (YarrStackSpaceForBackTrackInfo*) matches the new BackTrackInfo* struct fields.

The two open inline nits (missing #if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) around five readUnicodeCharBackward call sites; a stray double blank line) are cosmetic / non-Bun-target only.

Extended reasoning...

Overview

This PR adds lookbehind JIT compilation to Yarr (previously an interpreter-only fallback), engine-neutral alternation prefix factoring and top-level folding in YarrPattern.cpp, first-character dispatch for wide alternations in YarrJIT.cpp, and reworks Boyer-Moore search-range scoring and SIMD-vs-scalar loop selection. It touches YarrJIT.cpp (~1,700 net lines), YarrPattern.cpp (~450 net lines), YarrPattern.h, Yarr.h, YarrJIT.h, RegExp.cpp, and adds two stress tests.

Security risks

Regex JIT is security-sensitive: it emits machine code from attacker-controllable patterns and subjects. The new backward-frame addressing (negativeOffsetIndexedAddress Backward branch, claimBackwardPairLead, checkNotEnoughInputBackward), the dispatch chain's patched frame stores (chainResume), and the pattern-level alternative reordering all create new surfaces for out-of-bounds reads or backtracking-state corruption if an invariant is violated. The PR's own verification (test262, differential JIT-vs-interpreter fuzzing, 52k BM cases) is substantial, but the design decisions — mirroring via deep-copied disjunctions, per-op direction stamping, forward-only dispatch — deserve a human reviewer familiar with Yarr's frame layout and backtracking model.

Level of scrutiny

High. This is core-engine JIT codegen with correctness and memory-safety implications, plus several large-scale design choices (mirrored-body compilation strategy, always-on pattern rewrites that reorder alternatives, frame-slot growth affecting the interpreter). It is well outside the "simple, mechanical, or obvious" bar for auto-approval.

Other factors

All prior review threads on this PR are resolved except two of my own cosmetic nits (unguarded readUnicodeCharBackward call sites for non-x64/arm64 targets, and a leftover double blank line); neither affects Bun's shipped builds. The bug-hunting pass on the current head found nothing new. Preview builds are green across all commits. No human has reviewed yet.

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