You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 array
js_string_from_str(m.as_str()) per capture — so capture N+1's as_str()
reads a borrow that capture N's allocation could have invalidated
crate::object::js_object_alloc(0, 0) + js_object_set_field_by_name — the
named-capture groups object
set_exec_array_metadata(arr, str_data, …) in the fancy arm — allocates the "index" key string, then copies input out of the borrow
set_exec_array_indices / set_exec_array_indices_fancy
(regex/exec_array.rs) — these allocate an array and a [start, end] pair per
capture, and call byte_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 String per capture vs. byte ranges.match_all pays a to_string() per capture. exec is hotter (tokenizers, routers, ajv), so the
cheaper shape is to snapshot Vec<Option<(byte_start, byte_len, utf16_len, flags)>>
in phase 1 and build each capture with string::string_copy_range — which
roots its source and re-reads after the allocation — for zero extra copies.
Needs compute_utf16_len_wtf8 / bytes_have_lone_surrogate on 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 every byte_index_to_utf16_index into a Vec<Option<(f64, f64)>>before the first js_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_slow
GcTriggerThresholdTestGuard::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.
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 collectionpoint is user JS (
ToLength(Get(R, "lastIndex"))running a coerciblelastIndex'svalueOf). What it deliberately did not close is theallocation-point window that follows.
After the match,
crates/perry-runtime/src/regex/exec.rskeeps usingstr_data(the
&strinto the subject's inline WTF-8 payload) and theregex::Captures/fancy_regex::Capturesborrowing 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 invalidated
crate::object::js_object_alloc(0, 0)+js_object_set_field_by_name— thenamed-capture
groupsobjectset_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 percapture, and call
byte_index_to_utf16_index(str_data, …)between theallocations
crates/perry-runtime/src/regex/match_string.rs(js_string_match, thenon-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 callsforce_full_scan()unconditionallysince #7682 — a conservative stack scan makes the copying minor ineligible, so
nothing moves. That is the same "closed by accident" property
string/alloc.rsdocuments 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'smaterialize_match_all_resultsruns a Phase 1 (borrowing, no JS allocation)snapshot into owned Rust data, then a Phase 2 that allocates only from the
snapshot (see
OwnedMatchAllDataand its doc comment, which names this exacthazard from the 2026-07-09 audit wave 1). Give
execandjs_string_matchthesame two-phase shape.
Two sub-decisions worth measuring rather than guessing, which is why this is not
folded into #8446:
Stringper capture vs. byte ranges.match_allpays ato_string()per capture.execis hotter (tokenizers, routers, ajv), so thecheaper shape is to snapshot
Vec<Option<(byte_start, byte_len, utf16_len, flags)>>in phase 1 and build each capture with
string::string_copy_range— whichroots its source and re-reads after the allocation — for zero extra copies.
Needs
compute_utf16_len_wtf8/bytes_have_lone_surrogateon the phase-1side so the WTF-8 flags stay exact.
set_exec_array_indices{,_fancy}can be fixed in place without touchingexec's structure: precompute everybyte_index_to_utf16_indexinto aVec<Option<(f64, f64)>>before the firstjs_array_alloc, then allocatefrom that. Self-contained and worth doing first.
Also fold in: the fancy arm's
.input(set_exec_array_metadata) copies thesubject out of the borrow after allocating the
"index"key; the standard armalready re-boxes the rooted subject via
set_exec_array_metadata_groups_fresh.Validation
gc::tests::runtime_roots::regexp_last_index(added infix(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(seeruntime_roots/string_slice.rs) with the alloc-point scan pinned off(
ConservativeScanDisabledGuard), otherwise the minor is non-moving and thetest is vacuous.
proving nothing.
Strings.Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump.