Skip to content

runtime: js_string_normalize holds a payload borrow across user toString — moving GC can relocate the subject mid-call #8426

Description

@proggeramlug

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions