Skip to content

fix(codegen,runtime): closure-literal singletons broke function identity — pi boots - #9128

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:fix-pi-closure-identity
Aug 30, 2026
Merged

fix(codegen,runtime): closure-literal singletons broke function identity — pi boots#9128
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:fix-pi-closure-identity

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

The last two of pi's four startup walls, and with them pi boots natively for the first time (--version prints 0.0.0, node-identical; all four earlier walls were #9069/#9073/#9085 + these).

Commit 1 — closure-literal singleton identity. expr/closure.rs routed closure literals through js_closure_alloc_singleton / js_closure_alloc_with_captures_singleton keyed on (func_ptr, capture bits): two evaluations of the same literal with bit-identical captures — any captureless arrow, or an arrow capturing the same constant — came back ===-equal. ECMA-262 OrdinaryFunctionCreate requires a fresh object per evaluation, observable via ===, expandos, and WeakMap keys. pi hit it through graceful-fs's wrap pattern: setPrototypeOf(wrapped, original) received ONE object twice and the runtime correctly threw Cyclic __proto__ value — with obj_bits == proto_bits exactly, and none of the bundle's 17 textual setPrototypeOf sites firing a JS logging shim, which is what pointed below the JS boundary. Gap fixture test_gap_9090_closure_literal_identity.ts.

Commit 2 — name-keyed builtin-member reads must see user overrides (part 2 of the same boot sequence, native_module.rs). Gap fixture test_gap_9091_native_member_patch_roundtrip.ts.

First pi measurements (loaded dev box, so wall time is an upper bound): --version boots clean; [gc-time] share_permille=102 — GC ≈ 10% of pi startup, with the vacuity gate satisfied (copying minors ran, 124k-object nursery census) — first real data point for the concurrent-GC decision gate (threshold 15%).

Diagnosed and implemented by a subagent (runtime-side throw instrumentation → bundle-side call-site shims → identity-conflation hypothesis → smallest repro); validated by the coordinating session via the full pi boot.

Summary by CodeRabbit

  • Bug Fixes

    • Closure literals now create distinct function objects on each evaluation, preventing shared state and property leakage.
    • Updates to built-in namespace members are now reflected in subsequent name-based reads and module exports.
    • Preserved correct prototype behavior for patched built-in functions.
  • Tests

    • Added coverage for closure identity, property isolation, and built-in namespace member patching.

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 71443b8c-bfd3-4044-9dba-ce87de873b9d

📥 Commits

Reviewing files that changed from the base of the PR and between eb8e06f and 649784f.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/closure.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs

📝 Walkthrough

Walkthrough

Closure singleton allocation is restricted to compiler-generated async step closures. Native namespace reads now include dynamic fields. Regression tests cover closure identity and patched builtin namespace members.

Changes

Function identity and namespace member updates

Layer / File(s) Summary
Restrict closure singleton allocation
crates/perry-codegen/src/expr/closure.rs, crates/perry-codegen/tests/native_proof_regressions.rs, test-files/test_gap_9090_closure_literal_identity.ts
Singleton allocation now requires a compiler-synthesized plain-async step closure. User-authored closure literals use fresh function objects. Tests cover identity, expandos, prototypes, typed closure allocation, and proof ordering.
Expose dynamic native namespace members
crates/perry-runtime/src/object/native_module.rs, test-files/test_gap_9091_native_member_patch_roundtrip.ts
Name-keyed and ESM export reads now check namespace overrides and dynamic own fields. Tests cover patched process and fs members.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to eb8e0

The PR restores fresh function identity and makes native-module overrides visible through shared reads, but named built-in imports may observe those overrides before the expected synchronization point. The change is mergeable with explicit owner awareness and follow-up to confirm the intended snapshot behavior.

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 4 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the closure-literal singleton identity fix and its runtime impact. It is concise and related to the primary changes.
Description check ✅ Passed The description provides a detailed summary of both fixes, their motivation, affected behavior, and validation results. It does not use the template headings and omits an explicit related issue, comma…
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.
Full details: Description check

Explanation

The description provides a detailed summary of both fixes, their motivation, affected behavior, and validation results. It does not use the template headings and omits an explicit related issue, command-based test plan, and checklist confirmation, but the core information is present.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 4 files. (1 skipped: 1 too large.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

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

Ralph Küpper added 3 commits August 30, 2026 06:57
…n identity — pi boot threw Cyclic __proto__

perry-compiled pi (13MB esbuild bundle) died at startup with
`TypeError: Cyclic __proto__ value` out of `js_object_set_prototype_of`,
with obj_bits == proto_bits exactly — the two ARGUMENTS were the same
pointer — while the chain behind the proto was healthy. None of the
bundle's 17 textual `Object.setPrototypeOf(` sites fired a JS logging
shim, because the self-set was manufactured upstream of the call: the
closure-literal singleton caches handed back ONE ClosureHeader for two
evaluations of the same function literal, so `setPrototypeOf(wrapped,
original)` (a graceful-fs-style wrap pattern) received one object twice
and correctly refused the "cycle".

Mechanism: `expr/closure.rs` routed closure literals through
`js_closure_alloc_singleton` (captureless arrows) and
`js_closure_alloc_with_captures_singleton` (arrows with captures, and
non-arrow literals whose captures are all boxes) keyed by
(func_ptr, capture bits). Two evaluations of the same literal with
bit-identical captures — e.g. an arrow capturing the same constant, or
any captureless arrow — came back `===`-equal. ECMA-262
OrdinaryFunctionCreate requires a fresh object per evaluation, and the
distinction is observable through `===`, expando properties, WeakMap
keys, addEventListener de-duplication, and `Object.setPrototypeOf`.
Minimal repros (byte-compared against node before/after):

    function mk() { return () => K; }        // captured arrow
    const a = mk(), b = mk();                // perry: a === b (node: false)
    Object.setPrototypeOf(a, b);             // perry threw Cyclic __proto__

and the same with `() => 1` (captureless). Both now match node.

Fix: gate every closure.rs literal singleton path on
`is_plain_async_step_body` — the file's existing detector for the
compiler-synthesized plain-async step closures (their terminal
`Stmt::ReleaseBoxes` arms cannot appear in user code). Those are the
closures the caches were built for (PerryTS#8269's parallel async-await
pattern re-creates them per resume with the same per-activation box
captures, and their identity never escapes the promise machinery), and
they keep the fast path. Every user-authored arrow and function
expression now mints a fresh closure. Runtime-internal singleton users
(function-declaration references, property_get/i18n/arrays wrapper
thunks) are separate paths and unchanged. A genuine
`setPrototypeOf(x, x)` still throws — the cycle check is untouched.

Perf note: this deliberately gives back the user-arrow closure reuse
from the PerryTS#8269/PerryTS#8291 captured-singleton extension (e.g. ECS
`World.executeEntityCommands`' per-call inner arrow) and the captureless
user-arrow singleton at literal sites; a sound replacement needs
escape-aware caching rather than identity-violating sharing.

Validation: repros above and test-files/
test_gap_9090_closure_literal_identity.ts byte-identical to node;
`cargo test -p perry-runtime --lib -- --test-threads=1` green — 2813
passed, 0 failed with `--skip reserved_floor` (that module's at-scale
tests SIGABRT on this pre-PerryTS#9110 base; known PerryTS#9108/PerryTS#9110, unrelated);
`cargo test -p perry-codegen`: 283+75 passed after updating the four
native_proof_regressions pins from `js_closure_alloc_singleton` to
`js_closure_alloc` (their real subject — the alloc storing the public
wrapper pointer — is preserved); one pre-existing env-leak flake
(`packed_f64_loop_unary_math_store_versions_with_side_exit`) passes in
isolation.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
… — pi boot threw Cyclic __proto__ (part 2)

With the closure-literal identity fix in place, pi still died at startup
with `TypeError: Cyclic __proto__ value`, obj_bits == proto_bits exactly.
The instrumented throw site showed both arguments were ONE closure with
`func_ptr = 0xBADD_DEAD` (BOUND_METHOD_FUNC_PTR, capture_count 3) and a
healthy 3-link chain behind it — the canonical bound-native callable that
`bound_native_callable_export_value` mints once per (module, member).

The failing code is graceful-fs's module init, bundled into pi
(pi-bundle.mjs:6621/6686/6705):

    var chdir = process.chdir;
    process.chdir = function (d) { ... };
    if (Object.setPrototypeOf) Object.setPrototypeOf(process.chdir, chdir);

and the same wrap for fs.rename / fs.read. Under perry the patch write
did not round-trip on the re-read, so setPrototypeOf received the SAME
canonical closure for both arguments — a self-set — and the cycle check
correctly refused it. The earlier probe of this exact shape passed
because it patched a PLAIN object, where writes round-trip; the failure
needs a builtin namespace receiver. The JS shim over
`Object.setPrototypeOf(` never fired because the conflation happens in
the native member-READ, upstream of the call.

Root cause: user writes to builtin namespace members are stored in two
different places depending on the lowering — computed stores
(`process[k] = fn`) go through `nm_field_set_override` into
`NATIVE_NAMESPACE_PROP_OVERRIDES`, while static stores
(`process.chdir = fn`) reach the generic store path and land as an own
dynamic field on the canonical namespace object. The NAME-KEYED read
entries carry no object pointer and consulted only the override table:

  * `js_native_module_property_by_name` (codegen static reads of
    process.* members) missed own-field stores, so the graceful-fs
    static patch was invisible to the static re-read;
  * `js_native_module_esm_export_value` (codegen property reads off a
    builtin DEFAULT import — `import fs from "node:fs"; fs.rename`)
    consulted NOTHING (consult_overrides=false plus its own snapshot
    cache), so no fs patch was ever visible. In Node the default import
    of a core module is the live mutable CJS exports object, so the
    patched value must win; the tls DEFAULT_* cache-coherence hack was
    the ad-hoc version of this for three keys.

Fix: `native_namespace_user_value(module, prop)` consults the override
table and then the canonical namespace object's own field (never
creating a namespace — if none exists, no user store can have landed on
one). Both name-keyed read entries call it before any built-in
resolution or snapshot cache. Named ESM import bindings of core modules
snapshot at module init before user patches run, so their intended
snapshot semantics are unaffected in the eager case.

Validation: r11-r16 probe matrix (process/fs, static/computed reads and
writes) and test-files/test_gap_9091_native_member_patch_roundtrip.ts
byte-identical to node; a genuine `setPrototypeOf(x, x)` still throws.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
@proggeramlug
proggeramlug force-pushed the fix-pi-closure-identity branch from eb8e06f to 649784f Compare August 30, 2026 05:18
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged, plus a rustfmt commit. pi boots natively — worth stating on its own.

The singleton keying was a genuine spec violation, not just a nuisance: OrdinaryFunctionCreate makes a fresh function object per evaluation, so mk() === mk() must be false even for a captureless arrow. Keying on (func_ptr, capture bits) made any two evaluations with bit-identical captures collapse to one object, which breaks every identity-based pattern — listener removal, WeakMap keying, per-instance function properties.

Verified against node v26.5.1, 15 identity shapes, byte-identical:

shape node
1, 2, 3 mk() === mk() for captureless arrow, loop-created arrows, function expression false throughout
4 two closures capturing the same constant false, both still return 5
5, 6 three instances in a Set → size 3; two as distinct WeakMap keys 3, [1,2]
7 add/remove listeners by identity indexOf finds the right one
10, 11 own properties are per-instance (a.tag set, b.tag undefined; disjoint Object.keys)
8, 9 a stable reference is still ===, and o.m === o.m true — the fix doesn't over-correct
13, 14 per-iteration capture and parameterized captures still work [0,1,2], [2,4,6]

Cases 8 and 9 were the ones I most wanted green: making every literal fresh must not make an existing reference compare unequal to itself.

One thing to flag for whoever measures next: this trades an allocation elision for spec correctness, so closure-heavy hot paths may show a regression. That's the right trade and I'm not asking for it back — but if a benchmark moves, this is the likely cause rather than a mystery.

Validation: codegen 1349 passed, runtime 2822 passed (exit 0), perry --bins 1066 passed, fmt clean, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped. Rebased onto main, --diff-filter=D empty.

(One runtime run in the middle showed a single unrelated failure that did not reproduce on re-run — same order/state flake I've hit twice today, not this PR.)

@proggeramlug
proggeramlug merged commit 653e886 into PerryTS:main Aug 30, 2026
14 of 15 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 30, 2026
A fresh (identity-carrying) capturing closure was born as js_closure_alloc
plus one js_closure_set_capture_bits runtime call per capture, and each
setter re-resolved the GC header, re-checked forwarding, re-dispatched on
the object kind for layout_note_slot and paid the write barrier's
page-table classification again. After PerryTS#9128 made every user closure
literal fresh, that per-capture chain was ~24% of a capturing-closure
birth and js_closure_alloc itself ~34% (sample, main@PerryTS#9128).

New runtime entry js_closure_alloc_init(func_ptr, capture_count,
captures_ptr): no-collect-first nursery allocation (its Some contract
keeps the raw capture bits valid; no trigger check), header + bulk slot
copy, ONE newborn layout classification (layout_init_from_slots:
forget-once, then pointer-free / unknown / side-mask — no per-slot notes,
no interleaved table removes), and a barrier pass that classifies the
parent once for all slots (runtime_write_barrier_newborn_slots; with
barriers off it is the incremental-mark shade check per value). The
block-boundary fallback takes the original alloc + per-slot setter path.

Codegen emits it for fresh closures whose captures are all plain bits
(bulk_fresh_init); box-cell captures keep the per-slot setter path (their
set_closure_box_capture bookkeeping has no bulk twin); the reserved this /
new.target slots are pre-filled with the pointer-free sentinel and patched
post-create exactly as before. Singleton (compiler-synthesized async-step)
closures are untouched.

Closure-birth differential vs node (plain and boxed captures, this-arrows,
new.target, async, identity, arrays of closures, nested and 10-capture
closures): byte-identical.

Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p
proggeramlug added a commit that referenced this pull request Aug 30, 2026
…15%) (#9136)

* perf(runtime,codegen): one-call birth for fresh capturing closures

A fresh (identity-carrying) capturing closure was born as js_closure_alloc
plus one js_closure_set_capture_bits runtime call per capture, and each
setter re-resolved the GC header, re-checked forwarding, re-dispatched on
the object kind for layout_note_slot and paid the write barrier's
page-table classification again. After #9128 made every user closure
literal fresh, that per-capture chain was ~24% of a capturing-closure
birth and js_closure_alloc itself ~34% (sample, main@#9128).

New runtime entry js_closure_alloc_init(func_ptr, capture_count,
captures_ptr): no-collect-first nursery allocation (its Some contract
keeps the raw capture bits valid; no trigger check), header + bulk slot
copy, ONE newborn layout classification (layout_init_from_slots:
forget-once, then pointer-free / unknown / side-mask — no per-slot notes,
no interleaved table removes), and a barrier pass that classifies the
parent once for all slots (runtime_write_barrier_newborn_slots; with
barriers off it is the incremental-mark shade check per value). The
block-boundary fallback takes the original alloc + per-slot setter path.

Codegen emits it for fresh closures whose captures are all plain bits
(bulk_fresh_init); box-cell captures keep the per-slot setter path (their
set_closure_box_capture bookkeeping has no bulk twin); the reserved this /
new.target slots are pre-filled with the pointer-free sentinel and patched
post-create exactly as before. Singleton (compiler-synthesized async-step)
closures are untouched.

Closure-birth differential vs node (plain and boxed captures, this-arrows,
new.target, async, identity, arrays of closures, nested and 10-capture
closures): byte-identical.

Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p

* perf(runtime): skip the barrier and the memcpy call on pointer-free closure births

Follow-up cuts on the same entry, from a Linux perf annotate of the birth
loop (18.5 ns/op, 478 instr/iter, IPC 4.97 — throughput-bound, so
instruction count is the cost):

- layout_init_from_slots now RETURNS whether any slot is pointer-bearing,
  and the birth skips runtime_write_barrier_newborn_slots entirely when
  nothing is. A closure capturing only numbers/booleans/SSO strings paid a
  call plus a page-table classification per slot for a barrier whose own
  child check would reject every one of them (write_barrier_slot_decoded
  was 9.3% of the loop on a NUMBER capture).
- The ≤64-slot case classifies into a register-resident u64 instead of a
  LayoutSlotMask, and reads the mask-min-slots threshold once instead of
  through a per-birth OnceLock call.
- layout_forget_object is called only when the per-object layout tables
  can actually hold an entry (per_object_layouts_maybe_nonempty), matching
  what the tables' own accessors check anyway (4.5% of the loop).
- Slot counts ≤8 copy through a counted store loop; the runtime-length
  copy_nonoverlapping compiled to a memcpy PLT call (2.6% for ONE slot).

Mini, medians: bare capturing closure 24.3 -> 21.0 ns (-13.6%),
captured-arrow-field literal 27.8 -> 22.2 (-20.1%); captureless and plain
literals unchanged. Cumulative against main: 28.5 -> 21.0 and 31.4 -> 22.2.
Closure-birth differential vs node unchanged (byte-identical).

Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p

* chore(gc): audit the counted capture-store loop and the forEach identity stacks

gc_store_site_inventory flagged #9136's counted store loop; classified
BARRIERED to match the copy_nonoverlapping arm beside it, which is followed
by the same closure layout/barrier rebuild.

gc_runtime_root_holders flagged #9095's SET_FOREACH_STACK / MAP_FOREACH_STACK;
classified not_a_gc_pointer — the entries are header addresses used only for
identity comparison, never dereferenced, and set_header_moved_for_gc /
map_header_moved_for_gc rewrite them when a header moves.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 30, 2026
…-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
proggeramlug added a commit that referenced this pull request Aug 30, 2026
…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>
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