Problem
Several string-producing builtins build their result in a Rust String/Vec<u8> on the Rust heap, then copy it into a fresh StringHeader — two full copies plus a malloc/free per call, and in one case no capacity hint at all:
Array.prototype.join — crates/perry-runtime/src/array/iter_methods.rs (~line 1122): let mut result = String::new(); — not even with_capacity, so the Rust-side buffer reallocates log(n) times on top of the final copy.
String.prototype.repeat — crates/perry-runtime/src/string/pad.rs:314: str_data.repeat(count) then copy.
padStart/padEnd — crates/perry-runtime/src/string/pad.rs (finish_pad_result, ~line 186): Rust-side assembly then copy.
- regex
replace paths — crates/perry-runtime/src/regex/replace_fn.rs (~lines 84/103) and siblings: Rust String assembly then copy.
Important context — why the naive fix is WRONG
Building in a Rust-heap buffer first is currently the GC-safe pattern: Rust-heap allocation never triggers a Perry collection, so the borrowed source bytes stay valid while building; the single GC allocation happens at the end with an owned source. Writing directly into a StringHeader payload means the GC allocation happens FIRST — and that allocation can collect and move the source strings, invalidating any raw source pointers taken before it.
The sound direct-write shape already exists in-tree; follow it exactly:
js_string_alloc_ascii_uninit (crates/perry-runtime/src/string/mod.rs:679) — allocate the destination first, then fill; 5 callers in buffer/ show the pattern.
string_copy_range (string/mod.rs:704) — root source, allocate, re-read source from the rooted handle after the allocation, then copy.
string/split.rs:479-520 — records byte ranges instead of borrows and re-derives the source pointer from a rooted handle per use.
So the recipe per function: compute the exact output size (join: sum of element string lengths + separators — note elements may need ToString coercion, which can run user JS and collect, so coerce-and-measure must itself be rooted), allocate the destination header once, re-read all source pointers from rooted handles, then memcpy pieces directly. Where sizing requires coercion side effects that must not run twice, coerce once into a rooted scratch array first (the js_string_concat_chain runtime helper, string/concat.rs:478, does exactly this dance and is the best model).
Expectations — measure before claiming
This removes a constant factor (one copy + Rust malloc traffic), not an asymptotic class. Benchmark each converted function on a realistic size sweep (e.g. join of 10k short strings; repeat to 1MB; a replace-heavy text pass) with instruction counts and wall time, before/after. If a conversion's win is inside noise, keep the Rust-String version and say so with numbers — the simpler code wins ties. join (missing with_capacity) is the most likely real win; start there. At minimum, String::with_capacity for join is a one-line no-risk improvement even if the full conversion isn't justified.
Validation
- Byte-identical output vs Node 26.5.1 on
test-files/ fixtures covering: WTF-8 lone surrogates in sources (flag STRING_FLAG_HAS_LONE_SURROGATES must propagate — check how js_string_from_wtf8_bytes sets it), empty separators/elements, holes in join arrays (spec: absent → empty string), astral chars.
- GC instrument pass on a fixture whose join elements have a user
toString that allocates: PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1.
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime for the touched modules.
- Build note:
cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static before any end-to-end check (stale-.a trap).
Workflow
PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge). One function family per PR is fine; join first.
Problem
Several string-producing builtins build their result in a Rust
String/Vec<u8>on the Rust heap, then copy it into a freshStringHeader— two full copies plus a malloc/free per call, and in one case no capacity hint at all:Array.prototype.join—crates/perry-runtime/src/array/iter_methods.rs(~line 1122):let mut result = String::new();— not evenwith_capacity, so the Rust-side buffer reallocates log(n) times on top of the final copy.String.prototype.repeat—crates/perry-runtime/src/string/pad.rs:314:str_data.repeat(count)then copy.padStart/padEnd—crates/perry-runtime/src/string/pad.rs(finish_pad_result, ~line 186): Rust-side assembly then copy.replacepaths —crates/perry-runtime/src/regex/replace_fn.rs(~lines 84/103) and siblings: RustStringassembly then copy.Important context — why the naive fix is WRONG
Building in a Rust-heap buffer first is currently the GC-safe pattern: Rust-heap allocation never triggers a Perry collection, so the borrowed source bytes stay valid while building; the single GC allocation happens at the end with an owned source. Writing directly into a
StringHeaderpayload means the GC allocation happens FIRST — and that allocation can collect and move the source strings, invalidating any raw source pointers taken before it.The sound direct-write shape already exists in-tree; follow it exactly:
js_string_alloc_ascii_uninit(crates/perry-runtime/src/string/mod.rs:679) — allocate the destination first, then fill; 5 callers inbuffer/show the pattern.string_copy_range(string/mod.rs:704) — root source, allocate, re-read source from the rooted handle after the allocation, then copy.string/split.rs:479-520— records byte ranges instead of borrows and re-derives the source pointer from a rooted handle per use.So the recipe per function: compute the exact output size (join: sum of element string lengths + separators — note elements may need ToString coercion, which can run user JS and collect, so coerce-and-measure must itself be rooted), allocate the destination header once, re-read all source pointers from rooted handles, then memcpy pieces directly. Where sizing requires coercion side effects that must not run twice, coerce once into a rooted scratch array first (the
js_string_concat_chainruntime helper,string/concat.rs:478, does exactly this dance and is the best model).Expectations — measure before claiming
This removes a constant factor (one copy + Rust malloc traffic), not an asymptotic class. Benchmark each converted function on a realistic size sweep (e.g. join of 10k short strings; repeat to 1MB; a replace-heavy text pass) with instruction counts and wall time, before/after. If a conversion's win is inside noise, keep the Rust-String version and say so with numbers — the simpler code wins ties.
join(missingwith_capacity) is the most likely real win; start there. At minimum,String::with_capacityforjoinis a one-line no-risk improvement even if the full conversion isn't justified.Validation
test-files/fixtures covering: WTF-8 lone surrogates in sources (flagSTRING_FLAG_HAS_LONE_SURROGATESmust propagate — check howjs_string_from_wtf8_bytessets it), empty separators/elements, holes in join arrays (spec: absent → empty string), astral chars.toStringthat allocates:PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1.RUST_TEST_THREADS=1 cargo test --release -p perry-runtimefor the touched modules.cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-staticbefore any end-to-end check (stale-.atrap).Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge). One function family per PR is fine; join first.