Skip to content

runtime: js_string_repeat holds a payload borrow across user valueOf — moving GC can relocate the subject mid-call #8427

Description

@proggeramlug

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

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