diff --git a/changelog.d/8581-8434-join-and-builders.md b/changelog.d/8581-8434-join-and-builders.md new file mode 100644 index 0000000000..3ff83dccee --- /dev/null +++ b/changelog.d/8581-8434-join-and-builders.md @@ -0,0 +1,3 @@ +**Land both #8434 string-builder wins — #8571's exact-allocation `String.prototype.repeat` and buffered-path WTF-8 finalization, plus #8568's exact-size direct-write `Array.prototype.join` — with two integration fixes neither PR had.** The PRs had reported opposite verdicts on "direct-write join" (1.38x faster vs 6.83x slower) because they measured different algorithms under one label: #8571's rejected candidate paid a per-element handle scope, a `js_jsvalue_to_string` coercion, and a barriered GC scratch-array store for every element, while #8568's computes the exact output size over the dense payload and allocates once. Re-measured interleaved on one host: joins of short strings retire 25.7% fewer instructions (0.68x wall), `repeat` retires 74.3% fewer (0.27x wall), and the rebuilt rejected candidate reproduces at 13.5x — both authors measured honestly. + +Fix 1 (#8568 correctness): the exact-size join assumed `byte_len == utf16_len` for SSO elements, but SSO length counts bytes and `JSON.parse` emits non-ASCII (and WTF-8 lone-surrogate) SSO payloads — the result header recorded a wrong `utf16_len` and could miss lone-surrogate flags. Such elements now take the canonicalizing builder path with their true UTF-16 length (metadata corruption only; `utf16_len <= byte_len` kept the byte-exact allocation safe). Fix 2 (#8568 performance): numeric elements paid a per-element handle scope plus GC string allocation in the builder — 2.7x main's retired instructions on a numbers-only sweep that #8568's strings-only fixture could not see. Number formatting runs no user code, so it needs no rooting: integer-likes format via stack `itoa`, the rest via the shared `js_format_f64` — numbers-join now retires 32.9% fewer instructions than main (0.59x wall), and `[1e21].join()` now matches `String(1e21)`/Node (`"1e+21"`, previously the 22-digit decimal). Verified byte-exact against Node 26.5.1 under `PERRY_GC_SCHEDULE_RATE=1` + `PERRY_GC_FORCE_EVACUATE=1` + `PERRY_GC_VERIFY_EVACUATION=1` + from-space quarantine (300/700/10205 forced collections across the three fixtures), with the 20-row sweep corpus byte-exact and instruction-flat. diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 5a1eb632b1..4afc2b90cb 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -1077,286 +1077,6 @@ pub(crate) fn throw_reduce_of_empty() -> ! { crate::exception::js_throw(f64::from_bits(err_value)) } -/// Read one exotic join element while keeping the receiver current across a -/// getter or prototype lookup that can allocate and move it. -#[cold] -#[inline(never)] -fn join_exotic_element(arr: *const ArrayHeader, index: u32) -> (Option, *const ArrayHeader) { - let scope = crate::gc::RuntimeHandleScope::new(); - let arr_handle = scope.root_raw_const_ptr(arr); - let present = arr_handle.with_const_ptr(|arr| crate::array::array_spec_has_index(arr, index)); - if !present { - return (None, arr_handle.with_const_ptr(normalize_array_receiver)); - } - let value = arr_handle.with_const_ptr(|arr| crate::array::array_spec_get(arr, index)); - ( - Some(value.to_bits()), - arr_handle.with_const_ptr(normalize_array_receiver), - ) -} - -/// Run an allocating element conversion with the receiver and value rooted, -/// then return the receiver's post-collection address. -#[cold] -#[inline(never)] -fn join_element_to_string( - arr: *const ArrayHeader, - element_bits: u64, -) -> (*const crate::string::StringHeader, *const ArrayHeader) { - let scope = crate::gc::RuntimeHandleScope::new(); - let arr_handle = scope.root_raw_const_ptr(arr); - let element_handle = scope.root_nanbox_u64(element_bits); - let string = crate::value::js_jsvalue_to_string(element_handle.get_nanbox_f64()); - (string, arr_handle.with_const_ptr(normalize_array_receiver)) -} - -#[cold] -#[inline(never)] -fn join_bigint_to_string( - arr: *const ArrayHeader, - element_bits: u64, -) -> (*const crate::string::StringHeader, *const ArrayHeader) { - let scope = crate::gc::RuntimeHandleScope::new(); - let arr_handle = scope.root_raw_const_ptr(arr); - let element_handle = scope.root_nanbox_u64(element_bits); - let value = crate::value::JSValue::from_bits(element_handle.get_nanbox_u64()); - let string = crate::bigint::js_bigint_to_string(value.as_bigint_ptr()); - (string, arr_handle.with_const_ptr(normalize_array_receiver)) -} - -/// join - Join array elements into a string with a separator -/// Returns pointer to new StringHeader -#[no_mangle] -pub extern "C" fn js_array_join( - arr: *const ArrayHeader, - separator: *const crate::string::StringHeader, -) -> *mut crate::string::StringHeader { - use crate::string::{js_string_from_bytes, OwnedStringBytes, StringHeader}; - use crate::value::JSValue; - - let mut arr = normalize_array_receiver(arr); - if arr.is_null() { - return crate::string::js_string_from_bytes(b"".as_ptr(), 0); - } - // #3148: TypedArray receiver — join element-typed values (Node formatting). - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - return crate::typedarray::js_typed_array_join( - arr as *const crate::typedarray::TypedArrayHeader, - separator, - ); - } - unsafe { - let length = (*arr).length; - - // Empty array returns empty string - if length == 0 { - return js_string_from_bytes(ptr::null(), 0); - } - - let mut elements_ptr = - (arr as *const u8).add(std::mem::size_of::()) as *const f64; - let exotic = crate::array::array_iteration_is_exotic(arr); - - // Element coercion can run user code and move a heap separator. Keep an - // owned snapshot so the hot loop never holds a GC payload borrow across - // a collection point. - let separator_snapshot = - (!separator.is_null()).then(|| OwnedStringBytes::copy_from_header(separator)); - let separator_str = separator_snapshot - .as_ref() - .map_or(",", |bytes| std::str::from_utf8_unchecked(bytes.as_bytes())); - - // Separators are an exact lower bound for the result. Element - // coercion remains single-pass because it can run user code. - let separator_bytes = separator_str - .len() - .saturating_mul(length.saturating_sub(1) as usize); - let mut result = String::with_capacity(separator_bytes); - - for i in 0..length as usize { - if i > 0 { - result.push_str(separator_str); - } - let element_bits = if exotic { - let (element, arr_now) = join_exotic_element(arr, i as u32); - arr = arr_now; - let Some(bits) = element else { - // absent slot (own or inherited) → empty string per spec - continue; - }; - bits - } else { - let bits = (*elements_ptr.add(i)).to_bits(); - // Issue #907: `Array(n)` initializes slots to TAG_HOLE; per - // ES2015 §22.1.3.13 holes stringify to the empty string. - if bits == crate::value::TAG_HOLE { - continue; - } - bits - }; - let jsvalue = JSValue::from_bits(element_bits); - - // Convert element to string based on its type - if jsvalue.is_string() { - let str_ptr = jsvalue.as_string_ptr(); - let str_len = (*str_ptr).byte_len as usize; - let str_data = (str_ptr as *const u8).add(std::mem::size_of::()); - let s = - std::str::from_utf8_unchecked(std::slice::from_raw_parts(str_data, str_len)); - result.push_str(s); - } else if jsvalue.is_short_string() { - // v0.5.214 SSO — decode inline into a stack buffer - // and push bytes. No heap roundtrip via - // materialize_to_heap. - let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let n = jsvalue.short_string_to_buf(&mut scratch); - let s = std::str::from_utf8_unchecked(&scratch[..n]); - result.push_str(s); - } else if jsvalue.is_pointer() { - // POINTER_TAG. Two cases: - // 1. A genuine string NaN-boxed with POINTER_TAG instead of - // STRING_TAG (a cross-module mis-tag) — read its bytes. - // 2. A real heap object/array/error/buffer — these must go - // through the spec `ToString` (`js_jsvalue_to_string`): - // Array→nested join, Error→"name: message" (#2135), an - // object with a custom `toString`→that result, buffers, - // etc. The old code read *every* pointer as a - // `StringHeader`, so a non-string's garbage `byte_len` - // produced corrupted output (`[err].join()` → empty). - // Distinguish via the GcHeader type tag, excluding the - // headerless buffer/symbol pointers first. - let ptr_addr = (element_bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if ptr_addr >= 0x1000 { - let is_string_obj = !crate::buffer::is_registered_buffer(ptr_addr) - && !crate::symbol::is_registered_symbol(ptr_addr) - && { - let gc_header = (ptr_addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) - as *const crate::gc::GcHeader; - (*gc_header).obj_type == crate::gc::GC_TYPE_STRING - }; - let s_ptr = if is_string_obj { - ptr_addr as *const StringHeader - } else { - let (s_ptr, arr_now) = join_element_to_string(arr, element_bits); - arr = arr_now; - if !exotic { - elements_ptr = array_elements_ptr(arr); - } - s_ptr - }; - if !s_ptr.is_null() { - let str_len = (*s_ptr).byte_len as usize; - let str_data = - (s_ptr as *const u8).add(std::mem::size_of::()); - result.push_str(std::str::from_utf8_unchecked(std::slice::from_raw_parts( - str_data, str_len, - ))); - } - } else { - result.push_str("[object Object]"); - } - } else if jsvalue.is_bigint() { - // BigInt elements are NaN-boxed with BIGINT_TAG (not POINTER_TAG), - // so they bypass the pointer arm above and previously fell through - // to the `[object Object]` catch-all. ToString(BigInt) is the plain - // decimal digits with NO `n` suffix (`[10n].join() === "10"`). - let (s_ptr, arr_now) = join_bigint_to_string(arr, element_bits); - arr = arr_now; - if !exotic { - elements_ptr = array_elements_ptr(arr); - } - if !s_ptr.is_null() { - let str_len = (*s_ptr).byte_len as usize; - let str_data = (s_ptr as *const u8).add(std::mem::size_of::()); - result.push_str(std::str::from_utf8_unchecked(std::slice::from_raw_parts( - str_data, str_len, - ))); - } - } else if jsvalue.is_number() { - let n = jsvalue.as_number(); - if n.is_nan() { - result.push_str("NaN"); - } else if n.is_infinite() { - result.push_str(if n > 0.0 { "Infinity" } else { "-Infinity" }); - } else if n == 0.0 { - result.push('0'); - } else if n.fract() == 0.0 && n.abs() < 1e15 { - result.push_str(&format!("{}", n as i64)); - } else { - result.push_str(&format!("{}", n)); - } - } else if jsvalue.is_null() { - // null stringifies to empty string in join - } else if jsvalue.is_undefined() { - // undefined stringifies to empty string in join - } else if jsvalue.is_bool() { - result.push_str(if jsvalue.as_bool() { "true" } else { "false" }); - } else if element_bits > 0x1000 - && element_bits < 0x0001_0000_0000_0000 - && (element_bits & 0x3) == 0 - { - // Raw pointer fallback — string stored without NaN-box tag - let str_ptr = element_bits as *const StringHeader; - let str_len = (*str_ptr).byte_len as usize; - if str_len < 10_000_000 { - let str_data = (str_ptr as *const u8).add(std::mem::size_of::()); - let s = std::str::from_utf8_unchecked(std::slice::from_raw_parts( - str_data, str_len, - )); - result.push_str(s); - } else { - result.push_str("[object Object]"); - } - } else { - // For objects/arrays, just use placeholder - result.push_str("[object Object]"); - } - } - - // Create result string - extract ptr/len before passing to avoid - // potential LLVM reordering of String drop vs copy_nonoverlapping - let result_ptr = result.as_ptr(); - let result_len = result.len() as u32; - let ret = js_string_from_bytes(result_ptr, result_len); - // Ensure result String stays alive until after the copy completes - std::hint::black_box(&result); - drop(result); - ret - } -} - -#[no_mangle] -pub extern "C" fn js_array_join_value( - arr: *const ArrayHeader, - separator_value: f64, -) -> *mut crate::string::StringHeader { - let separator = if separator_value.to_bits() == crate::value::TAG_UNDEFINED { - ptr::null() - } else { - // `ToString(separator)`: a Symbol separator throws a TypeError - // (§7.1.17) instead of rendering as "Symbol(…)". - if unsafe { crate::symbol::js_is_symbol(separator_value) } != 0 { - crate::collection_iter::throw_type_error("Cannot convert a Symbol value to a string"); - } - crate::value::js_jsvalue_to_string(separator_value) as *const crate::string::StringHeader - }; - js_array_join(arr, separator) -} - -// Symbol retention: codegen lowers `arr.join(sep)` to a call to -// `js_array_join_value`, but its only in-crate caller sits behind a dispatch -// path the auto-optimize whole-program-bitcode build can prove unreachable and -// dead-strip — which broke that link with `undefined _js_array_join_value`. -// The feature-gated `#[used]` static pins the symbol for the bitcode-LTO -// link (`keepalive-anchors`); the classic link keeps it via the program's -// own undefined reference. Same pattern as `node_stream_keepalive.rs`. -#[cfg(feature = "keepalive-anchors")] -#[used] -static KEEP_ARRAY_JOIN_VALUE: extern "C" fn( - *const ArrayHeader, - f64, -) -> *mut crate::string::StringHeader = js_array_join_value; - /// `arr.toLocaleString(locales?, options?)` (#2808). /// /// Per the ECMAScript `Array.prototype.toLocaleString` algorithm: walk the diff --git a/crates/perry-runtime/src/array/join.rs b/crates/perry-runtime/src/array/join.rs new file mode 100644 index 0000000000..9b066064f1 --- /dev/null +++ b/crates/perry-runtime/src/array/join.rs @@ -0,0 +1,898 @@ +//! `Array.prototype.join`: GC-safe construction directly in StringHeader storage. + +use super::*; +use crate::gc::RuntimeHandle; +use crate::string::{StringHeader, STRING_FLAG_HAS_LONE_SURROGATES}; +use crate::value::{ + JSValue, POINTER_MASK, SHORT_STRING_DATA_MASK, STRING_TAG, TAG_HOLE, TAG_UNDEFINED, +}; +use std::ptr; + +/// Matches Node's `buffer.constants.MAX_STRING_LENGTH`. +const MAX_STRING_LENGTH: u64 = 536_870_888; +/// Avoid a huge speculative allocation for very large or sparse arrays. The +/// builder grows geometrically if the sampled estimate is too small. +const MAX_INITIAL_CAPACITY: u64 = 16 * 1024 * 1024; +const CAPACITY_SAMPLE_SIZE: usize = 32; + +#[inline(always)] +unsafe fn array_elements_ptr(arr: *const ArrayHeader) -> *const f64 { + unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 } +} + +#[cold] +fn throw_invalid_string_length() -> ! { + let message = b"Invalid string length"; + let string = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_rangeerror_new(string); + crate::exception::js_throw(crate::value::js_nanbox_pointer(error as i64)) +} + +/// Read one exotic element while keeping the receiver current across a getter +/// or prototype lookup that can allocate and move it. +#[cold] +#[inline(never)] +fn exotic_element(arr: *const ArrayHeader, index: u32) -> Option { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_const_ptr(arr); + let present = arr_handle.with_const_ptr(|arr| crate::array::array_spec_has_index(arr, index)); + if !present { + return None; + } + Some( + arr_handle + .with_const_ptr(|arr| crate::array::array_spec_get(arr, index)) + .to_bits(), + ) +} + +/// Run an allocating element conversion with the receiver and value rooted. +#[cold] +#[inline(never)] +fn element_to_string(arr: *const ArrayHeader, element_bits: u64) -> *const StringHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let _arr_handle = scope.root_raw_const_ptr(arr); + let element_handle = scope.root_nanbox_u64(element_bits); + crate::value::js_jsvalue_to_string(element_handle.get_nanbox_f64()) +} + +/// Accept the legacy cross-module representation where a real StringHeader is +/// boxed with POINTER_TAG instead of STRING_TAG. Native handle ids and +/// headerless buffer/symbol pointers must be excluded before the GC-header +/// read. +#[inline] +unsafe fn pointer_tagged_string(element_bits: u64) -> Option<*const StringHeader> { + let value = JSValue::from_bits(element_bits); + if !value.is_pointer() { + return None; + } + let address = (element_bits & POINTER_MASK) as usize; + if crate::buffer::is_registered_buffer(address) || crate::symbol::is_registered_symbol(address) + { + return None; + } + unsafe { crate::value::addr_class::try_read_gc_header(address) } + .is_some_and(|header| header.obj_type == crate::gc::GC_TYPE_STRING) + .then_some(address as *const StringHeader) +} + +#[inline] +unsafe fn direct_string(element_bits: u64) -> Option<*const StringHeader> { + let value = JSValue::from_bits(element_bits); + if value.is_string() { + Some(value.as_string_ptr()) + } else { + unsafe { pointer_tagged_string(element_bits) } + } +} + +#[derive(Clone, Copy)] +struct WellFormedJoinSize { + byte_len: u64, + utf16_len: u64, +} + +/// Prove the common dense path contains only directly writable, well-formed +/// values while computing the exact result size. This loop deliberately does +/// no byte decoding and accounts for all separators in one multiplication. +unsafe fn well_formed_join_size( + arr: *const ArrayHeader, + separator: *const StringHeader, + length: u32, +) -> Option { + let (separator_bytes, separator_utf16) = if separator.is_null() { + (1u64, 1u64) + } else { + if unsafe { (*separator).flags } & STRING_FLAG_HAS_LONE_SURROGATES != 0 { + return None; + } + unsafe { ((*separator).byte_len as u64, (*separator).utf16_len as u64) } + }; + let repeats = length.saturating_sub(1) as u64; + let mut size = WellFormedJoinSize { + byte_len: separator_bytes.saturating_mul(repeats), + utf16_len: separator_utf16.saturating_mul(repeats), + }; + let elements = unsafe { array_elements_ptr(arr) }; + + for index in 0..length as usize { + let bits = unsafe { (*elements.add(index)).to_bits() }; + if bits == TAG_HOLE { + continue; + } + let value = JSValue::from_bits(bits); + let (byte_len, utf16_len) = if value.is_short_string() { + // SSO length counts BYTES. JSON.parse emits SSO values for + // non-ASCII payloads (including WTF-8 lone-surrogate halves), + // where the UTF-16 length is shorter than the byte length, so + // the exact-size proof below would record a wrong utf16_len. + // Send those to the canonicalizing builder path instead. + let len = value.short_string_len(); + let payload = (bits & SHORT_STRING_DATA_MASK).to_le_bytes(); + if payload[..len].iter().any(|&byte| byte >= 0x80) { + return None; + } + (len as u64, len as u64) + } else if let Some(string) = unsafe { direct_string(bits) } { + if unsafe { (*string).flags } & STRING_FLAG_HAS_LONE_SURROGATES != 0 { + return None; + } + unsafe { ((*string).byte_len as u64, (*string).utf16_len as u64) } + } else if value.is_bool() { + let len = if value.as_bool() { 4 } else { 5 }; + (len, len) + } else if value.is_null() || value.is_undefined() { + continue; + } else { + return None; + }; + size.byte_len = size.byte_len.saturating_add(byte_len); + size.utf16_len = size.utf16_len.saturating_add(utf16_len); + } + Some(size) +} + +/// Exact-size direct writer for the hot well-formed path. After the sole +/// destination allocation, both receiver and separator are re-read from their +/// roots before any payload pointer is used. +fn try_join_well_formed( + arr: *const ArrayHeader, + separator: *const StringHeader, + length: u32, +) -> Option<*mut StringHeader> { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_const_ptr(arr); + let separator_handle = (!separator.is_null()).then(|| scope.root_string_ptr(separator)); + let size = unsafe { well_formed_join_size(arr, separator, length) }?; + if size.utf16_len > MAX_STRING_LENGTH || size.byte_len > u32::MAX as u64 { + throw_invalid_string_length(); + } + + let (result, mut cursor) = crate::string::string_storage_alloc(size.byte_len as u32); + let mut write_result = |arr: *const ArrayHeader, separator: *const StringHeader| unsafe { + let elements = array_elements_ptr(arr); + let (separator_data, separator_len) = if separator.is_null() { + (b",".as_ptr(), 1usize) + } else { + ( + crate::string::string_data(separator), + (*separator).byte_len as usize, + ) + }; + + crate::string::init_string_header( + result, + size.utf16_len as u32, + size.byte_len as u32, + size.byte_len as u32, + 0, + 0, + ); + for index in 0..length as usize { + if index > 0 { + // GC_STORE_AUDIT(POINTER_FREE): String payloads contain raw bytes, not traced slots. + if separator_len == 1 { + *cursor = *separator_data; + } else if separator_len != 0 { + ptr::copy_nonoverlapping(separator_data, cursor, separator_len); + } + cursor = cursor.add(separator_len); + } + + let bits = (*elements.add(index)).to_bits(); + if bits == TAG_HOLE { + continue; + } + let value = JSValue::from_bits(bits); + if value.is_short_string() { + let len = value.short_string_len(); + let bytes = (bits & SHORT_STRING_DATA_MASK).to_le_bytes(); + ptr::copy_nonoverlapping(bytes.as_ptr(), cursor, len); + cursor = cursor.add(len); + } else if let Some(string) = direct_string(bits) { + let len = (*string).byte_len as usize; + if len != 0 { + ptr::copy_nonoverlapping(crate::string::string_data(string), cursor, len); + cursor = cursor.add(len); + } + } else if value.is_bool() { + let bytes = if value.as_bool() { + b"true".as_slice() + } else { + b"false".as_slice() + }; + ptr::copy_nonoverlapping(bytes.as_ptr(), cursor, bytes.len()); + cursor = cursor.add(bytes.len()); + } + } + debug_assert_eq!( + cursor, + crate::string::string_data(result).add(size.byte_len as usize) as *mut u8 + ); + }; + if let Some(separator_handle) = separator_handle { + separator_handle.with_const_ptr(|separator| { + arr_handle.with_const_ptr(|arr| write_result(arr, separator)) + }); + } else { + arr_handle.with_const_ptr(|arr| write_result(arr, ptr::null())); + } + Some(result) +} + +/// A rooted, growable StringHeader. Capacity is estimated from a small sample, +/// so ordinary joins allocate once and write each element once. If growth is +/// needed, sources are re-read from their roots after the allocation. +struct DirectJoinBuilder<'scope> { + result: RuntimeHandle<'scope>, + current: *mut StringHeader, + byte_len: u32, + utf16_len: u64, + surrogate_count: u64, + surrogate_pair_count: u64, + pending_high_surrogate: bool, +} + +impl<'scope> DirectJoinBuilder<'scope> { + fn new(scope: &'scope crate::gc::RuntimeHandleScope, capacity: u32) -> Self { + let (result, data) = crate::string::string_storage_alloc(capacity); + unsafe { + crate::string::init_string_header(result, 0, 0, capacity, 0, 0); + // Capacity beyond the final byte length is still inside the GC + // object. Initialize it before a coercion can run the conservative + // from-space verifier over this private, partially built string. + ptr::write_bytes(data, 0, capacity as usize); + } + Self { + result: scope.root_nanbox_u64(STRING_TAG | (result as u64 & POINTER_MASK)), + current: result, + byte_len: 0, + utf16_len: 0, + surrogate_count: 0, + surrogate_pair_count: 0, + pending_high_surrogate: false, + } + } + + #[inline] + fn result_ptr(&self) -> *mut StringHeader { + self.current + } + + #[inline] + fn refresh_after_gc(&mut self) { + self.current = (self.result.get_nanbox_u64() & POINTER_MASK) as *mut StringHeader; + } + + #[inline] + fn flags(&self) -> u32 { + if self.surrogate_count > self.surrogate_pair_count.saturating_mul(2) { + STRING_FLAG_HAS_LONE_SURROGATES + } else { + 0 + } + } + + #[inline] + fn check_utf16_growth(&self, additional: u32) { + if self.utf16_len.saturating_add(additional as u64) > MAX_STRING_LENGTH { + throw_invalid_string_length(); + } + } + + /// Reserve enough raw bytes for the next piece. Raw WTF-8 length is an + /// upper bound because a high+low surrogate pair shrinks from six bytes to + /// four when canonicalized. + fn reserve(&mut self, additional: u32) { + let required = self.byte_len as u64 + additional as u64; + if required > u32::MAX as u64 { + throw_invalid_string_length(); + } + let current = self.result_ptr(); + let capacity = unsafe { (*current).capacity }; + if required <= capacity as u64 { + return; + } + + let new_capacity = required + .max((capacity as u64).saturating_mul(2)) + .max(64) + .min(u32::MAX as u64) as u32; + let (new_result, new_data) = crate::string::string_storage_alloc(new_capacity); + + // Allocation may move the old builder. Re-read it from the root before + // copying, then publish the replacement in that same root. + let old_result = (self.result.get_nanbox_u64() & POINTER_MASK) as *mut StringHeader; + unsafe { + crate::string::init_string_header( + new_result, + self.utf16_len as u32, + self.byte_len, + new_capacity, + 0, + self.flags(), + ); + ptr::write_bytes(new_data, 0, new_capacity as usize); + if self.byte_len != 0 { + // GC_STORE_AUDIT(POINTER_FREE): Builder growth copies raw string payload bytes. + ptr::copy_nonoverlapping( + crate::string::string_data(old_result), + new_data, + self.byte_len as usize, + ); + } + } + self.result + .set_nanbox_u64(STRING_TAG | (new_result as u64 & POINTER_MASK)); + self.current = new_result; + } + + #[inline] + unsafe fn publish_header(&self) { + let result = self.result_ptr(); + unsafe { + (*result).utf16_len = self.utf16_len as u32; + (*result).byte_len = self.byte_len; + (*result).flags = self.flags(); + } + } + + /// Write a piece after `reserve(byte_len)` and the UTF-16 limit check. + /// WTF-8 surrogate halves are canonicalized across empty element and + /// separator boundaries in place. + unsafe fn write_prepared_piece( + &mut self, + data: *const u8, + byte_len: u32, + utf16_len: u32, + flags: u32, + ) { + self.utf16_len += utf16_len as u64; + if byte_len == 0 { + return; + } + + let result = self.result_ptr(); + let output = crate::string::string_data(result) as *mut u8; + if flags & STRING_FLAG_HAS_LONE_SURROGATES == 0 { + unsafe { + // GC_STORE_AUDIT(POINTER_FREE): String payloads contain raw bytes, not traced slots. + ptr::copy_nonoverlapping( + data, + output.add(self.byte_len as usize), + byte_len as usize, + ); + } + self.byte_len += byte_len; + self.pending_high_surrogate = false; + return; + } + + let bytes = unsafe { std::slice::from_raw_parts(data, byte_len as usize) }; + let mut index = 0; + while index < bytes.len() { + let remaining = &bytes[index..]; + if remaining.len() >= 3 && remaining[0] == 0xED && (0xA0..=0xAF).contains(&remaining[1]) + { + unsafe { + // GC_STORE_AUDIT(POINTER_FREE): WTF-8 code units are raw string payload bytes. + ptr::copy_nonoverlapping( + remaining.as_ptr(), + output.add(self.byte_len as usize), + 3, + ); + } + self.byte_len += 3; + self.surrogate_count += 1; + self.pending_high_surrogate = true; + index += 3; + } else if remaining.len() >= 3 + && remaining[0] == 0xED + && (0xB0..=0xBF).contains(&remaining[1]) + { + self.surrogate_count += 1; + if self.pending_high_surrogate { + let high_pos = self.byte_len as usize - 3; + let high = unsafe { std::slice::from_raw_parts(output.add(high_pos), 3) }; + let high_code_unit = ((high[0] as u32 & 0x0F) << 12) + | ((high[1] as u32 & 0x3F) << 6) + | (high[2] as u32 & 0x3F); + let low_code_unit = ((remaining[0] as u32 & 0x0F) << 12) + | ((remaining[1] as u32 & 0x3F) << 6) + | (remaining[2] as u32 & 0x3F); + let code_point = + 0x10000 + ((high_code_unit - 0xD800) << 10) + (low_code_unit - 0xDC00); + let mut encoded = [0u8; 4]; + let encoded = unsafe { char::from_u32_unchecked(code_point) } + .encode_utf8(&mut encoded) + .as_bytes(); + unsafe { + // GC_STORE_AUDIT(POINTER_FREE): Canonical UTF-8 is raw string payload data. + ptr::copy_nonoverlapping(encoded.as_ptr(), output.add(high_pos), 4); + } + self.byte_len += 1; + self.surrogate_pair_count += 1; + } else { + unsafe { + // GC_STORE_AUDIT(POINTER_FREE): WTF-8 code units are raw string payload bytes. + ptr::copy_nonoverlapping( + remaining.as_ptr(), + output.add(self.byte_len as usize), + 3, + ); + } + self.byte_len += 3; + } + self.pending_high_surrogate = false; + index += 3; + } else { + unsafe { + *output.add(self.byte_len as usize) = remaining[0]; + } + self.byte_len += 1; + self.pending_high_surrogate = false; + index += 1; + } + } + } + + fn append_static(&mut self, bytes: &'static [u8]) { + let len = bytes.len() as u32; + self.check_utf16_growth(len); + self.reserve(len); + unsafe { self.write_prepared_piece(bytes.as_ptr(), len, len, 0) }; + } + + fn append_sso(&mut self, bits: u64) { + let value = JSValue::from_bits(bits); + let len = value.short_string_len() as u32; + let bytes = (bits & SHORT_STRING_DATA_MASK).to_le_bytes(); + let payload = &bytes[..len as usize]; + // SSO length counts BYTES; JSON.parse emits non-ASCII (and WTF-8 + // lone-surrogate) SSO payloads. Derive the true UTF-16 unit count, + // and flag surrogate halves so write_prepared_piece canonicalizes + // them against adjacent pieces. + let (utf16_len, flags) = if payload.iter().all(|&byte| byte < 0x80) { + (len, 0) + } else { + ( + crate::string::compute_utf16_len_wtf8(payload), + if crate::string::bytes_have_lone_surrogate(payload) { + STRING_FLAG_HAS_LONE_SURROGATES + } else { + 0 + }, + ) + }; + self.check_utf16_growth(utf16_len); + self.reserve(len); + unsafe { self.write_prepared_piece(bytes.as_ptr(), len, utf16_len, flags) }; + } + + /// Append one number's canonical JS text. Number formatting cannot run + /// user code, so this never collects and needs no re-rooting. Without it + /// every numeric element paid a per-element handle scope plus a GC string + /// allocation in `element_to_string` (measured 2.7x the retired + /// instructions of the buffered join on a numbers-only sweep). + fn append_number(&mut self, n: f64) { + let mut itoa_buf = itoa::Buffer::new(); + let text: &str = if n == 0.0 { + "0" + } else if n.fract() == 0.0 && n.abs() < 1e15 { + itoa_buf.format(n as i64) + } else { + // NaN / Infinity / fractional / extreme magnitudes: the shared + // formatter, so join output matches String(n) exactly. + let owned = crate::string::js_format_f64(n); + self.append_ascii_text(owned.as_bytes()); + return; + }; + self.append_ascii_text(text.as_bytes()); + } + + /// Append pure-ASCII bytes owned outside the GC heap. + fn append_ascii_text(&mut self, bytes: &[u8]) { + debug_assert!(bytes.iter().all(|&byte| byte < 0x80)); + let len = bytes.len() as u32; + self.check_utf16_growth(len); + self.reserve(len); + unsafe { self.write_prepared_piece(bytes.as_ptr(), len, len, 0) }; + } + + fn append_separator(&mut self, separator: RuntimeHandle<'scope>) { + let (byte_len, utf16_len, flags) = + separator.with_const_ptr::(|source| unsafe { + ((*source).byte_len, (*source).utf16_len, (*source).flags) + }); + self.check_utf16_growth(utf16_len); + self.reserve(byte_len); + separator.with_const_ptr::(|source| unsafe { + self.write_prepared_piece( + crate::string::string_data(source), + byte_len, + utf16_len, + flags, + ) + }); + } + + fn append_rooted_string(&mut self, source: *const StringHeader) { + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_string_ptr(source); + let (byte_len, utf16_len, flags) = + source.with_const_ptr::(|current| unsafe { + ((*current).byte_len, (*current).utf16_len, (*current).flags) + }); + self.check_utf16_growth(utf16_len); + self.reserve(byte_len); + source.with_const_ptr::(|current| unsafe { + self.write_prepared_piece( + crate::string::string_data(current), + byte_len, + utf16_len, + flags, + ) + }); + } + + fn append_array_string( + &mut self, + array: RuntimeHandle<'scope>, + index: usize, + source: *const StringHeader, + ) { + let (byte_len, utf16_len, flags) = + unsafe { ((*source).byte_len, (*source).utf16_len, (*source).flags) }; + self.check_utf16_growth(utf16_len); + self.reserve(byte_len); + + // Growth may move both the array and the string in its slot. Re-read + // the slot after reserving instead of retaining the old payload ptr. + array.with_const_ptr(|array| unsafe { + let bits = (*array_elements_ptr(array).add(index)).to_bits(); + let source = direct_string(bits) + .expect("direct join string slot changed during a GC-only window"); + debug_assert_eq!((*source).byte_len, byte_len); + self.write_prepared_piece( + crate::string::string_data(source), + byte_len, + utf16_len, + flags, + ) + }); + } + + fn finish(self) -> *mut StringHeader { + unsafe { self.publish_header() }; + self.result_ptr() + } +} + +#[inline] +unsafe fn estimated_element_bytes(bits: u64) -> u32 { + if bits == TAG_HOLE { + return 0; + } + let value = JSValue::from_bits(bits); + if let Some(string) = unsafe { direct_string(bits) } { + unsafe { (*string).byte_len } + } else if value.is_short_string() { + value.short_string_len() as u32 + } else if value.is_bool() { + if value.as_bool() { + 4 + } else { + 5 + } + } else if value.is_null() || value.is_undefined() { + 0 + } else { + // Numbers and ordinary object stringifications are commonly short. + 8 + } +} + +/// Estimate once from a bounded prefix. The 12.5% slack absorbs modest +/// variance while keeping peak memory below the old Rust String + copied GC +/// String pair. No getter or user coercion is invoked during sampling. +unsafe fn estimate_initial_capacity( + arr: *const ArrayHeader, + separator: *const StringHeader, + length: u32, + exotic: bool, +) -> u32 { + let separator_len = if separator.is_null() { + 1 + } else { + unsafe { (*separator).byte_len as u64 } + }; + let separator_bytes = separator_len.saturating_mul(length.saturating_sub(1) as u64); + + let element_bytes = if exotic { + (length as u64).saturating_mul(8) + } else { + let sample_len = (length as usize).min(CAPACITY_SAMPLE_SIZE); + if sample_len == 0 { + 0 + } else { + let elements = unsafe { array_elements_ptr(arr) }; + let mut sample_bytes = 0u64; + for index in 0..sample_len { + let bits = unsafe { (*elements.add(index)).to_bits() }; + sample_bytes = + sample_bytes.saturating_add(unsafe { estimated_element_bytes(bits) as u64 }); + } + sample_bytes + .saturating_add(sample_len as u64 - 1) + .checked_div(sample_len as u64) + .unwrap_or(0) + .saturating_mul(length as u64) + } + }; + + let estimate = separator_bytes.saturating_add(element_bytes); + if estimate == 0 { + return 0; + } + estimate + .saturating_add(estimate / 8) + .saturating_add(32) + .min(MAX_INITIAL_CAPACITY) + .min(u32::MAX as u64) as u32 +} + +fn join_values( + arr: *const ArrayHeader, + separator: *const StringHeader, + length: u32, + exotic: bool, +) -> *mut StringHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_const_ptr(arr); + let separator_handle = (!separator.is_null()).then(|| scope.root_string_ptr(separator)); + let initial_capacity = unsafe { estimate_initial_capacity(arr, separator, length, exotic) }; + let mut result = DirectJoinBuilder::new(&scope, initial_capacity); + + for index in 0..length as usize { + if index > 0 { + if let Some(separator) = separator_handle { + result.append_separator(separator); + } else { + result.append_static(b","); + } + } + + let element_bits = if exotic { + let bits = arr_handle + .with_const_ptr(|arr| exotic_element(arr, index as u32)) + .unwrap_or(TAG_UNDEFINED); + result.refresh_after_gc(); + bits + } else { + arr_handle + .with_const_ptr(|arr| unsafe { (*array_elements_ptr(arr).add(index)).to_bits() }) + }; + if element_bits == TAG_HOLE { + continue; + } + + let value = JSValue::from_bits(element_bits); + if value.is_null() || value.is_undefined() { + continue; + } + if value.is_short_string() { + result.append_sso(element_bits); + } else if let Some(string) = unsafe { direct_string(element_bits) } { + if exotic { + result.append_rooted_string(string); + } else { + result.append_array_string(arr_handle, index, string); + } + } else if value.is_bool() { + result.append_static(if value.as_bool() { b"true" } else { b"false" }); + } else if value.is_number() { + result.append_number(value.as_number()); + } else if value.is_int32() + && !crate::object::is_class_id_registered((element_bits & 0xFFFF_FFFF) as u32) + { + // A registered class id shares the INT32 encoding (ClassRef); + // those need the spec ToString below for function-source text. + result.append_number(value.as_int32() as f64); + } else { + crate::builtins::reject_symbol_to_string(f64::from_bits(element_bits)); + let string = arr_handle.with_const_ptr(|arr| element_to_string(arr, element_bits)); + result.refresh_after_gc(); + if crate::string::is_valid_string_ptr(string) { + result.append_rooted_string(string); + } + } + } + + result.finish() +} + +/// Join array elements into one GC-managed StringHeader, writing pieces +/// directly into its payload instead of assembling a Rust String first. +#[no_mangle] +pub extern "C" fn js_array_join( + arr: *const ArrayHeader, + separator: *const StringHeader, +) -> *mut StringHeader { + let arr = normalize_array_receiver(arr); + if arr.is_null() { + return crate::string::js_string_from_bytes(ptr::null(), 0); + } + // #3148: TypedArray receiver — join element-typed values (Node formatting). + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + return crate::typedarray::js_typed_array_join( + arr as *const crate::typedarray::TypedArrayHeader, + separator, + ); + } + + let length = unsafe { (*arr).length }; + if length == 0 { + return crate::string::js_string_from_bytes(ptr::null(), 0); + } + let exotic = crate::array::array_iteration_is_exotic(arr); + if !exotic { + if let Some(result) = try_join_well_formed(arr, separator, length) { + return result; + } + } + join_values(arr, separator, length, exotic) +} + +#[no_mangle] +pub extern "C" fn js_array_join_value( + arr: *const ArrayHeader, + separator_value: f64, +) -> *mut StringHeader { + // Separator ToString can run user code and collect. Keep the receiver + // current until js_array_join establishes its own roots. + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_const_ptr(arr); + let separator = if separator_value.to_bits() == TAG_UNDEFINED { + ptr::null() + } else { + if unsafe { crate::symbol::js_is_symbol(separator_value) } != 0 { + crate::collection_iter::throw_type_error("Cannot convert a Symbol value to a string"); + } + crate::value::js_jsvalue_to_string(separator_value) as *const StringHeader + }; + arr_handle.with_const_ptr(|arr| js_array_join(arr, separator)) +} + +// Codegen lowers `arr.join(sep)` to this symbol. Keep it alive through the +// auto-optimize whole-program-bitcode link. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_ARRAY_JOIN_VALUE: extern "C" fn(*const ArrayHeader, f64) -> *mut StringHeader = + js_array_join_value; + +#[cfg(test)] +mod tests { + use super::*; + + fn boxed_string(string: *const StringHeader) -> f64 { + f64::from_bits(STRING_TAG | (string as u64 & POINTER_MASK)) + } + + unsafe fn result_bytes(result: *const StringHeader) -> &'static [u8] { + unsafe { + std::slice::from_raw_parts( + crate::string::string_data(result), + (*result).byte_len as usize, + ) + } + } + + #[test] + fn join_canonicalizes_a_surrogate_pair_across_an_empty_field() { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = js_array_alloc(3); + let arr_handle = scope.root_raw_mut_ptr(arr); + + let high = crate::string::js_string_from_wtf8_bytes([0xED, 0xA0, 0xBD].as_ptr(), 3); + let arr = arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, boxed_string(high))); + let arr = js_array_push_hole(arr); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let low = crate::string::js_string_from_wtf8_bytes([0xED, 0xB8, 0x80].as_ptr(), 3); + let arr = arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, boxed_string(low))); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let empty = crate::string::js_string_from_bytes(ptr::null(), 0); + + let result = arr_handle.with_const_ptr(|arr| js_array_join(arr, empty)); + unsafe { + assert_eq!((*result).utf16_len, 2); + assert_eq!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + assert_eq!(result_bytes(result), "😀".as_bytes()); + } + } + + #[test] + fn join_sizes_non_ascii_sso_payloads_by_utf16_units() { + // JSON.parse emits SSO values for non-ASCII payloads: "\u{e9}" is 2 + // bytes / 1 UTF-16 unit, so the exact-size path's byte_len == + // utf16_len assumption does not hold and must defer to the builder. + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = js_array_alloc(2); + let arr_handle = scope.root_raw_mut_ptr(arr); + let sso = crate::value::JSValue::try_short_string("\u{e9}".as_bytes()).expect("fits SSO"); + let arr = arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, f64::from_bits(sso.bits()))); + let arr = js_array_push_f64(arr, f64::from_bits(sso.bits())); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let separator = crate::string::js_string_from_bytes(b"-".as_ptr(), 1); + let result = arr_handle.with_const_ptr(|arr| js_array_join(arr, separator)); + unsafe { + assert_eq!(result_bytes(result), "\u{e9}-\u{e9}".as_bytes()); + assert_eq!((*result).utf16_len, 3); + assert_eq!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + } + + #[test] + fn join_canonicalizes_surrogate_halves_held_in_sso_payloads() { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = js_array_alloc(2); + let arr_handle = scope.root_raw_mut_ptr(arr); + let high = crate::value::JSValue::try_short_string(&[0xED, 0xA0, 0xBD]).expect("fits SSO"); + let low = crate::value::JSValue::try_short_string(&[0xED, 0xB8, 0x80]).expect("fits SSO"); + let arr = + arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, f64::from_bits(high.bits()))); + let arr = js_array_push_f64(arr, f64::from_bits(low.bits())); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let empty = crate::string::js_string_from_bytes(ptr::null(), 0); + let result = arr_handle.with_const_ptr(|arr| js_array_join(arr, empty)); + unsafe { + assert_eq!(result_bytes(result), "\u{1f600}".to_string().as_bytes()); + assert_eq!((*result).utf16_len, 2); + assert_eq!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + } + + #[test] + fn join_preserves_separated_lone_surrogates_and_flags() { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = js_array_alloc(2); + let arr_handle = scope.root_raw_mut_ptr(arr); + + let high = crate::string::js_string_from_wtf8_bytes([0xED, 0xA0, 0xBD].as_ptr(), 3); + let arr = arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, boxed_string(high))); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let low = crate::string::js_string_from_wtf8_bytes([0xED, 0xB8, 0x80].as_ptr(), 3); + let arr = arr_handle.with_mut_ptr(|arr| js_array_push_f64(arr, boxed_string(low))); + arr_handle.with_mut_ptr(|current| assert_eq!(arr, current)); + let separator = crate::string::js_string_from_bytes(b"|".as_ptr(), 1); + + let result = arr_handle.with_const_ptr(|arr| js_array_join(arr, separator)); + unsafe { + assert_eq!((*result).utf16_len, 3); + assert_ne!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + assert_eq!( + result_bytes(result), + &[0xED, 0xA0, 0xBD, b'|', 0xED, 0xB8, 0x80] + ); + } + } +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 3faa98fd60..0decd10c9a 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -16,6 +16,7 @@ mod is_array; mod iter_methods; mod iter_object; mod iterator; +mod join; mod jsvalue_api; mod prototype_addr; mod push_pop; @@ -134,10 +135,9 @@ pub use self::is_array::js_array_is_array; pub(crate) use self::iter_methods::throw_reduce_of_empty; pub use self::iter_methods::{ js_array_at, js_array_every, js_array_filter, js_array_find, js_array_findIndex, - js_array_find_last, js_array_find_last_index, js_array_flatMap, js_array_forEach, - js_array_join, js_array_join_value, js_array_map, js_array_map_discard, js_array_reduce, - js_array_some, js_array_to_locale_string, js_validate_array_callback, - js_validate_array_map_callback, + js_array_find_last, js_array_find_last_index, js_array_flatMap, js_array_forEach, js_array_map, + js_array_map_discard, js_array_reduce, js_array_some, js_array_to_locale_string, + js_validate_array_callback, js_validate_array_map_callback, }; pub use self::iter_object::{ array_entries_iter, array_keys_iter, array_values_iter, array_values_iter_null_done, @@ -149,6 +149,7 @@ pub(crate) use self::iterator::iter_bt_dump; pub use self::iterator::{ js_array_spread_append, js_for_of_to_array, js_get_async_iterator, js_iterator_to_array, }; +pub use self::join::{js_array_join, js_array_join_value}; pub use self::prototype_addr::scan_prototype_addr_cache_roots_mut; pub(crate) use self::prototype_addr::{ array_prototype_addr, object_prototype_addr, object_prototype_addr_matches, diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index 3553a411dd..f77a7ce8ed 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -50,8 +50,8 @@ fn boxed_pointer(ptr: *mut u8) -> f64 { crate::value::js_nanbox_pointer(ptr as i64) } -fn string_value(ptr: *mut crate::StringHeader) -> f64 { - f64::from_bits(crate::value::JSValue::string_ptr(ptr).bits()) +fn string_value(ptr: *const crate::StringHeader) -> f64 { + f64::from_bits(crate::value::JSValue::string_ptr(ptr.cast_mut()).bits()) } #[test] @@ -1647,6 +1647,82 @@ fn join_accepts_heap_string_tagged_elements() { } } +#[test] +fn join_preserves_wtf8_and_canonicalizes_boundaries() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let high = scope.root_string_ptr(crate::string::js_string_from_wtf8_bytes( + [0xED, 0xA0, 0xBD].as_ptr(), + 3, + )); + let low = scope.root_string_ptr(crate::string::js_string_from_wtf8_bytes( + [0xED, 0xB8, 0x80].as_ptr(), + 3, + )); + let mut arr = js_array_alloc(3); + arr = high.with_const_ptr(|high: *const crate::string::StringHeader| { + js_array_push_f64(arr, string_value(high)) + }); + arr = js_array_push_f64(arr, f64::from_bits(crate::value::TAG_HOLE)); + arr = low.with_const_ptr(|low: *const crate::string::StringHeader| { + js_array_push_f64(arr, string_value(low)) + }); + + let arr = scope.root_raw_const_ptr(arr); + let empty = scope.root_string_ptr(crate::string::js_string_from_bytes(ptr::null(), 0)); + let result = arr.with_const_ptr(|arr: *const ArrayHeader| { + empty.with_const_ptr(|empty: *const crate::string::StringHeader| { + js_array_join(arr, empty) + }) + }); + let bytes = std::slice::from_raw_parts( + crate::string::string_data(result), + (*result).byte_len as usize, + ); + assert_eq!(bytes, "😀".as_bytes()); + assert_eq!((*result).utf16_len, 2); + assert_eq!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + +#[test] +fn join_keeps_separated_lone_surrogates_flagged() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let high = scope.root_string_ptr(crate::string::js_string_from_wtf8_bytes( + [0xED, 0xA0, 0xBD].as_ptr(), + 3, + )); + let low = scope.root_string_ptr(crate::string::js_string_from_wtf8_bytes( + [0xED, 0xB8, 0x80].as_ptr(), + 3, + )); + let mut arr = js_array_alloc(2); + arr = high.with_const_ptr(|high: *const crate::string::StringHeader| { + js_array_push_f64(arr, string_value(high)) + }); + arr = low.with_const_ptr(|low: *const crate::string::StringHeader| { + js_array_push_f64(arr, string_value(low)) + }); + let arr = scope.root_raw_const_ptr(arr); + let separator = + scope.root_string_ptr(crate::string::js_string_from_bytes(b"|".as_ptr(), 1)); + let result = arr.with_const_ptr(|arr: *const ArrayHeader| { + separator.with_const_ptr(|separator: *const crate::string::StringHeader| { + js_array_join(arr, separator) + }) + }); + assert_eq!((*result).utf16_len, 3); + assert_ne!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + #[test] fn refresh_local_head_follows_growth_forwarding() { // Repsel 4a.2 (#6904): a caller-held pre-grow head must refresh to the diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index e0e99431f9..67ac66ce07 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -79,7 +79,7 @@ pub use replace_expand::{ js_string_replace_regex_named, }; #[cfg(feature = "regex-engine")] -use replace_fn::call_replace_callback; +use replace_fn::{call_replace_callback, copy_replace_source, finish_replace_bytes}; pub use replace_fn::{ js_string_replace_all_string, js_string_replace_all_string_fn, js_string_replace_string, js_string_replace_string_fn, @@ -1318,7 +1318,7 @@ unsafe fn replace_regex_str_fancy( last_end = full_match.end(); } result.push_str(&str_data[last_end..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } /// string.replace(regex, replacement) -> string @@ -1342,7 +1342,7 @@ pub extern "C" fn js_string_replace_regex( if !is_valid_regex_ptr(re) { // If regex is null, return original string - return js_string_from_str(str_data); + return copy_replace_source(s); } unsafe { @@ -1374,7 +1374,7 @@ pub extern "C" fn js_string_replace_regex( .to_string() }; - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } } @@ -1390,9 +1390,8 @@ pub extern "C" fn js_string_replace_all_regex( return js_string_from_str(""); } - let str_data = string_as_str(s); if !is_valid_regex_ptr(re) { - return js_string_from_str(str_data); + return copy_replace_source(s); } ensure_replace_all_regex_global(re); diff --git a/crates/perry-runtime/src/regex/replace_expand.rs b/crates/perry-runtime/src/regex/replace_expand.rs index 55dc23ab6b..431dc4944a 100644 --- a/crates/perry-runtime/src/regex/replace_expand.rs +++ b/crates/perry-runtime/src/regex/replace_expand.rs @@ -3,6 +3,7 @@ //! `expand_js_replacement` (ECMAScript `$`-pattern expansion) and //! `replace_regex_fn_fancy` (the fancy-regex callback-replace fallback). +use super::replace_fn::{copy_replace_source, finish_replace_bytes}; use super::*; /// of `String.prototype.replace` special patterns that the Rust `regex` @@ -147,7 +148,7 @@ unsafe fn replace_fn_run_matches( ) -> *mut StringHeader { let cur_str = || string_as_str(s_handle.get_raw_const_ptr::()); if matches.is_empty() { - return js_string_from_str(cur_str()); + return s_handle.with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); } let outer = crate::gc::RuntimeHandleScope::new(); let closure_handle = outer.root_raw_const_ptr(closure_ptr); @@ -216,7 +217,7 @@ unsafe fn replace_fn_run_matches( } result.push_str(&cur_str()[last_end..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } /// Fancy-regex fallback for `js_string_replace_regex_fn`: used when the pattern @@ -293,7 +294,7 @@ pub extern "C" fn js_string_replace_regex_fn( let s_handle = scope.root_string_ptr(s); if !is_valid_regex_ptr(re) { - return js_string_from_str(string_as_str(s)); + return s_handle.with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); } unsafe { @@ -304,7 +305,8 @@ pub extern "C" fn js_string_replace_regex_fn( let closure_ptr = crate::value::js_nanbox_get_pointer(callback) as *const crate::closure::ClosureHeader; if closure_ptr.is_null() { - return js_string_from_str(string_as_str(s)); + return s_handle + .with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); } // If the `regex` crate couldn't compile this pattern (lookahead, @@ -373,9 +375,8 @@ pub extern "C" fn js_string_replace_all_regex_fn( return js_string_from_str(""); } - let str_data = string_as_str(s); if !is_valid_regex_ptr(re) { - return js_string_from_str(str_data); + return copy_replace_source(s); } ensure_replace_all_regex_global(re); @@ -401,7 +402,7 @@ pub extern "C" fn js_string_replace_regex_named( }; if !is_valid_regex_ptr(re) { - return js_string_from_str(str_data); + return copy_replace_source(s); } // Check if replacement contains $ patterns @@ -437,7 +438,7 @@ pub extern "C" fn js_string_replace_regex_named( }; if captures_list.is_empty() { - return js_string_from_str(str_data); + return copy_replace_source(s); } for caps in &captures_list { @@ -457,7 +458,7 @@ pub extern "C" fn js_string_replace_regex_named( } result.push_str(&str_data[last_end..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } } @@ -472,9 +473,8 @@ pub extern "C" fn js_string_replace_all_regex_named( return js_string_from_str(""); } - let str_data = string_as_str(s); if !is_valid_regex_ptr(re) { - return js_string_from_str(str_data); + return copy_replace_source(s); } ensure_replace_all_regex_global(re); diff --git a/crates/perry-runtime/src/regex/replace_fn.rs b/crates/perry-runtime/src/regex/replace_fn.rs index 45ae8c85dc..950428d653 100644 --- a/crates/perry-runtime/src/regex/replace_fn.rs +++ b/crates/perry-runtime/src/regex/replace_fn.rs @@ -22,6 +22,103 @@ pub(super) unsafe fn call_replace_callback(callback: f64, args: &[f64]) -> Strin } } +#[derive(Clone, Copy)] +enum Utf16StringUnit { + Source { start: usize, len: usize }, + InlineSurrogate([u8; 3]), +} + +#[inline] +fn encode_wtf8_surrogate(unit: u16) -> [u8; 3] { + [ + 0xE0 | ((unit >> 12) as u8), + 0x80 | (((unit >> 6) & 0x3F) as u8), + 0x80 | ((unit & 0x3F) as u8), + ] +} + +/// Snapshot a Perry UTF-8/WTF-8 payload as JavaScript UTF-16 units. Astral +/// UTF-8 sequences become two WTF-8 surrogate halves so an empty search string +/// can match between them, as ECMAScript requires. +fn snapshot_utf16_units(bytes: &[u8]) -> Vec { + let mut units = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + let (advance, utf16_units, code_point) = crate::string::wtf8_step(bytes, i); + let end = (i + advance).min(bytes.len()); + if utf16_units == 2 && code_point >= 0x10000 { + let astral = code_point - 0x10000; + units.push(Utf16StringUnit::InlineSurrogate(encode_wtf8_surrogate( + 0xD800 + (astral >> 10) as u16, + ))); + units.push(Utf16StringUnit::InlineSurrogate(encode_wtf8_surrogate( + 0xDC00 + (astral & 0x3FF) as u16, + ))); + } else if utf16_units == 1 { + units.push(Utf16StringUnit::Source { + start: i, + len: end - i, + }); + } + i = end; + } + units +} + +fn append_utf16_units(out: &mut Vec, source: &[u8], units: &[Utf16StringUnit]) { + for unit in units { + match unit { + Utf16StringUnit::Source { start, len } => { + out.extend_from_slice(&source[*start..*start + *len]); + } + Utf16StringUnit::InlineSurrogate(bytes) => out.extend_from_slice(bytes), + } + } +} + +/// Expand one replacement template for an empty string-pattern match at a +/// UTF-16 boundary. In particular, `$\`` and `$'` select unit ranges rather +/// than UTF-8 byte/Unicode-scalar ranges. +fn append_empty_pattern_replacement( + out: &mut Vec, + replacement: &[u8], + source: &[u8], + units: &[Utf16StringUnit], + position: usize, +) { + let mut i = 0usize; + while i < replacement.len() { + if replacement[i] != b'$' || i + 1 == replacement.len() { + out.push(replacement[i]); + i += 1; + continue; + } + match replacement[i + 1] { + b'$' => out.push(b'$'), + b'&' => {} + b'`' => append_utf16_units(out, source, &units[..position]), + b'\'' => append_utf16_units(out, source, &units[position..]), + _ => { + out.push(b'$'); + i += 1; + continue; + } + } + i += 2; + } +} + +pub(super) fn finish_replace_bytes(bytes: &[u8]) -> *mut StringHeader { + let result = crate::string::js_string_from_builder_bytes(bytes); + std::hint::black_box(bytes); + result +} + +pub(super) fn copy_replace_source(s: *const StringHeader) -> *mut StringHeader { + let (byte_len, utf16_len, flags) = unsafe { ((*s).byte_len, (*s).utf16_len, (*s).flags) }; + crate::string::string_copy_range(s, 0, byte_len, utf16_len, flags) +} + /// Invoke a string-pattern replacer callback with `(matched, offset, whole)`. /// /// `matched` must be an OWNED (or static) Rust string — never a slice of a GC @@ -80,16 +177,17 @@ pub extern "C" fn js_string_replace_string_fn( if pattern_str.is_empty() { let replacement = call_string_replace_callback(callback_handle.get_nanbox_f64(), "", 0, &s_handle); - let str_data = cur_str(); - let mut result = String::with_capacity(replacement.len() + str_data.len()); - result.push_str(&replacement); - result.push_str(str_data); - return js_string_from_str(&result); + let source = cur_str().as_bytes(); + let mut result = Vec::with_capacity(replacement.len() + source.len()); + result.extend_from_slice(replacement.as_bytes()); + result.extend_from_slice(source); + return finish_replace_bytes(&result); } let str_data = cur_str(); let Some(byte_idx) = str_data.find(pattern_str.as_str()) else { - return js_string_from_str(str_data); + return s_handle + .with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); }; let char_offset = super::utf16::byte_index_to_utf16_index(str_data, byte_idx); let replacement = call_string_replace_callback( @@ -104,7 +202,7 @@ pub extern "C" fn js_string_replace_string_fn( result.push_str(&str_data[..byte_idx]); result.push_str(&replacement); result.push_str(&str_data[byte_idx + pattern_str.len()..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } } @@ -139,29 +237,29 @@ pub extern "C" fn js_string_replace_all_string_fn( unsafe { if pattern_str.is_empty() { - // Owned char snapshot: the old code iterated `str_data.chars()` - // while the callback ran between steps — a stale borrow across - // user code. - let chars: Vec = cur_str().chars().collect(); - let mut result = String::new(); - result.push_str(&call_string_replace_callback( - callback_handle.get_nanbox_f64(), - "", - 0, - &s_handle, - )); - let mut offset = 0usize; - for ch in chars { - result.push(ch); - offset += 1; - result.push_str(&call_string_replace_callback( - callback_handle.get_nanbox_f64(), - "", - offset, - &s_handle, - )); + // Owned byte/unit snapshot: callbacks may move the subject, and JS + // empty-pattern matches occur at UTF-16 (not Unicode scalar) + // boundaries, including between an astral surrogate pair. + let source = cur_str().as_bytes().to_vec(); + let units = snapshot_utf16_units(&source); + let mut result = Vec::with_capacity(source.len()); + result.extend_from_slice( + call_string_replace_callback(callback_handle.get_nanbox_f64(), "", 0, &s_handle) + .as_bytes(), + ); + for (index, unit) in units.iter().enumerate() { + append_utf16_units(&mut result, &source, std::slice::from_ref(unit)); + result.extend_from_slice( + call_string_replace_callback( + callback_handle.get_nanbox_f64(), + "", + index + 1, + &s_handle, + ) + .as_bytes(), + ); } - return js_string_from_str(&result); + return finish_replace_bytes(&result); } // Precompute every match position (byte index + char offset) before @@ -185,7 +283,8 @@ pub extern "C" fn js_string_replace_all_string_fn( .collect() }; if matches.is_empty() { - return js_string_from_str(cur_str()); + return s_handle + .with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); } let mut result = String::new(); let mut last_end = 0usize; @@ -202,7 +301,7 @@ pub extern "C" fn js_string_replace_all_string_fn( last_end = byte_idx + pattern_str.len(); } result.push_str(&cur_str()[last_end..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } } @@ -274,11 +373,20 @@ pub extern "C" fn js_string_replace_string( "undefined" }; + if pattern_str.is_empty() { + let source = str_data.as_bytes(); + let units = snapshot_utf16_units(source); + let mut result = Vec::with_capacity(source.len().saturating_add(repl_str.len())); + append_empty_pattern_replacement(&mut result, repl_str.as_bytes(), source, &units, 0); + append_utf16_units(&mut result, source, &units); + return finish_replace_bytes(&result); + } + // String.replace with a string pattern only replaces the first occurrence. // Fast path: a replacement with no `$` needs no substitution. - if !repl_str.contains('$') || pattern_str.is_empty() { + if !repl_str.contains('$') { let result = str_data.replacen(pattern_str, repl_str, 1); - return js_string_from_str(&result); + return finish_replace_bytes(result.as_bytes()); } let result = match str_data.find(pattern_str) { Some(pos) => { @@ -291,7 +399,7 @@ pub extern "C" fn js_string_replace_string( } None => str_data.to_string(), }; - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } /// Replace ALL occurrences with a simple string pattern (not regex) @@ -318,12 +426,34 @@ pub extern "C" fn js_string_replace_all_string( "undefined" }; - // Fast path: a replacement with no `$` (or an empty pattern, whose - // between-every-char match positions are left to Rust's `replace`) needs - // no `$$`/`$&`/`` $` ``/`$'` substitution. - if !repl_str.contains('$') || pattern_str.is_empty() { + if pattern_str.is_empty() { + let source = str_data.as_bytes(); + let units = snapshot_utf16_units(source); + let insertion_count = units.len().saturating_add(1); + let mut result = Vec::with_capacity( + source + .len() + .saturating_add(repl_str.len().saturating_mul(insertion_count)), + ); + for position in 0..=units.len() { + append_empty_pattern_replacement( + &mut result, + repl_str.as_bytes(), + source, + &units, + position, + ); + if position < units.len() { + append_utf16_units(&mut result, source, std::slice::from_ref(&units[position])); + } + } + return finish_replace_bytes(&result); + } + + // Fast path: a replacement with no `$` needs no substitution. + if !repl_str.contains('$') { let result = str_data.replace(pattern_str, repl_str); - return js_string_from_str(&result); + return finish_replace_bytes(result.as_bytes()); } let mut result = String::with_capacity(str_data.len()); let mut last = 0; @@ -335,7 +465,7 @@ pub extern "C" fn js_string_replace_all_string( last = pos + m.len(); } result.push_str(&str_data[last..]); - js_string_from_str(&result) + finish_replace_bytes(result.as_bytes()) } /// `replaceValue` whose function-ness is only knowable at RUNTIME (a closure diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index c69e8d42fa..0cc718740b 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -5,6 +5,16 @@ fn make_string(s: &str) -> *mut StringHeader { js_string_from_bytes(s.as_ptr(), s.len() as u32) } +fn make_wtf8(bytes: &[u8]) -> *mut StringHeader { + crate::string::js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32) +} + +fn string_payload(s: *const StringHeader) -> Vec { + unsafe { + std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize).to_vec() + } +} + #[test] fn regexp_has_dedicated_gc_kind_and_is_not_a_shaped_object() { let _lock = crate::gc::global_side_table_test_lock(); @@ -111,6 +121,78 @@ fn js_replacement_named_group_gate() { ); } +#[test] +fn literal_replace_expands_every_subject_token() { + let result = js_string_replace_all_string( + make_string("abcabc"), + make_string("abc"), + make_string("$`<$&>$'"), + ); + assert_eq!(string_as_str(result), "abcabc"); +} + +#[test] +fn literal_replace_all_empty_pattern_splits_astral_utf16_units() { + let result = js_string_replace_all_string(make_string("😀"), make_string(""), make_string("|")); + assert_eq!( + string_payload(result), + [b'|', 0xED, 0xA0, 0xBD, b'|', 0xED, 0xB8, 0x80, b'|'] + ); + unsafe { + assert_eq!((*result).utf16_len, 5); + assert_ne!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + +#[test] +fn literal_replace_canonicalizes_a_new_surrogate_boundary() { + let scope = crate::gc::RuntimeHandleScope::new(); + let low = scope.root_string_ptr(make_wtf8(&[0xED, 0xB8, 0x80])); + let high = scope.root_string_ptr(make_wtf8(&[0xED, 0xA0, 0xBD])); + let empty = scope.root_string_ptr(make_string("")); + let result = low.with_const_ptr(|low: *const StringHeader| { + empty.with_const_ptr(|empty: *const StringHeader| { + high.with_const_ptr(|high: *const StringHeader| { + js_string_replace_string(low, empty, high) + }) + }) + }); + assert_eq!(string_payload(result), "😀".as_bytes()); + unsafe { + assert_eq!((*result).utf16_len, 2); + assert_eq!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + +#[test] +fn literal_replace_nonempty_pattern_preserves_wtf8_boundaries() { + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_string_ptr(make_wtf8(&[0xED, 0xA0, 0xBD, b'X'])); + let pattern = scope.root_string_ptr(make_string("X")); + let replacement = scope.root_string_ptr(make_wtf8(&[0xED, 0xB8, 0x80])); + let result = source.with_const_ptr(|source: *const StringHeader| { + pattern.with_const_ptr(|pattern: *const StringHeader| { + replacement.with_const_ptr(|replacement: *const StringHeader| { + js_string_replace_string(source, pattern, replacement) + }) + }) + }); + assert_eq!(string_payload(result), "😀".as_bytes()); + unsafe { + assert_eq!((*result).utf16_len, 2); + assert_eq!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + // ---- #4797: fancy-regex fallback wired through every operation ---- #[test] @@ -257,6 +339,34 @@ fn test_string_replace_global() { assert_eq!(string_as_str(result), "hell0 w0rld"); } +#[test] +fn regex_replace_preserves_and_canonicalizes_wtf8_boundaries() { + let scope = crate::gc::RuntimeHandleScope::new(); + let pattern = scope.root_string_ptr(make_string("X")); + let flags = scope.root_string_ptr(make_string("")); + let re = pattern.with_const_ptr(|pattern: *const StringHeader| { + flags.with_const_ptr(|flags: *const StringHeader| js_regexp_new(pattern, flags)) + }); + let re = scope.root_raw_const_ptr(re); + let source = scope.root_string_ptr(make_wtf8(&[0xED, 0xA0, 0xBD, b'X'])); + let replacement = scope.root_string_ptr(make_wtf8(&[0xED, 0xB8, 0x80])); + let result = source.with_const_ptr(|source: *const StringHeader| { + re.with_const_ptr(|re: *const RegExpHeader| { + replacement.with_const_ptr(|replacement: *const StringHeader| { + js_string_replace_regex(source, re, replacement) + }) + }) + }); + assert_eq!(string_payload(result), "😀".as_bytes()); + unsafe { + assert_eq!((*result).utf16_len, 2); + assert_eq!( + (*result).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + } +} + #[test] fn escaped_hyphen_in_class_stays_literal() { // #4425: `\-` inside a character class is always a literal hyphen. The diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 0d3d933949..260f0a6b56 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -606,6 +606,18 @@ pub(crate) fn string_storage_alloc(capacity: u32) -> (*mut StringHeader, *mut u8 (ptr, data) } +/// Maximum number of UTF-16 code units in one Perry string. Mirrors V8's +/// `buffer.constants.MAX_STRING_LENGTH` on the Node version Perry targets. +pub(crate) const MAX_STRING_LENGTH: usize = 536_870_888; + +/// Throw the common V8-compatible error used by exact-size string builders. +pub(crate) fn throw_invalid_string_length() -> ! { + let message = "Invalid string length"; + let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = crate::error::js_rangeerror_new(msg); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) +} + /// [`string_storage_alloc`] with **no collection point**: `Some` means the /// bytes came out of the nursery block that was already open, so nothing on /// the heap moved and any raw string pointer the caller read *before* this @@ -874,6 +886,42 @@ pub(crate) fn compute_utf16_len_wtf8(bytes: &[u8]) -> u32 { count } +/// Finalize bytes accumulated by a Rust-side string builder. Unlike +/// [`js_string_from_bytes`], this derives the lone-surrogate flag while it +/// counts UTF-16 units, then canonicalizes any high/low pair created at a +/// builder boundary. The input must be owned outside the GC heap so it stays +/// valid across the destination allocation. +pub(crate) fn js_string_from_builder_bytes(bytes: &[u8]) -> *mut StringHeader { + let len = u32::try_from(bytes.len()).unwrap_or_else(|_| throw_invalid_string_length()); + if bytes.iter().all(|&byte| byte < 0x80) { + if bytes.len() > MAX_STRING_LENGTH { + throw_invalid_string_length(); + } + return js_string_from_ascii_bytes(bytes.as_ptr(), len); + } + + let mut utf16_len = 0u32; + let mut has_lone_surrogate = false; + let mut offset = 0usize; + while offset < bytes.len() { + let (advance, units, code_point) = wtf8_step(bytes, offset); + utf16_len = utf16_len.saturating_add(units as u32); + has_lone_surrogate |= units == 1 && (0xD800..=0xDFFF).contains(&code_point); + offset = (offset + advance).min(bytes.len()); + } + if utf16_len as usize > MAX_STRING_LENGTH { + throw_invalid_string_length(); + } + + let flags = if has_lone_surrogate { + STRING_FLAG_HAS_LONE_SURROGATES + } else { + 0 + }; + let result = js_string_from_bytes_known_utf16(bytes.as_ptr(), len, utf16_len, flags); + concat::canonicalize_surrogate_pairs(result) +} + /// Internal helper: Create a StringHeader from a Rust &str #[inline] pub(crate) fn js_string_from_str(s: &str) -> *mut StringHeader { diff --git a/crates/perry-runtime/src/string/pad.rs b/crates/perry-runtime/src/string/pad.rs index 89126ea3a1..dce8cd5bee 100644 --- a/crates/perry-runtime/src/string/pad.rs +++ b/crates/perry-runtime/src/string/pad.rs @@ -34,13 +34,6 @@ pub extern "C" fn js_string_pad_fill(value: f64) -> *mut StringHeader { #[used] static KEEP_PAD_FILL: extern "C" fn(f64) -> *mut StringHeader = js_string_pad_fill; -/// Maximum string length Perry/V8 supports as a single `String`. This -/// mirrors the value Node v25 reports via `buffer.constants.MAX_STRING_LENGTH` -/// (536_870_888 = `(1 << 29) - 24` on this V8 build). `padStart`/`padEnd` -/// throw `RangeError: Invalid string length` when the requested length -/// exceeds this, instead of silently capping. (#2786 / #2880) -const MAX_STRING_LENGTH: usize = 536_870_888; - /// ToLength coercion (ECMA-262 §7.1.21) for `padStart`/`padEnd`'s target /// length: NaN/negative → 0, fractional values truncate, `+Infinity` → /// `2^53 - 1`. Per the spec's `StringPad`, ToLength itself never throws — @@ -191,18 +184,12 @@ fn finish_pad_result( bytes.extend_from_slice(str_data.as_bytes()); bytes.extend_from_slice(pad_chunk); } - if pad_has_lone_surrogate || receiver_has_lone_surrogate { + let result = if pad_has_lone_surrogate || receiver_has_lone_surrogate { js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32) } else { js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) - } -} - -fn throw_invalid_string_length() -> ! { - let message = "Invalid string length"; - let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32); - let err = crate::error::js_rangeerror_new(msg); - crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) + }; + super::concat::canonicalize_surrogate_pairs(result) } /// Pad the start of a string to reach target length (in UTF-16 code units). @@ -316,7 +303,7 @@ pub extern "C" fn js_string_repeat(s: *const StringHeader, count_value: f64) -> // *post-collection* address back out of the handle. let scope = crate::gc::RuntimeHandleScope::new(); let receiver_root = scope.root_string_ptr(s); - let (count_number, s) = receiver_root + let (count_number, _) = receiver_root .across_const::(|| crate::builtins::js_number_coerce(count_value)); let count_integer = to_integer_or_infinity(count_number); @@ -331,16 +318,58 @@ pub extern "C" fn js_string_repeat(s: *const StringHeader, count_value: f64) -> if count_integer == 0.0 { return js_string_from_bytes("".as_ptr(), 0); } - let str_data = string_as_str(s); - if str_data.is_empty() { + let (source_byte_len, source_utf16_len, source_flags) = + receiver_root.with_const_ptr(|s_before: *const StringHeader| unsafe { + ( + (*s_before).byte_len as usize, + (*s_before).utf16_len as usize, + (*s_before).flags, + ) + }); + if source_byte_len == 0 { return js_string_from_bytes("".as_ptr(), 0); } + if count_integer > usize::MAX as f64 { + throw_invalid_string_length(); + } let count = count_integer as usize; - let result = str_data.repeat(count); - let ret = js_string_from_bytes(result.as_ptr(), result.len() as u32); - std::hint::black_box(&result); - ret + let result_utf16_len = source_utf16_len + .checked_mul(count) + .unwrap_or_else(|| throw_invalid_string_length()); + let result_byte_len = source_byte_len + .checked_mul(count) + .unwrap_or_else(|| throw_invalid_string_length()); + if result_utf16_len > MAX_STRING_LENGTH || result_byte_len > u32::MAX as usize { + throw_invalid_string_length(); + } + + let (result, result_data) = string_storage_alloc(result_byte_len as u32); + unsafe { + init_string_header( + result, + result_utf16_len as u32, + result_byte_len as u32, + result_byte_len as u32, + 0, + source_flags, + ); + + // The allocation above can move `s`; only now re-read its address. + receiver_root.with_const_ptr(|s_now: *const StringHeader| { + ptr::copy_nonoverlapping(string_data(s_now), result_data, source_byte_len); + }); + + // Grow from the already-written destination prefix. This takes O(log n) + // bulk copies rather than one tiny memcpy per repetition. + let mut written = source_byte_len; + while written < result_byte_len { + let chunk = written.min(result_byte_len - written); + ptr::copy_nonoverlapping(result_data, result_data.add(written), chunk); + written += chunk; + } + } + super::concat::canonicalize_surrogate_pairs(result) } fn to_integer_or_infinity(value: f64) -> f64 { @@ -438,3 +467,67 @@ mod decode_wtf8_tests { assert!(decode_wtf8_units(&[0xE2, 0x82]).len() <= 2); } } + +#[cfg(test)] +mod builder_tests { + use super::*; + + fn wtf8(bytes: &[u8]) -> *mut StringHeader { + js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32) + } + + fn payload(s: *const StringHeader) -> Vec { + unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize).to_vec() } + } + + #[test] + fn repeat_writes_exact_payload_and_preserves_lone_surrogate_flag() { + let source = wtf8(&[0xED, 0xA0, 0xBD]); // lone high surrogate D83D + let result = js_string_repeat(source, 3.0); + assert_eq!( + payload(result), + [0xED, 0xA0, 0xBD, 0xED, 0xA0, 0xBD, 0xED, 0xA0, 0xBD] + ); + unsafe { + assert_eq!((*result).utf16_len, 3); + assert_ne!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + } + + #[test] + fn pad_boundaries_canonicalize_surrogate_pairs() { + let scope = crate::gc::RuntimeHandleScope::new(); + let high = scope.root_string_ptr(wtf8(&[0xED, 0xA0, 0xBD])); + let low = scope.root_string_ptr(wtf8(&[0xED, 0xB8, 0x80])); + + let start = low.with_const_ptr(|low: *const StringHeader| { + high.with_const_ptr(|high: *const StringHeader| js_string_pad_start(low, 2.0, high)) + }); + assert_eq!(payload(start), "😀".as_bytes()); + unsafe { + assert_eq!((*start).utf16_len, 2); + assert_eq!((*start).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + + let end = high.with_const_ptr(|high: *const StringHeader| { + low.with_const_ptr(|low: *const StringHeader| js_string_pad_end(high, 2.0, low)) + }); + assert_eq!(payload(end), "😀".as_bytes()); + unsafe { + assert_eq!((*end).utf16_len, 2); + assert_eq!((*end).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + } + + #[test] + fn pad_cycles_and_truncates_by_utf16_units() { + let source = js_string_from_str("x"); + let pad = js_string_from_str("😀a"); // three UTF-16 units + let result = js_string_pad_start(source, 6.0, pad); + assert_eq!(string_as_str(result), "😀a😀x"); + unsafe { + assert_eq!((*result).utf16_len, 6); + assert_eq!((*result).flags & STRING_FLAG_HAS_LONE_SURROGATES, 0); + } + } +} diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index bc49db73bc..84320b2308 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -12,8 +12,8 @@ inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 -inline-offset | perry-runtime | 369 +inline-offset | perry-runtime | 365 inline-offset | perry-stdlib | 48 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 -reader-helper | perry-runtime | 13 +reader-helper | perry-runtime | 12 diff --git a/test-files/test_gap_8434_string_builder_roundtrips.ts b/test-files/test_gap_8434_string_builder_roundtrips.ts new file mode 100644 index 0000000000..99cd9c4dd4 --- /dev/null +++ b/test-files/test_gap_8434_string_builder_roundtrips.ts @@ -0,0 +1,72 @@ +// parity-env: PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 + +function codeUnits(value: string): string { + const units: string[] = []; + for (let i = 0; i < value.length; i++) { + units.push(value.charCodeAt(i).toString(16)); + } + return units.join(","); +} + +function dump(label: string, value: string): void { + console.log(label, value.length, JSON.stringify(value), codeUnits(value)); +} + +const high = String.fromCharCode(0xd83d); +const low = String.fromCharCode(0xde00); +const astral = "😀"; + +const holes = new Array(5); +holes[1] = ""; +holes[2] = high; +holes[3] = low; +holes[4] = astral; +dump("join-empty-holes", holes.join("")); +dump("join-separated-lone", [high, low].join("|")); +dump("join-astral", ["a", astral, "b"].join("—")); + +let joinAllocations = 0; +function allocatingElement(text: string) { + return { + toString() { + const scratch: string[] = []; + for (let i = 0; i < 64; i++) scratch.push(text + i); + joinAllocations += scratch.length; + return text; + }, + }; +} +dump( + "join-allocating-tostring", + [allocatingElement("left"), allocatingElement(astral), allocatingElement("right")].join("::"), +); +console.log("join-allocations", joinAllocations); + +dump("repeat-ascii", "ab".repeat(7)); +dump("repeat-lone", high.repeat(3)); +dump("repeat-boundary", (low + high).repeat(2)); +dump("pad-start", low.padStart(2, high)); +dump("pad-end", high.padEnd(2, low)); +dump("pad-cycle", "x".padStart(6, astral + "a")); + +dump("replace-literal", "abcabc".replaceAll("abc", "$`<$&>$'")); +dump("replace-empty-astral", astral.replaceAll("", "|")); +dump("replace-empty-boundary", low.replace("", high)); +dump("replace-literal-boundary", (high + "X").replace("X", low)); +dump("replace-regex-boundary", (high + "X").replace(/X/, low)); +dump("replace-regex-no-match", (high + "X").replace(/Z/, low)); +dump( + "replace-regex", + "John Smith; Ada Lovelace".replace(/(?\w+) (?\w+)/g, "$, $"), +); +dump("replace-fancy", "$5 and $10".replace(/(?<=\$)(?\d+)/g, "[$]")); + +let callbackAllocations = 0; +const callbackResult = "a1b2c3".replaceAll(/\d/g, function (match, offset, whole) { + const scratch: string[] = []; + for (let i = 0; i < 64; i++) scratch.push(whole + match + i); + callbackAllocations += scratch.length; + return "[" + match + ":" + offset + "]"; +}); +dump("replace-callback", callbackResult); +console.log("replace-callback-allocations", callbackAllocations); diff --git a/test-files/test_issue_8434_array_join_gc.ts b/test-files/test_issue_8434_array_join_gc.ts index 91227f3476..93b5881313 100644 --- a/test-files/test_issue_8434_array_join_gc.ts +++ b/test-files/test_issue_8434_array_join_gc.ts @@ -1,11 +1,27 @@ +// parity-env: PERRY_GC_SCHEDULE_SEED=8434 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 show = (label: string, value: string): void => { console.log(`${label}:${value}|${value.length}`); }; +const showCodeUnits = (label: string, value: string): void => { + let units = ""; + for (let i = 0; i < value.length; i++) { + if (i > 0) units += ","; + units += value.charCodeAt(i).toString(16); + } + console.log(`${label}:${units}|${value.length}`); +}; + show("holes", new Array(3).join("|")); show("empty", ["", "", ""].join("")); show("unicode", ["A", "😀", "é"].join("·")); +const high = String.fromCharCode(0xd83d); +const low = String.fromCharCode(0xde00); +show("surrogate-pair", [high, , low].join("")); +showCodeUnits("lone-surrogates", [high, low].join("|")); + let calls = 0; const values: unknown[] = []; const churn = (): number => { @@ -27,3 +43,19 @@ values.push(allocating, "original", "tail"); const movingSeparator = ("x" + "/").slice(1); show("coerce", values.join(movingSeparator)); console.log(`calls:${calls}`); + +const growthPayload = "0123456789abcdef".repeat(256); +const growthValues: unknown[] = []; +for (let i = 0; i < 32; i++) growthValues.push(""); +let growthCalls = 0; +growthValues.push({ + toString(): string { + growthCalls++; + churn(); + return growthPayload; + }, +}); +const grown = growthValues.join(""); +console.log( + `growth:${grown.length}:${grown.slice(0, 4)}:${grown.slice(-4)}:${growthCalls}`, +);