From 66863bfe3544797b5a0d993f27dc71a6bc82bba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 20 Aug 2026 06:30:03 +0200 Subject: [PATCH 1/2] fix(runtime): root string copy sources across allocation --- changelog.d/TMP-gc-safe-string-copy.md | 3 +++ crates/perry-runtime/src/builtins/globals.rs | 8 +++++--- crates/perry-runtime/src/string/compare.rs | 8 +++++--- .../test_gap_gc_string_copy_source_rooting.ts | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 changelog.d/TMP-gc-safe-string-copy.md create mode 100644 test-files/test_gap_gc_string_copy_source_rooting.ts diff --git a/changelog.d/TMP-gc-safe-string-copy.md b/changelog.d/TMP-gc-safe-string-copy.md new file mode 100644 index 0000000000..83c2179505 --- /dev/null +++ b/changelog.d/TMP-gc-safe-string-copy.md @@ -0,0 +1,3 @@ +### Fixed + +- `String.prototype.toWellFormed()` and `structuredClone()` now keep source strings rooted while allocating their copies, preventing moving garbage collection from reading stale string payload pointers. diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index 950c180656..54b3a1a1f4 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -747,9 +747,11 @@ fn js_structured_clone_inner(value: f64, depth: usize) -> f64 { return value; } unsafe { - let len = (*str_ptr).byte_len as usize; - let data = (str_ptr as *const u8).add(std::mem::size_of::()); - let new_str = js_string_from_bytes(data, len as u32); + let len = (*str_ptr).byte_len; + let utf16_len = (*str_ptr).utf16_len; + let flags = (*str_ptr).flags; + // Root and refresh the source across the destination allocation. + let new_str = crate::string::string_copy_range(str_ptr, 0, len, utf16_len, flags); let new_bits = 0x7FFF_0000_0000_0000u64 | (new_str as u64 & 0x0000_FFFF_FFFF_FFFF); f64::from_bits(new_bits) } diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index 00cfa90b89..d0d6865bde 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -777,11 +777,13 @@ pub extern "C" fn js_string_to_well_formed(s: *const StringHeader) -> *mut Strin } let flags = unsafe { (*s).flags }; let blen = unsafe { (*s).byte_len } as usize; - let data = string_data(s); if flags & STRING_FLAG_HAS_LONE_SURROGATES == 0 { - // Well-formed UTF-8: return a copy without scanning - return js_string_from_bytes(data, blen as u32); + // Well-formed UTF-8: return a copy without scanning. The destination + // allocation can move `s`, so refresh its payload pointer afterwards. + let utf16_len = unsafe { (*s).utf16_len }; + return string_copy_range(s, 0, blen as u32, utf16_len, flags); } + let data = string_data(s); // Scan raw bytes and replace every WTF-8 lone-surrogate sequence with U+FFFD. // WTF-8 surrogate: first byte = 0xED, second = 0xA0..=0xBF, third = 0x80..=0xBF. let bytes = unsafe { slice::from_raw_parts(data, blen) }; diff --git a/test-files/test_gap_gc_string_copy_source_rooting.ts b/test-files/test_gap_gc_string_copy_source_rooting.ts new file mode 100644 index 0000000000..62dc749112 --- /dev/null +++ b/test-files/test_gap_gc_string_copy_source_rooting.ts @@ -0,0 +1,19 @@ +// #8423: both of these operations used to take a raw pointer into a source +// string's inline payload and then allocate the destination before copying it. +// Keep the two paths under allocation pressure together so moving alloc-point +// collections can exercise the source-rooting contract as that GC path evolves. +// parity-env: PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 + +const astral = "🦆"; + +let badWellFormed = 0; +let badClone = 0; +for (let i = 0; i < 1000; i++) { + // A fresh, non-SSO source gives the collector a young heap cell each time. + const source = `well-formed-${astral}-${i}-`.repeat(16) + "tail"; + if (source.toWellFormed() !== source) badWellFormed++; + if (structuredClone(source) !== source) badClone++; +} + +console.log("toWellFormed copies:", badWellFormed === 0); +console.log("structuredClone copies:", badClone === 0); From d0e98fc4c20b6093f6842ac0ed016acb928cedf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 20 Aug 2026 06:30:40 +0200 Subject: [PATCH 2/2] chore: key changelog fragment to PR 8439 --- .../{TMP-gc-safe-string-copy.md => 8439-gc-safe-string-copy.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{TMP-gc-safe-string-copy.md => 8439-gc-safe-string-copy.md} (100%) diff --git a/changelog.d/TMP-gc-safe-string-copy.md b/changelog.d/8439-gc-safe-string-copy.md similarity index 100% rename from changelog.d/TMP-gc-safe-string-copy.md rename to changelog.d/8439-gc-safe-string-copy.md