Repository navigation
runtime: js_regexp_exec keeps the subject borrow (and its Captures) live across the result-array, capture-string and groups allocations #8449
Description
Follow-up to #8428 (fixed in #8446), same borrowed-heap-slice class as #8423.
Problem
#8446 closed the live window in js_regexp_exec — the one whose collection
point is user JS (ToLength(Get(R, "lastIndex")) running a coercible
lastIndex's valueOf). What it deliberately did not close is the
allocation-point window that follows.
After the match, crates/perry-runtime/src/regex/exec.rs keeps using str_data
(the &str into the subject's inline WTF-8 payload) and the regex::Captures /
fancy_regex::Captures borrowing it, across every one of these allocations:
crate::array::js_array_alloc(caps.len())— the result arrayjs_string_from_str(m.as_str())per capture — so capture N+1'sas_str()
reads a borrow that capture N's allocation could have invalidatedcrate::object::js_object_alloc(0, 0)+js_object_set_field_by_name— the
named-capturegroupsobjectset_exec_array_metadata(arr, str_data, …)in the fancy arm — allocates the
"index"key string, then copiesinputout of the borrowset_exec_array_indices/set_exec_array_indices_fancy
(regex/exec_array.rs) — these allocate an array and a[start, end]pair per
capture, and callbyte_index_to_utf16_index(str_data, …)between the
allocations
crates/perry-runtime/src/regex/match_string.rs (js_string_match, the
non-global fancy arm and the standard non-global arm) has the identical shape.
Why this is latent rather than live
The only collection points in this window are the allocations themselves, and
gc_check_trigger's nursery-churn arm calls force_full_scan() unconditionally
since #7682 — a conservative stack scan makes the copying minor ineligible, so
nothing moves. That is the same "closed by accident" property string/alloc.rs
documents for #7213 and #8423's write-up spells out, and it is exactly the
property the moving-GC roadmap keeps eroding. These sites should not depend on it.
Suggested fix
The pattern already exists in this module: regex/match_all.rs's
materialize_match_all_results runs a Phase 1 (borrowing, no JS allocation)
snapshot into owned Rust data, then a Phase 2 that allocates only from the
snapshot (see OwnedMatchAllData and its doc comment, which names this exact
hazard from the 2026-07-09 audit wave 1). Give exec and js_string_match the
same two-phase shape.
Two sub-decisions worth measuring rather than guessing, which is why this is not
folded into #8446:
- Owned
Stringper capture vs. byte ranges.match_allpays a
to_string()per capture.execis hotter (tokenizers, routers, ajv), so the
cheaper shape is to snapshotVec<Option<(byte_start, byte_len, utf16_len, flags)>>
in phase 1 and build each capture withstring::string_copy_range— which
roots its source and re-reads after the allocation — for zero extra copies.
Needscompute_utf16_len_wtf8/bytes_have_lone_surrogateon the phase-1
side so the WTF-8 flags stay exact. set_exec_array_indices{,_fancy}can be fixed in place without touching
exec's structure: precompute everybyte_index_to_utf16_indexinto a
Vec<Option<(f64, f64)>>before the firstjs_array_alloc, then allocate
from that. Self-contained and worth doing first.
Also fold in: the fancy arm's .input (set_exec_array_metadata) copies the
subject out of the borrow after allocating the "index" key; the standard arm
already re-boxes the rooted subject via set_exec_array_metadata_groups_fresh.
Validation
- The witness shape is
gc::tests::runtime_roots::regexp_last_index(added in
fix(runtime): coerce lastIndex before borrowing the regex subject #8446): plant the collection, assert the subject was live and moved, then
assert the captures. For this issue the collection has to be injected at an
allocation rather than in a callback —force_next_general_arena_alloc_slowGcTriggerThresholdTestGuard::make_arena_trigger_due(see
runtime_roots/string_slice.rs) with the alloc-point scan pinned off
(ConservativeScanDisabledGuard), otherwise the minor is non-moving and the
test is vacuous.
- Sabotage-check it: the test must fail against the pre-fix code, or it is
proving nothing. - Watch for a regression on regex-heavy workloads if option 1 lands as owned
Strings.
Workflow
PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump.
Activity
fix(runtime): land #8555 — snapshot regex captures before allocation (#…