Skip to content

YARR JIT: O(1) backward step when backtracking a greedy variable-width /u character class - #289

Open
robobun wants to merge 2 commits into
mainfrom
robobun/yarr-greedy-unicode-backtrack-o1
Open

robobun wants to merge 2 commits into
mainfrom
robobun/yarr-greedy-unicode-backtrack-o1

Conversation

@robobun

@robobun robobun commented Jul 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

For /u-mode regexes on 16-bit input, backtrackCharacterClassGreedy handled variable-width character classes (e.g. any negated class like [^"]) by resetting to beginIndex and walking forward count - 1 code points on every backtrack step. That makes a single attempt O(n^2), and an unanchored match O(n^3).

Minimal repro:

/[^"]*X/u.test("\u2014" + "a".repeat(4000))
n before (JIT) interpreter after (JIT)
1000 294 ms 7.4 ms 2.9 ms
2000 2340 ms 29.8 ms 7.8 ms
4000 18637 ms 119 ms 30 ms

This shows up in the wild as a multi-second stall in prettier with parser: "typescript" on any file that contains a non-ASCII character, via @typescript-eslint's JSX detection regex /(?:^[^"'`]*<\/)|(?:^[^/]{2}.*\/>)/mu.

Fix

Step backward one code unit, then load the unit at the new position: if it is a trail surrogate and the preceding unit (bounded by beginIndex, which generateCharacterClassGreedy stores for exactly this case) is a lead surrogate, step back one more. This mirrors what the YARR interpreter's backtrackCharacterClass already does via uncheckInput(1) + tryReadBackward().

The surrogate classification uses surrogateTagMask so it agrees with tryReadUnicodeCharImpl on how many units a given pair consumed; the beginIndex bound prevents pairing a lone trail at the start of the range with a lead that precedes the range.

Added JSTests/stress/regexp-unicode-greedy-charclass-backtrack.js covering mixed BMP/non-BMP backtracking, lone surrogates, U+F800/U+FC00, and the cubic-regression timing.

Upstream

The same issue is being fixed upstream as WebKit#69364 (tracked at https://bugs.webkit.org/show_bug.cgi?id=319370) with a slightly different implementation. Either works for bun; once that lands, the next upstream sync will replace this.

…h Unicode character class

backtrackCharacterClassGreedy rematched forward from beginIndex on every
single backtrack step for /u-mode variable-width classes, making each step
O(n) and unanchored patterns like /[^"]*X/u O(n^3) on 16-bit input. The
interpreter already steps backward one code unit (two for a valid surrogate
pair) in O(1); do the same in the JIT, bounded by beginIndex so a lone
trail at the start of the quantified range is not combined with a lead that
precedes it.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

A JavaScriptCore RegExp JIT backtracking path now rolls back one Unicode code point in O(1) time. A new stress test validates surrogate behavior, mixed-width unwinding, and execution time for large inputs.

Changes

Unicode RegExp backtracking

Layer / File(s) Summary
Constant-time code-point rollback
Source/JavaScriptCore/yarr/YarrJIT.cpp
Unicode greedy character-class backtracking now uses surrogate classification to step back one UTF-16 code point without rematching loops.
Correctness and performance coverage
JSTests/stress/regexp-unicode-greedy-charclass-backtrack.js
Assertions cover surrogate pairs, lone surrogates, mixed-width full unwinding, and a sub-second regression guard for large input.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, fix, and test, but it omits the repository's required bug/title/reviewer/bugzilla template fields. Rewrite the PR description in the required WebKit template, including the bug link, bug title, reviewer line, and changed-file summary.
✅ 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 change: making YARR JIT backtracking O(1) for greedy variable-width /u character classes.

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

Comment thread Source/JavaScriptCore/yarr/YarrJIT.cpp
@github-actions

github-actions Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
1e7b9193 autobuild-preview-pr-289-1e7b9193 2026-07-14 08:51:07 UTC
e25e53b0 autobuild-preview-pr-289-e25e53b0 2026-07-14 07:38:47 UTC

…icodeCharImpl

The forward read classifies surrogates with surrogateTagMask (& 0xdc00); the
backward step must use the same classification so both sides agree on how
many code units a given pair consumed. Adds coverage for U+F800/U+FC00.

@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 — 1e7b919 addresses the mask-consistency issue; the backward step now uses surrogateTagMask so it agrees with tryReadUnicodeCharImpl on which unit pairs count as 2-wide, and the added U+F800/U+FC00 tests cover it. I didn't find anything else, but this is hand-written JIT codegen on the regex hot path, so worth a human pass before merge.

What was reviewed:

  • beginIndex is stored under exactly !isFixedWidthCharacterClass() (== !hasOneCharacterSize() || invert()), so the frame slot is always live when this path runs.
  • Both load16Unaligned sites read addresses the forward greedy loop already touched; the index > beginIndex guard bounds the second load and prevents over-stepping past the range start.
  • Traced the visited-position invariant: a unit can't satisfy both & surrogateTagMask == 0xd800 and == 0xdc00, so if index-1 was a 1-unit forward step its predecessor can't test as a lead — no over-retreat.
  • #else fallback (sub32(1)) matches old behavior since readCharacter doesn't combine surrogates without YARR_JIT_UNICODE_EXPRESSIONS.
Extended reasoning...

Overview

Replaces the O(n)-per-step rematch loop in backtrackCharacterClassGreedy (variable-width /u character class on 16-bit input) with an O(1) backward step: retreat one code unit, and if it tests as a trail surrogate with a lead immediately before it (bounded by the stored beginIndex), retreat one more. ~30 lines of MacroAssembler codegen in YarrJIT.cpp plus a new stress test. My earlier review flagged that the first revision used & 0xfc00 while the forward path uses surrogateTagMask (& 0xdc00), causing index/count desync on inputs like U+F800 U+FC00; commit 1e7b919 switched both mask sites to surrogateTagMask and added test coverage for that case.

Security risks

Regex JIT operates on attacker-controlled strings, so an off-by-one here could in principle produce an out-of-bounds load16Unaligned or leave index below beginIndex. I checked both loads against the forward scan's proven-in-bounds range: the first load at offset checkedOffset - inputPosition after index -= 1 is the same address the forward loop's readCharacter used at that position; the second load (offset+1) is guarded by index > beginIndex, so it never reads before input[beginIndex - offset], which the forward loop's first iteration already read. The second sub32 is reached only when index > beginIndex, so index stays >= beginIndex. I don't see a memory-safety hazard, but the argument is invariant-based rather than structurally obvious.

Level of scrutiny

High. This is hand-authored JIT emission on a hot, security-relevant path (YARR handles untrusted regex subjects across all of Bun). The correctness depends on a non-local invariant — that the forward greedy loop visits position P as a code-point start only if input[P-1] does not test as a lead under the same mask — which I verified by case analysis but which a reviewer with YARR context should sanity-check, particularly around the errorCodePoint / lone-trail slow-path behavior in tryReadUnicodeCharSlowThunkGenerator. The first revision already had one subtle inconsistency, which raises the bar for a second pair of eyes.

Other factors

  • !isFixedWidthCharacterClass() is definitionally !hasOneCharacterSize() || invert(), matching the beginIndex store guard in generateCharacterClassGreedy, so the frame slot is always initialized on this path.
  • regT2 is newly used here with m_usesT2 = true set, consistent with generateCharacterClassGreedy.
  • The #else branch degrades to sub32(1), which matches pre-PR semantics because without YARR_JIT_UNICODE_EXPRESSIONS the forward readCharacter never combines pairs.
  • Stress test covers BMP/non-BMP mixes, lone leads/trails, the q-prefixed beginIndex boundary, the F800/FC00 mask-consistency case, and a timing regression guard.
  • The remaining surrogateTagMask = 0xdc00dc00 vs 0xfc00fc00 question is pre-existing and reasonably deferred.

Deferring to human review rather than auto-approving given the code path; no outstanding issues from my side.

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