Problem
js_regexp_exec borrows the subject string's payload, then observes lastIndex — a Get → ToLength that, per the function's own spec comment, deliberately surfaces user valueOf/toString side effects — while holding the borrow (crates/perry-runtime/src/regex/exec.rs:36-75):
let str_data = string_as_str(s); // :36 — borrow of s's payload
...
// "the read — and any valueOf/toString side effect of a coercible
// lastIndex — is observed exactly once" (test262 …lastindex-access)
let last_index_read = regex_last_index_offset(re); // :51 — can run user JS
...
super::exec_array::utf16_index_to_byte(str_data, last_index) // :61 — reads borrow
...
let search_str = &str_data[search_start_byte..]; // :75 — reads borrow
re.lastIndex = { valueOf() { /* arbitrary JS */ return 0; } }; re.exec(subject) is the trigger (the comment cites the exact test262 family proving this path runs user code). User JS in the window can run a moving collection today (back-edge safepoint polls in the callback, default-on since #7721, make the copying minor eligible); if the subject string is evacuated, str_data dangles into from-space for the entire match.
Also audit set_last_index_throwing (:68) in the same pass: if a user-defined lastIndex accessor can run there, the same hazard applies after that call. And check the sibling entry points that share this shape — regex/match_string.rs:81, regex/match_all.rs:92, regex/replace_* — for the same borrow-before-user-code ordering.
Background (borrowed-heap-slice class)
Perry strings: STRING_TAG NaN-box → 20-byte StringHeader, WTF-8 payload inline (crates/perry-runtime/src/string/mod.rs:307), young-generation, relocated by the copying minor. Rooting rewrites slots, never an already-materialized &str (HeapKeyBytes doc, crates/perry-runtime/src/object/field_get_set.rs:11-27). string_as_str<'a> (string/mod.rs:791) has a caller-chosen lifetime.
Suggested fix
Reorder is again the minimal fix: perform the lastIndex read (:51) — and, if it can run user code, the reset at :68 — before taking str_data. The spec ordering (lastIndex Get happens before the match) is exactly what the reorder produces; the current code already reads lastIndex up front, it just takes the borrow one step too early. If any later call in the function can allocate through the GC before the last str_data use (the match itself is pure Rust over the borrow; the result-array allocation at :95+ happens after — verify the borrow is dead by then, or re-derive), re-derive str_data from a rooted handle after each such point instead (RuntimeHandleScope::root_string_ptr + re-read; string/split.rs:479-520 shows the ranges-not-borrows pattern for match loops).
Repro / validation
- Fixture
test-files/test_issue_<this>_exec_lastindex_reentrant.ts: global regex, dynamically-built young subject (>5 bytes, heap), re.lastIndex = {valueOf(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return 0; }}, exec + print groups; byte-compare vs Node 26.5.1. Cover both the std-regex arm and the fancy-regex arm (a lookbehind pattern) — the borrow feeds both.
- Fault demonstration BEFORE the fix:
PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 PERRY_GC_FORCE_EVACUATE=1 — expect a from-space fault naming obj_type 3; PERRY_GC_DIAG=1 must show [gc-fromspace-protect] retired_set= (non-vacuous). Clean after.
- Build note:
cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static (stale-.a trap); perry-runtime tests RUST_TEST_THREADS=1.
Siblings, same class: #8423 (latent allocation-point windows), js_string_normalize (user toString), js_string_repeat (user valueOf) — filed separately.
Workflow
PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge).
Problem
js_regexp_execborrows the subject string's payload, then observeslastIndex— a Get → ToLength that, per the function's own spec comment, deliberately surfaces uservalueOf/toStringside effects — while holding the borrow (crates/perry-runtime/src/regex/exec.rs:36-75):re.lastIndex = { valueOf() { /* arbitrary JS */ return 0; } }; re.exec(subject)is the trigger (the comment cites the exact test262 family proving this path runs user code). User JS in the window can run a moving collection today (back-edge safepoint polls in the callback, default-on since #7721, make the copying minor eligible); if the subject string is evacuated,str_datadangles into from-space for the entire match.Also audit
set_last_index_throwing(:68) in the same pass: if a user-definedlastIndexaccessor can run there, the same hazard applies after that call. And check the sibling entry points that share this shape —regex/match_string.rs:81,regex/match_all.rs:92,regex/replace_*— for the same borrow-before-user-code ordering.Background (borrowed-heap-slice class)
Perry strings:
STRING_TAGNaN-box → 20-byteStringHeader, WTF-8 payload inline (crates/perry-runtime/src/string/mod.rs:307), young-generation, relocated by the copying minor. Rooting rewrites slots, never an already-materialized&str(HeapKeyBytesdoc,crates/perry-runtime/src/object/field_get_set.rs:11-27).string_as_str<'a>(string/mod.rs:791) has a caller-chosen lifetime.Suggested fix
Reorder is again the minimal fix: perform the
lastIndexread (:51) — and, if it can run user code, the reset at:68— before takingstr_data. The spec ordering (lastIndex Get happens before the match) is exactly what the reorder produces; the current code already reads lastIndex up front, it just takes the borrow one step too early. If any later call in the function can allocate through the GC before the laststr_datause (the match itself is pure Rust over the borrow; the result-array allocation at:95+happens after — verify the borrow is dead by then, or re-derive), re-derivestr_datafrom a rooted handle after each such point instead (RuntimeHandleScope::root_string_ptr+ re-read;string/split.rs:479-520shows the ranges-not-borrows pattern for match loops).Repro / validation
test-files/test_issue_<this>_exec_lastindex_reentrant.ts: global regex, dynamically-built young subject (>5 bytes, heap),re.lastIndex = {valueOf(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return 0; }}, exec + print groups; byte-compare vs Node 26.5.1. Cover both the std-regex arm and the fancy-regex arm (a lookbehind pattern) — the borrow feeds both.PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 PERRY_GC_FORCE_EVACUATE=1— expect a from-space fault namingobj_type3;PERRY_GC_DIAG=1must show[gc-fromspace-protect] retired_set=(non-vacuous). Clean after.cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static(stale-.atrap); perry-runtime testsRUST_TEST_THREADS=1.Siblings, same class: #8423 (latent allocation-point windows),
js_string_normalize(usertoString),js_string_repeat(uservalueOf) — filed separately.Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge).