Problem
js_string_repeat borrows the subject string's payload, then runs user JavaScript (the count argument's valueOf) while holding the borrow, then reads it (crates/perry-runtime/src/string/pad.rs:296-317):
let str_data = string_as_str(s); // :302 — borrow of s's payload
let count_number = crate::builtins::js_number_coerce(count_value); // :303 — runs user valueOf
...
if count_integer == 0.0 || str_data.is_empty() { // :309 — reads borrow
...
let result = str_data.repeat(count); // :314 — reads borrow
"abcdef".repeat({ valueOf() { /* arbitrary JS */ return 3; } }) is the trigger. User JS in the window can run a moving collection today: a loop in the callback hits back-edge safepoint polls (default-on, #7721) where the copying minor is eligible; if it evacuates s (young heap string), str_data dangles into from-space and repeat copies garbage (or faults under the quarantine instrument).
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), moved by the copying minor. Rooting rewrites slots, not 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, which is how this compiled.
Suggested fix
Reorder — coerce count_value (and run the range checks, :303-307) before taking str_data. Nothing after the coercion runs user code, and the final js_string_from_bytes(result…) copies from an owned Rust String (safe). The observable coercion-before-emptiness-check ordering per ECMA-262 §22.1.3.17 (ToIntegerOrInfinity runs even for the empty string, and a negative count throws before the empty check) is preserved by the reorder — add the fixture case "".repeat({valueOf(){ throw-side-effect-order... }}) variants only if Node semantics are asserted by the byte-compare anyway.
Check the same file for the pad paths: js_string_pad_* take str_data and pad arguments too — if the pad-string/length coercions run user code after the subject borrow (see finish_pad_result callers around :290), fix them in the same PR with the same reorder; state in the PR whether they were affected.
Repro / validation
- Fixture
test-files/test_issue_<this>_repeat_reentrant.ts: build a young >5-byte subject dynamically (heap, not SSO/interned), call .repeat({valueOf(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return 3; }}), print result; byte-compare vs Node 26.5.1.
- 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 the fix.
- 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_regexp_exec (lastIndex coercion) — filed separately.
Workflow
PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge).
Problem
js_string_repeatborrows the subject string's payload, then runs user JavaScript (the count argument'svalueOf) while holding the borrow, then reads it (crates/perry-runtime/src/string/pad.rs:296-317):"abcdef".repeat({ valueOf() { /* arbitrary JS */ return 3; } })is the trigger. User JS in the window can run a moving collection today: a loop in the callback hits back-edge safepoint polls (default-on, #7721) where the copying minor is eligible; if it evacuatess(young heap string),str_datadangles into from-space andrepeatcopies garbage (or faults under the quarantine instrument).Background (borrowed-heap-slice class)
Perry strings:
STRING_TAGNaN-box → 20-byteStringHeader, WTF-8 payload inline (crates/perry-runtime/src/string/mod.rs:307), moved by the copying minor. Rooting rewrites slots, not 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, which is how this compiled.Suggested fix
Reorder — coerce
count_value(and run the range checks,:303-307) before takingstr_data. Nothing after the coercion runs user code, and the finaljs_string_from_bytes(result…)copies from an owned RustString(safe). The observable coercion-before-emptiness-check ordering per ECMA-262 §22.1.3.17 (ToIntegerOrInfinity runs even for the empty string, and a negative count throws before the empty check) is preserved by the reorder — add the fixture case"".repeat({valueOf(){ throw-side-effect-order... }})variants only if Node semantics are asserted by the byte-compare anyway.Check the same file for the pad paths:
js_string_pad_*takestr_dataand pad arguments too — if the pad-string/length coercions run user code after the subject borrow (seefinish_pad_resultcallers around:290), fix them in the same PR with the same reorder; state in the PR whether they were affected.Repro / validation
test-files/test_issue_<this>_repeat_reentrant.ts: build a young >5-byte subject dynamically (heap, not SSO/interned), call.repeat({valueOf(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return 3; }}), print result; byte-compare vs Node 26.5.1.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 the fix.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_regexp_exec(lastIndex coercion) — filed separately.Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge).