Problem
js_string_normalize borrows the subject string's payload, then runs user JavaScript (the form argument's toString) while holding the borrow, then reads the borrow (crates/perry-runtime/src/string/compare.rs:513-550):
let str_data = string_as_str(s); // :520 — borrow of s's payload
...
crate::builtins::reject_symbol_to_string(form_value);
let form_ptr = crate::value::js_jsvalue_to_string(form_value); // :532 — runs user toString
...
"NFC" => str_data.nfc().collect(), // :544-547 — reads the stale borrow
"…".normalize({ toString() { /* arbitrary JS */ return "NFC"; } }) is the trigger. Unlike the allocation-point-only windows (#8423), user JS in the window can run a moving collection today: a loop inside the callback hits back-edge safepoint polls (default-on since #7721), where the copying minor is eligible. If it evacuates s (a young heap string), str_data points into from-space; the normalize pass then reads recycled or poisoned bytes.
Background (borrowed-heap-slice class)
Perry strings are STRING_TAG NaN-boxes → 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 — the written rule is HeapKeyBytes's doc (crates/perry-runtime/src/object/field_get_set.rs:11-27): stop borrowing, or re-derive after every collection point. string_as_str<'a> (string/mod.rs:791) has a caller-chosen lifetime, which is how this compiled.
Suggested fix
Reorder — the minimal sound fix: coerce/validate the form argument first (lines 525-538 need nothing from str_data), and take let str_data = string_as_str(s) only after the last user-code/allocating call, immediately before the normalization match. Nothing else in the function allocates through the GC before the final js_string_from_bytes of the result (built from an owned Rust String, which is safe). Keep the Symbol-throws-TypeError-before-RangeError ordering (#2782 comment) — it is observable and the reorder must not change it (it doesn't: coercion still runs first).
Note both cfg arms (string-normalize feature on/off, :540-554) read str_data/form — fix covers both.
Repro / validation
- Fixture
test-files/test_issue_<this>_normalize_reentrant.ts: build a young multi-byte subject dynamically (e.g. concat pieces at runtime, >5 bytes so it is heap not SSO, non-interned), then call subject.normalize({toString(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return "NFC"; }}) and print the result — byte-compare against Node 26.5.1.
- Fault demonstration BEFORE the fix (proves the test can fail): run the fixture with
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 SIGSEGV whose reporter names obj_type 3 (GC_TYPE_STRING) / 0xDE poison. With PERRY_GC_DIAG=1 confirm [gc-fromspace-protect] retired_set= appears (a run with zero copying minors protects nothing). After the fix: clean under the same knobs.
- Build note:
cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static (building only -p perry-runtime leaves a stale libperry_runtime.a and both arms of the A/B behave identically). perry-runtime unit tests: RUST_TEST_THREADS=1.
Siblings, same class: #8423 (latent, allocation-point windows), js_string_repeat (user valueOf), 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_normalizeborrows the subject string's payload, then runs user JavaScript (the form argument'stoString) while holding the borrow, then reads the borrow (crates/perry-runtime/src/string/compare.rs:513-550):"…".normalize({ toString() { /* arbitrary JS */ return "NFC"; } })is the trigger. Unlike the allocation-point-only windows (#8423), user JS in the window can run a moving collection today: a loop inside the callback hits back-edge safepoint polls (default-on since #7721), where the copying minor is eligible. If it evacuatess(a young heap string),str_datapoints into from-space; the normalize pass then reads recycled or poisoned bytes.Background (borrowed-heap-slice class)
Perry strings are
STRING_TAGNaN-boxes → 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— the written rule isHeapKeyBytes's doc (crates/perry-runtime/src/object/field_get_set.rs:11-27): stop borrowing, or re-derive after every collection point.string_as_str<'a>(string/mod.rs:791) has a caller-chosen lifetime, which is how this compiled.Suggested fix
Reorder — the minimal sound fix: coerce/validate the form argument first (lines 525-538 need nothing from
str_data), and takelet str_data = string_as_str(s)only after the last user-code/allocating call, immediately before the normalization match. Nothing else in the function allocates through the GC before the finaljs_string_from_bytesof the result (built from an owned RustString, which is safe). Keep the Symbol-throws-TypeError-before-RangeError ordering (#2782 comment) — it is observable and the reorder must not change it (it doesn't: coercion still runs first).Note both cfg arms (
string-normalizefeature on/off,:540-554) readstr_data/form — fix covers both.Repro / validation
test-files/test_issue_<this>_normalize_reentrant.ts: build a young multi-byte subject dynamically (e.g. concat pieces at runtime, >5 bytes so it is heap not SSO, non-interned), then callsubject.normalize({toString(){ let junk=""; for(let i=0;i<5000;i++) junk = junk + "x"; return "NFC"; }})and print the result — byte-compare against 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 SIGSEGV whose reporter namesobj_type3 (GC_TYPE_STRING) / 0xDE poison. WithPERRY_GC_DIAG=1confirm[gc-fromspace-protect] retired_set=appears (a run with zero copying minors protects nothing). After the fix: clean under the same knobs.cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static(building only-p perry-runtimeleaves a stalelibperry_runtime.aand both arms of the A/B behave identically). perry-runtime unit tests:RUST_TEST_THREADS=1.Siblings, same class: #8423 (latent, allocation-point windows),
js_string_repeat(uservalueOf),js_regexp_exec(lastIndex coercion) — filed separately.Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge).