Skip to content

fix(runtime): coerce lastIndex before borrowing the regex subject - #8446

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/8428-regexp-lastindex-gc
Aug 20, 2026
Merged

fix(runtime): coerce lastIndex before borrowing the regex subject#8446
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/8428-regexp-lastindex-gc

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #8428.

Problem

RegExpBuiltinExec step 4 is ToLength(Get(R, "lastIndex")). When lastIndex
holds an object, the ToNumber half runs OrdinaryToPrimitive — user
valueOf/toString, i.e. arbitrary JS, which reaches back-edge safepoint polls
and can therefore run a moving minor today.

js_regexp_exec took the subject's inline-payload borrow (string_as_str,
regex/exec.rs:36) before that coercion (:51). Rooting rewrites slots,
never an already-materialized &str (the HeapKeyBytes doc states the rule), so
a collection inside the callback left the entire match running over from-space
bytes — the match silently evaporates, or returns text from whatever was
recycled into those pages.

String.prototype.matchAll had the same defect through Rust's left-to-right
argument evaluation: materialize_match_all_results(s, re, regex_last_index_offset(re))
evaluates s and re before the coercion runs, so the snapshot phase rooted
and read a pre-move subject.

Fix

  • regex/exec.rs — root re and s, run the lastIndex coercion, take the
    borrow from the refreshed addresses. This is also strictly closer to spec:
    step 4 precedes reading the flags (steps 5–7) and [[RegExpMatcher]]
    (step 12), which the old ordering did in the other order.
  • regex/exec.rs — the fancy-regex arm now writes lastIndex before the result
    array is allocated, matching both the spec (step 15 precedes step 16's
    ArrayCreate) and the standard arm; that also keeps re out of the window the
    capture allocations open.
  • regex/exec.rs — the match array's .input decoration re-boxes the subject,
    so it now reads the current address rather than the one bound before the
    capture/groups allocations.
  • regex/match_all.rs — both matchAll entry points root the subject and the
    regex header across the coercion and hand over refreshed pointers.

Audit (issue asked for these explicitly)

  • set_last_index_throwing does not reopen the window. It reads the property
    attributes (a side-table read) and stores a number; Perry has no user-defined
    lastIndex setter on this path. Its only allocation is on the non-writable arm,
    which throws and therefore never returns to the borrow. Noted in the code.
  • regex/match_string.rs (js_string_match) never reads lastIndex, so it
    has no user-JS window ahead of its borrow; js_string_match_value already
    roots the subject across the pattern coercion.
  • regex/replace_fn.rs / regex/replace_expand.rs — the user-callback paths
    already re-derive the subject per use (cur_str = || string_as_str(s_handle…));
    the string/named paths run pure Rust over the borrow with a single terminal
    allocation.

Not in scope, filed separately: exec.rs/exec_array.rs still read
str_data (and regex::Captures borrowing it) after the result-array,
capture-string and groups-object allocations. That is the allocation-point
window — the same latent class as #8423 — which is closed today only by the
#7682 guard forcing a conservative scan (hence a non-moving minor) at alloc
points. Closing it properly means the owned-snapshot restructure match_all.rs
already carries, which is a much larger change to a hot path and deserves its own
measurement. Filed as #8449.

Validation

  • New GC witness gc::tests::runtime_roots::regexp_last_index — plants a
    valueOf that runs a copying minor and then refills the retired Eden with a
    distinctive pattern. It asserts its subject was live both ways (a copying
    minor ran; the subject address actually changed) before checking the captures.
    Sabotage-checked: reverting only exec.rs/match_all.rs to main makes it
    fail (/(young)-(\d+)/g must match the relocated subject — the match evaporates),
    and it passes with the fix.
  • New parity fixture test-files/test_issue_8428_exec_lastindex_reentrant.ts
    — std-regex arm, fancy-regex (lookbehind) arm, a non-zero coerced lastIndex
    (which additionally walks the borrow in utf16_index_to_byte), named captures
    with the d flag, matchAll, and test(). Runs under
    PERRY_GC_SCHEDULE_SEED/RATE=1 + FORCE_EVACUATE + VERIFY_EVACUATION;
    byte-compared against Node 26.5.1.
  • scripts/raw_handle_debt.py stays at baseline (978) — the new code uses
    across_* / with_*_ptr throughout and adds no bare handle reads.

No version bump (maintainer bumps at merge).

RegExpBuiltinExec step 4 is `ToLength(Get(R, "lastIndex"))`. When `lastIndex`
holds an object the ToNumber half runs OrdinaryToPrimitive — user
`valueOf`/`toString`, i.e. arbitrary JS, which reaches back-edge safepoint
polls and can therefore run a moving minor today.

`js_regexp_exec` took the subject's inline-payload borrow (`string_as_str`)
BEFORE that coercion. Rooting rewrites slots, never an already materialized
`&str`, so a collection inside the callback left the entire match running over
from-space bytes: the match evaporates, or reads whatever was recycled into
those pages.

`String.prototype.matchAll` had the same defect through Rust's left-to-right
argument evaluation — `materialize_match_all_results(s, re, regex_last_index_offset(re))`
binds `s` and `re` before the coercion runs, so the snapshot phase rooted a
pre-move subject.

Both entry points now root the regex header and the subject, run the coercion,
and read the refreshed addresses. This is also strictly closer to spec: step 4
precedes reading the flags (steps 5-7) and [[RegExpMatcher]] (step 12), which
the old ordering did the other way round. The fancy-regex arm additionally
writes `lastIndex` before the result array is allocated (spec step 15 precedes
step 16's ArrayCreate, and it keeps `re` out of the window the capture
allocations open), and the match array's `.input` decoration re-boxes the
current subject rather than the address bound before those allocations.

Audited in the same pass, no change needed: `set_last_index_throwing` reads
property attributes and stores a number, so it cannot run user code (its only
allocation is on the throwing arm, which never returns to the borrow);
`js_string_match` never reads `lastIndex`; the `replace_*` callback paths
already re-derive the subject per use.

The new witness `gc::tests::runtime_roots::regexp_last_index` plants a
`valueOf` that runs a copying minor and refills the retired Eden, asserts its
subject was live both ways (a copying minor ran; the subject moved), and fails
on pre-fix code.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 seconds

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 83d8df49-5779-4ee2-931e-2e6b0aa31575

📥 Commits

Reviewing files that changed from the base of the PR and between 526e0b5 and a834fd5.

📒 Files selected for processing (6)
  • changelog.d/8446-regexp-lastindex-rooting.md
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/regexp_last_index.rs
  • crates/perry-runtime/src/regex/exec.rs
  • crates/perry-runtime/src/regex/match_all.rs
  • test-files/test_issue_8428_exec_lastindex_reentrant.ts

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Validated as part of an 11-PR batch (#8439, #8440, #8441, #8442, #8443, #8444, #8446,
#8448, #8450, #8453, #8454) stacked on main and built once, then merged individually.

  • 19/19 sweep corpus byte-exact against the Node oracle
  • perry-runtime --lib 2601 · perry --bin perry 1007 · perry-codegen --lib 1113
  • scripts/run_lint_gates.sh — all 50 gates
  • A re-entrancy probe (user JS re-entering via toString/valueOf/replacer/comparator
    during normalize, repeat, regex lastIndex coercion, replace, JSON.stringify,
    sort, and punycode host conversion) matches Node exactly, including under
    PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1.

One thing stated plainly: these are hardening, not demonstrated repairs

I could not make the underlying bugs reproduce. My probe passes on unmodified main, and so
do all four of the fixtures this series ships
(test_issue_8426_normalize_reentrant, test_issue_8428_exec_lastindex_reentrant,
test_gap_gc_string_copy_source_rooting, test_gap_gc_string_repeat_reentrant_count) —
including under PERRY_GC_FORCE_EVACUATE=1, PERRY_GC_VERIFY_EVACUATION=1, and
PERRY_GC_PROTECT_FROMSPACE=1 at depth 800.

That is consistent with the string audit having found these windows by reading rather than by
reproducing, and with this bug class being invisible at collection time. The changes are
still worth landing — an unrooted borrow across user JS is a real latent hazard. But the
merge rests on "correct by construction and regression-free", not on "fixes an observed
failure", and the fixtures should be understood as no-regression guards rather than
reproducers.

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.

runtime: js_regexp_exec holds the subject payload borrow across the lastIndex Get/ToLength user side effects

1 participant