fix(regex): don't recompile a cached pattern just to re-validate it - #5777
Conversation
`js_regexp_new` validated every pattern by compiling it with BOTH the `regex` and `fancy-regex` engines (to decide whether to throw `SyntaxError`) — and did so BEFORE consulting `REGEX_CACHE`. So a regex literal evaluated in a hot loop recompiled its automaton on every single call even though `get_or_compile_regex` had already cached the compiled form from the first call. This is pathological for large patterns. string-width's `emojiRegex()` returns a fresh `/…/g` literal (12807 chars, a huge alternation) on every text measurement; ink's flexbox `calculateLayout` measures every cell, so the splash render sat at ~99% CPU for minutes rebuilding the same automaton thousands of times. Fix: skip the expensive validation compile when `(pattern, flags)` is already in `REGEX_CACHE`. A cached pattern is by definition compilable, so the "both engines fail → throw" branch can never fire for it. The cheap JS-syntax checks (`has_invalid_repeated_quantifier`, the `/u`-flag legacy-escape checks) still run on every call, and `get_or_compile_regex` below returns the cached `Arc<Regex>` as before — each `RegExp` object still gets its own fresh header (and thus its own `lastIndex`). Uncached patterns are validated exactly as before. Adds an integration test asserting a repeatedly-compiled literal keeps matching correctly, that two regexes from the same pattern keep independent `lastIndex`, that flags stay part of the cache key, and that invalid patterns still throw on every call. The test runs under a wall-clock timeout so a regression to per-call recompilation fails fast.
📝 WalkthroughWalkthrough
Regex cache validation skip
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry/tests/regexp_cache_repeated_compile.rs (1)
46-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winStrengthen the timeout regression signal.
This timeout only becomes meaningful if the repeated construction is expensive enough to distinguish cache hits from recompilation. With
/(\d+)-(\w+)/over 1000 iterations, the test mostly proves semantic correctness; a recompile regression could still finish well under 30s. Consider swapping in a materially larger pattern (or many more iterations) so the timeout actually exercises the performance regression this PR is fixing.Also applies to: 87-95
🤖 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 `@crates/perry/tests/regexp_cache_repeated_compile.rs` around lines 46 - 55, Strengthen the timeout regression test so it actually detects repeated recompilation slowdowns instead of only verifying correctness. Update the repeated-compile benchmark in regexp_cache_repeated_compile.rs, specifically the test that spawns the compiled binary and measures elapsed time, by using a materially larger regex pattern and/or significantly more iterations than the current 1000-loop case. Keep the same timeout-based structure, but make the workload heavy enough that cache hits and recompilation diverge clearly, so the wall-clock timeout meaningfully fails on regressions.
🤖 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.
Nitpick comments:
In `@crates/perry/tests/regexp_cache_repeated_compile.rs`:
- Around line 46-55: Strengthen the timeout regression test so it actually
detects repeated recompilation slowdowns instead of only verifying correctness.
Update the repeated-compile benchmark in regexp_cache_repeated_compile.rs,
specifically the test that spawns the compiled binary and measures elapsed time,
by using a materially larger regex pattern and/or significantly more iterations
than the current 1000-loop case. Keep the same timeout-based structure, but make
the workload heavy enough that cache hits and recompilation diverge clearly, so
the wall-clock timeout meaningfully fails on regressions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 30487329-e08f-4171-aaac-eb7318b12699
📒 Files selected for processing (2)
crates/perry-runtime/src/regex.rscrates/perry/tests/regexp_cache_repeated_compile.rs
…5808) `js_regexp_new` ran three JS-syntax validation checks (`has_invalid_repeated_quantifier`, `has_unicode_forbidden_legacy_escape`, `has_unicode_forbidden_pattern`) on EVERY call, before the `if !in_cache` gate that already skipped the expensive both-engines recompile (#5777). These checks are not actually cheap: `has_invalid_repeated_quantifier` does a `pattern.chars().collect::<Vec<char>>()` (a ~51 KB allocation for a 12,807-char pattern) plus an O(n) scan. The common `string-width` / `emoji-regex` npm packages construct a fresh ~12,807-char `/…/g` literal on every measurement, and a layout pass can call them thousands of times, so this re-validation — not the already-cached compile — became the top hot frame in profiles. Move all three checks inside the existing `if !in_cache { ... }` block (with `in_cache` computed first), so the whole validation block is skipped on a REGEX_CACHE hit. This is safe: regex validity is a pure function of (pattern, flags); an invalid pattern throws before `get_or_compile_regex` can cache it; and both writers of REGEX_CACHE (`js_regexp_new` and `regex/compile.rs`) run these exact checks before populating the cache, so any cache hit is provably valid. Invalid patterns are never cached, so they remain cache misses and still throw SyntaxError on every call. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…-code's 2,378 literals 232 → 50 ms
A symbolized `perf` profile of the claude-code bundle running `--help` — a
command that prints help text and exits — put **14.3% of all retired
instructions inside regex COMPILATION**: `ClassUnicodeRange::case_fold_simple`
3.53%, `thompson::compiler::Compiler::c` 2.16%, `determinize::next` 1.94%,
`add_nfa_states` 0.84%, plus the remainder across `regex_syntax` /
`regex_automata`. The whole of cc's compiled JavaScript is 0.11% of the same
profile.
## When compilation happened
At construction, for every regex the program HAS, not every regex it USES.
`js_regexp_new` — what both a `/…/` literal (`Expr::RegExp` in
`codegen/expr/logical_collections.rs`) and `new RegExp(…)` lower to — answered
"is this pattern a SyntaxError?" by BUILDING the pattern.
`compile_and_cache_regex_checked` is a full `regex::Regex::new`: parse, HIR
translate (Unicode class expansion and, under `i`, `case_fold_simple`),
Thompson NFA, meta strategy selection. The result was installed on the header
and cached thread-locally under `(pattern, canonical_flags)` in `REGEX_CACHE` /
`FANCY_CACHE` / `REPEAT_MATCHER_CACHE` (512 entries, cleared wholesale on
overflow). A regex literal is evaluated when its module initialises, so a
bundle pays for every literal it contains.
## The proof
A fixture of N regex literals of realistic shape (Unicode ranges, alternations,
`i`/`u` flags) where exactly ONE is ever executed, and a second fixture built
from **every distinct regex literal in the claude-code bundle** (2,378 of them,
extracted from `cli_2.1.112.js`), again matching with exactly one. Construction
time is the program's own `Date.now()` delta; min of 9 runs.
| literals constructed, 1 used | before | after | node |
|---|---|---|---|
| 50 | 19 ms | 2 ms | 0 ms |
| 200 | 73 ms | 7 ms | 1 ms |
| 400 | 145 ms | 15 ms | 3 ms |
| **2,378 (real claude-code literals)** | **232 ms** | **50 ms** | 5 ms |
Perfectly linear in the count before the change — 362 µs per literal — which is
the signature of "every literal compiles". Whole-process wall clock for the
claude-code corpus: 247.5 → 59.9 ms.
## What changed
Only the *program build* moves; everything observable at construction stays at
construction. New `regex/lazy.rs`:
* **`js_regexp_new` no longer builds.** `regex_ptr` (with `fancy_ptr` /
`repeat_matcher_ptr`) is left null — the "not built yet" state — and
`ensure_regex_compiled` installs all three, from the same caches, on the
first operation that needs a matcher. Every `&*(*re).regex_ptr` in the tree
now goes through `header_std_regex`, and `lookup_fancy_regex` /
`lookup_repeat_matcher` build first, so a null there cannot be confused with
"this pattern has no fallback". Publishing `regex_ptr` last keeps it a sound
built/not-built flag.
* **Validation stays eager, and gets cheap.** A syntactically invalid pattern
must still throw `SyntaxError` from the same point in the program, so
`js_regexp_new` still validates — but with the parser instead of the builder.
`regex_syntax`'s AST parse is pure grammar (unbalanced groups, `a{2,1}`,
`[z-a]`, dangling `)` all fail there); its HIR translate pass is where the
Unicode class expansion and case folding live. The only translate-only
diagnostic reachable from the strings perry produces is an unknown Unicode
property name, so `std_engine_syntax_ok` AST-parses everything and pays for
the full translate only when the translated pattern mentions `\p`/`\P` —
0.7% of the claude-code literals (16 of 2,378).
A parser rejection is not a verdict: every lookbehind/backreference pattern
is rejected by the linear engine too, so that case falls through to the
UNCHANGED both-engines path, which still owns the `SyntaxError` decision and
still populates the caches for the fancy fallback.
* **`VALIDATED_PATTERNS`** replaces the `REGEX_CACHE`-hit gate on the whole
validation block. Validity is a pure function of `(pattern, flags)`; PerryTS#5777
keyed that skip off a cache hit, which worked only because construction also
compiled. Same 512-entry cap and clear-on-overflow policy as the program
caches.
* `ensure_regex_compiled` is `#[inline]` with the build in a `#[cold]` callee:
it runs up to three times per match operation, and `is_valid_regex_ptr`
reaches `try_read_gc_header`/heap-space classification (1.55% of the same
profile), far too expensive to repeat once the program exists. The hot path
is two loads.
Not lazy, deliberately: `RegExp.prototype.compile` (Annex B, rare) still builds
eagerly, and its own validation is untouched.
## Semantics
`test-files/test_gap_9163_lazy_regex_semantics.ts` is byte-identical to node
and covers the five things the deferral could break: `SyntaxError` still raised
at construction (including for a regex that is constructed and never matched
with); `.source`/`.flags`/the flag getters readable before any match and
unchanged after one; `/g` and `/y` `lastIndex` statefulness across the build
being installed mid-life; identity (a fresh object per evaluation, independent
`lastIndex` and expandos — the RegExp analogue of PerryTS#9128's closure-literal
singleton bug); and the fancy-regex + RepeatMatcher fallbacks being installed
by the deferred build too. `RegExp.prototype.compile` on a header whose program
was never built (it releases a null old pointer) is covered as well.
`tests::syntax_check_agrees_with_full_build` pins the cheap check against
`build_std_regex` on a committed corpus, and takes a `PERRY_REGEX_CORPUS` file
for the wide sweep it was developed against: every distinct regex literal in
the claude-code, pi and kimi bundles (3,402) plus 6,297 mutations of the
claude-code set (truncations, single-character deletions, an injected `{2,1}`)
to load the reject direction — **9,899 patterns, zero disagreements**, in both
directions, through perry's real `js_regex_to_rust` translation. The
claude-code corpus fixture also reports `rejected=3` identically before and
after: the three ~10-15 KB `/u` identifier classes perry already refused are
still refused, at the same point.
One corner does move, in node's direction: a pattern the parser accepts but the
NFA build rejects (i.e. one that blows the 64 MiB size budget) used to be a
construction-time `SyntaxError` and is now a silent never-match at first use.
Node raises no error for such a pattern either. Zero occurrences across the
9,899-pattern sweep.
## Validation
* `cargo test -p perry-runtime --lib -- --test-threads=1`: 2846 passed,
0 failed, 4 ignored.
* `test-files/test_gap_9163_lazy_regex_semantics.ts` byte-identical to
`node --experimental-strip-types`.
* `cargo fmt --all -- --check` clean; `check_file_size.sh`,
`check_thread_locals.py`, `check_test_registration.py`,
`workspace_architecture.py --check` all OK.
* `cargo check -p perry-runtime --no-default-features` (regex engine gated off)
clean.
* Match-throughput control fixture (200k `test`, 200k `exec`, 100k
`replace`, 100k `search` on a built regex): 220 → 198 ms min. The per-op null
check costs nothing measurable.
* `.text` of a compiled fixture binary 8,530,132 → 8,536,468 bytes (+6,336,
+0.07%). Codegen is unchanged; the growth is runtime only.
Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
…erals 232ms → 50ms at startup (#9178) * perf(runtime): regex literals stop compiling at construction — claude-code's 2,378 literals 232 → 50 ms A symbolized `perf` profile of the claude-code bundle running `--help` — a command that prints help text and exits — put **14.3% of all retired instructions inside regex COMPILATION**: `ClassUnicodeRange::case_fold_simple` 3.53%, `thompson::compiler::Compiler::c` 2.16%, `determinize::next` 1.94%, `add_nfa_states` 0.84%, plus the remainder across `regex_syntax` / `regex_automata`. The whole of cc's compiled JavaScript is 0.11% of the same profile. ## When compilation happened At construction, for every regex the program HAS, not every regex it USES. `js_regexp_new` — what both a `/…/` literal (`Expr::RegExp` in `codegen/expr/logical_collections.rs`) and `new RegExp(…)` lower to — answered "is this pattern a SyntaxError?" by BUILDING the pattern. `compile_and_cache_regex_checked` is a full `regex::Regex::new`: parse, HIR translate (Unicode class expansion and, under `i`, `case_fold_simple`), Thompson NFA, meta strategy selection. The result was installed on the header and cached thread-locally under `(pattern, canonical_flags)` in `REGEX_CACHE` / `FANCY_CACHE` / `REPEAT_MATCHER_CACHE` (512 entries, cleared wholesale on overflow). A regex literal is evaluated when its module initialises, so a bundle pays for every literal it contains. ## The proof A fixture of N regex literals of realistic shape (Unicode ranges, alternations, `i`/`u` flags) where exactly ONE is ever executed, and a second fixture built from **every distinct regex literal in the claude-code bundle** (2,378 of them, extracted from `cli_2.1.112.js`), again matching with exactly one. Construction time is the program's own `Date.now()` delta; min of 9 runs. | literals constructed, 1 used | before | after | node | |---|---|---|---| | 50 | 19 ms | 2 ms | 0 ms | | 200 | 73 ms | 7 ms | 1 ms | | 400 | 145 ms | 15 ms | 3 ms | | **2,378 (real claude-code literals)** | **232 ms** | **50 ms** | 5 ms | Perfectly linear in the count before the change — 362 µs per literal — which is the signature of "every literal compiles". Whole-process wall clock for the claude-code corpus: 247.5 → 59.9 ms. ## What changed Only the *program build* moves; everything observable at construction stays at construction. New `regex/lazy.rs`: * **`js_regexp_new` no longer builds.** `regex_ptr` (with `fancy_ptr` / `repeat_matcher_ptr`) is left null — the "not built yet" state — and `ensure_regex_compiled` installs all three, from the same caches, on the first operation that needs a matcher. Every `&*(*re).regex_ptr` in the tree now goes through `header_std_regex`, and `lookup_fancy_regex` / `lookup_repeat_matcher` build first, so a null there cannot be confused with "this pattern has no fallback". Publishing `regex_ptr` last keeps it a sound built/not-built flag. * **Validation stays eager, and gets cheap.** A syntactically invalid pattern must still throw `SyntaxError` from the same point in the program, so `js_regexp_new` still validates — but with the parser instead of the builder. `regex_syntax`'s AST parse is pure grammar (unbalanced groups, `a{2,1}`, `[z-a]`, dangling `)` all fail there); its HIR translate pass is where the Unicode class expansion and case folding live. The only translate-only diagnostic reachable from the strings perry produces is an unknown Unicode property name, so `std_engine_syntax_ok` AST-parses everything and pays for the full translate only when the translated pattern mentions `\p`/`\P` — 0.7% of the claude-code literals (16 of 2,378). A parser rejection is not a verdict: every lookbehind/backreference pattern is rejected by the linear engine too, so that case falls through to the UNCHANGED both-engines path, which still owns the `SyntaxError` decision and still populates the caches for the fancy fallback. * **`VALIDATED_PATTERNS`** replaces the `REGEX_CACHE`-hit gate on the whole validation block. Validity is a pure function of `(pattern, flags)`; #5777 keyed that skip off a cache hit, which worked only because construction also compiled. Same 512-entry cap and clear-on-overflow policy as the program caches. * `ensure_regex_compiled` is `#[inline]` with the build in a `#[cold]` callee: it runs up to three times per match operation, and `is_valid_regex_ptr` reaches `try_read_gc_header`/heap-space classification (1.55% of the same profile), far too expensive to repeat once the program exists. The hot path is two loads. Not lazy, deliberately: `RegExp.prototype.compile` (Annex B, rare) still builds eagerly, and its own validation is untouched. ## Semantics `test-files/test_gap_9163_lazy_regex_semantics.ts` is byte-identical to node and covers the five things the deferral could break: `SyntaxError` still raised at construction (including for a regex that is constructed and never matched with); `.source`/`.flags`/the flag getters readable before any match and unchanged after one; `/g` and `/y` `lastIndex` statefulness across the build being installed mid-life; identity (a fresh object per evaluation, independent `lastIndex` and expandos — the RegExp analogue of #9128's closure-literal singleton bug); and the fancy-regex + RepeatMatcher fallbacks being installed by the deferred build too. `RegExp.prototype.compile` on a header whose program was never built (it releases a null old pointer) is covered as well. `tests::syntax_check_agrees_with_full_build` pins the cheap check against `build_std_regex` on a committed corpus, and takes a `PERRY_REGEX_CORPUS` file for the wide sweep it was developed against: every distinct regex literal in the claude-code, pi and kimi bundles (3,402) plus 6,297 mutations of the claude-code set (truncations, single-character deletions, an injected `{2,1}`) to load the reject direction — **9,899 patterns, zero disagreements**, in both directions, through perry's real `js_regex_to_rust` translation. The claude-code corpus fixture also reports `rejected=3` identically before and after: the three ~10-15 KB `/u` identifier classes perry already refused are still refused, at the same point. One corner does move, in node's direction: a pattern the parser accepts but the NFA build rejects (i.e. one that blows the 64 MiB size budget) used to be a construction-time `SyntaxError` and is now a silent never-match at first use. Node raises no error for such a pattern either. Zero occurrences across the 9,899-pattern sweep. ## Validation * `cargo test -p perry-runtime --lib -- --test-threads=1`: 2846 passed, 0 failed, 4 ignored. * `test-files/test_gap_9163_lazy_regex_semantics.ts` byte-identical to `node --experimental-strip-types`. * `cargo fmt --all -- --check` clean; `check_file_size.sh`, `check_thread_locals.py`, `check_test_registration.py`, `workspace_architecture.py --check` all OK. * `cargo check -p perry-runtime --no-default-features` (regex engine gated off) clean. * Match-throughput control fixture (200k `test`, 200k `exec`, 100k `replace`, 100k `search` on a built regex): 220 → 198 ms min. The per-op null check costs nothing measurable. * `.text` of a compiled fixture binary 8,530,132 → 8,536,468 bytes (+6,336, +0.07%). Codegen is unchanged; the growth is runtime only. Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP * chore: classify VALIDATED_PATTERNS and add the changelog fragment --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Problem
js_regexp_newvalidates every pattern by compiling it with both theregexandfancy-regexengines (to decide whether to throwSyntaxError) — and does so before consultingREGEX_CACHE. So a regex literal evaluated in a hot loop recompiled its automaton on every single call, even thoughget_or_compile_regexhad already cached the compiled form on the first call.This is pathological for large patterns. string-width's
emojiRegex()returns a fresh/…/gliteral (12807 chars — a huge alternation) on every text measurement, and ink's flexboxcalculateLayoutmeasures every cell. The result: rendering the splash sat at ~99% CPU for minutes, rebuilding the same automaton thousands of times.Backtrace of the spin:
Fix
Skip the expensive validation compile when
(pattern, flags)is already inREGEX_CACHE. A cached pattern is by definition compilable, so the "both engines fail → throw" branch can never fire for it.has_invalid_repeated_quantifier, the/u-flag legacy-escape checks) still run on every call.get_or_compile_regexstill returns the cachedArc<Regex>, and eachRegExpobject still gets its own fresh header (its ownlastIndex).Net effect: 30,000 fresh-literal compiles of a large pattern drop from minutes to ~25ms (cache hit).
Test
regexp_cache_repeated_compile.rsasserts a repeatedly-compiled literal keeps matching correctly, two regexes from the same pattern keep independentlastIndex, flags stay part of the cache key, and invalid patterns still throw on every call — run under a wall-clock timeout so a regression to per-call recompilation fails fast.Summary by CodeRabbit
Performance Improvements
Bug Fixes
lastIndexbehavior independent for separate regex instances created from the same pattern.