diff --git a/changelog.d/10656-codepointat-linear.md b/changelog.d/10656-codepointat-linear.md new file mode 100644 index 0000000000..31a8d597fa --- /dev/null +++ b/changelog.d/10656-codepointat-linear.md @@ -0,0 +1,5 @@ +`String.prototype.codePointAt` is no longer O(n) per call on strings containing +non-ASCII characters. It now uses the same lazy UTF-16 index `charCodeAt` and +bracket indexing were moved to in #10067, so a sequential scan is linear rather +than quadratic. A natively compiled `tsc` goes from 658 s to 85 s on a two-line +input (#10656). diff --git a/changelog.d/10685-slice-linear.md b/changelog.d/10685-slice-linear.md new file mode 100644 index 0000000000..207f47a863 --- /dev/null +++ b/changelog.d/10685-slice-linear.md @@ -0,0 +1,5 @@ +`String.prototype.substring` / `slice` / `substr` no longer walk from byte 0 to +resolve their start offset on strings containing non-ASCII characters. They now +use the same lazy UTF-16 index the other accessors use, so slicing at increasing +offsets — every tokenizer's access pattern — is linear rather than quadratic. A +natively compiled `tsc` goes from 85 s to 7.8 s (#10685). diff --git a/changelog.d/10688-per-string-index.md b/changelog.d/10688-per-string-index.md new file mode 100644 index 0000000000..1c45c5380a --- /dev/null +++ b/changelog.d/10688-per-string-index.md @@ -0,0 +1,6 @@ +The UTF-16 index no longer lives in a fixed four-slot cache that evicted +round-robin. Interleaving indexed access across more strings than it held +rebuilt the index from scratch on every access — 1,224x slower from the fifth +string onward, as a step function. Entries now live until their string dies and +are reclaimed by the collector's existing prune hook, so the cliff cannot occur +at any number of strings (#10688). diff --git a/changelog.d/10694-array-index-gate.md b/changelog.d/10694-array-index-gate.md new file mode 100644 index 0000000000..5c533ffc2d --- /dev/null +++ b/changelog.d/10694-array-index-gate.md @@ -0,0 +1,5 @@ +Plain-array element reads and writes no longer probe the buffer and typed-array +registries. A `GC_TYPE_ARRAY` header can never be either, and the iteration +helpers already gated on that; the indexing path did not. On a `tsc --noEmit` +of a two-line file this halves `is_registered_buffer` probes, 79.7M to 39.8M +(#10694). diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 69d15f0ace..e23aed493c 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -86,8 +86,9 @@ pub(crate) fn array_iteration_is_exotic(arr: *const ArrayHeader) -> bool { if arr.is_null() { return false; } - if crate::buffer::is_registered_buffer(arr as usize) - || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + if super::header::receiver_may_be_registered_exotic(arr as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some()) { return true; } @@ -119,8 +120,9 @@ pub(crate) unsafe fn array_iteration_is_exotic_cleaned( arr: *const ArrayHeader, flags: u16, ) -> bool { - if crate::buffer::is_registered_buffer(arr as usize) - || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + if super::header::receiver_may_be_registered_exotic(arr as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some()) { return true; } @@ -606,18 +608,30 @@ pub extern "C" fn js_array_get_f64(arr: *const ArrayHeader, index: u32) -> f64 { return f64::NAN; } let arr = cleaned; - // Check if this is actually a TypedArray — dispatch through typed array helper - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - return crate::typedarray::js_typed_array_get( - arr as *const crate::typedarray::TypedArrayHeader, - index as i32, - ); - } - // Check if this is actually a buffer (Uint8Array) — read individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - let byte_val = - crate::buffer::js_buffer_get(arr as *const crate::buffer::BufferHeader, index as i32); - return byte_val as f64; + // #10694: a `GC_TYPE_ARRAY` header can never be a registered buffer or + // typed array — every registration carries its own GC object type — so a + // plain array must not pay the thread-local registry probes. The + // iteration helpers already gate on this; the indexing path did not, and + // on a `tsc --noEmit` of a two-line file that cost **79.7 M** + // `is_registered_buffer` probes for a process that registers **9** + // buffers, hitting 90 times. One already-warm GC-header byte read and an + // integer compare replace them. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a TypedArray — dispatch through typed array helper + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + return crate::typedarray::js_typed_array_get( + arr as *const crate::typedarray::TypedArrayHeader, + index as i32, + ); + } + // Check if this is actually a buffer (Uint8Array) — read individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + let byte_val = crate::buffer::js_buffer_get( + arr as *const crate::buffer::BufferHeader, + index as i32, + ); + return byte_val as f64; + } } // The usual case cleans to the same address, so reuse the header tag read // above. A forwarded Array resolves to a different address and needs its @@ -794,23 +808,33 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 if arr.is_null() { return; } - // Check if this is actually a buffer (Uint8Array) — write individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - crate::buffer::js_buffer_set( - arr as *mut crate::buffer::BufferHeader, - index as i32, - value as i32, - ); - return; - } - // Check if this is a typed array — route through per-kind store. - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - crate::typedarray::js_typed_array_set( - arr as *mut crate::typedarray::TypedArrayHeader, - index as i32, - value, - ); - return; + // #10694: a `GC_TYPE_ARRAY` header can never be a registered buffer or + // typed array — every registration carries its own GC object type — so a + // plain array must not pay the thread-local registry probes. The + // iteration helpers already gate on this; the indexing path did not, and + // on a `tsc --noEmit` of a two-line file that cost **79.7 M** + // `is_registered_buffer` probes for a process that registers **9** + // buffers, hitting 90 times. One already-warm GC-header byte read and an + // integer compare replace them. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a buffer (Uint8Array) — write individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + crate::buffer::js_buffer_set( + arr as *mut crate::buffer::BufferHeader, + index as i32, + value as i32, + ); + return; + } + // Check if this is a typed array — route through per-kind store. + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + crate::typedarray::js_typed_array_set( + arr as *mut crate::typedarray::TypedArrayHeader, + index as i32, + value, + ); + return; + } } // SAFETY: the clean above resolved this exact plain-array head; the // Buffer/TypedArray exits precede this direct header read. @@ -857,8 +881,9 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) { let clean = clean_arr_ptr_mut(arr); if clean.is_null() - || crate::buffer::is_registered_buffer(clean as usize) - || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some() + || (super::header::receiver_may_be_registered_exotic(clean as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(clean as usize) + || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some())) { return; } @@ -1236,8 +1261,9 @@ fn js_array_set_f64_extend_strict_impl( } let clean = clean_arr_ptr_mut(arr); if clean.is_null() - || crate::buffer::is_registered_buffer(clean as usize) - || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some() + || (super::header::receiver_may_be_registered_exotic(clean as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(clean as usize) + || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some())) { // Preserve the existing polymorphic/subclass behavior on receivers // that are not live plain arrays. These are cold and cannot use the @@ -1463,23 +1489,27 @@ pub extern "C" fn js_array_set_f64_extend( return js_array_alloc(0); } let arr = cleaned; - // Check if this is actually a buffer (Uint8Array) — write individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - crate::buffer::js_buffer_set( - arr as *mut crate::buffer::BufferHeader, - index as i32, - value as i32, - ); - return arr; - } - // Check if this is a typed array — route through per-kind store (no extension). - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - crate::typedarray::js_typed_array_set( - arr as *mut crate::typedarray::TypedArrayHeader, - index as i32, - value, - ); - return arr; + // #10694: skip both registry probes for a `GC_TYPE_ARRAY` header, which + // can never be a registered buffer or typed array. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a buffer (Uint8Array) — write individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + crate::buffer::js_buffer_set( + arr as *mut crate::buffer::BufferHeader, + index as i32, + value as i32, + ); + return arr; + } + // Check if this is a typed array — route through per-kind store (no extension). + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + crate::typedarray::js_typed_array_set( + arr as *mut crate::typedarray::TypedArrayHeader, + index as i32, + value, + ); + return arr; + } } // SAFETY: the clean above resolved this live plain-array head, and the // compatible Buffer/TypedArray receivers have exited. diff --git a/crates/perry-runtime/src/string/char_ops.rs b/crates/perry-runtime/src/string/char_ops.rs index c807f477a4..1449fb5aa8 100644 --- a/crates/perry-runtime/src/string/char_ops.rs +++ b/crates/perry-runtime/src/string/char_ops.rs @@ -104,6 +104,14 @@ fn utf16_unit_at(s: *const StringHeader, idx: usize) -> Option { utf16_index::unit_at(s, idx) } +/// Byte offset of the code point containing UTF-16 index `idx`, plus whether +/// `idx` is its low surrogate half, resolved through the same cached index. +/// `None` means the caller should fall back to its own walk (short payloads, +/// `idx == 0`, or an index past the last decodable unit). +pub(super) fn utf16_boundary_at(s: *const StringHeader, idx: usize) -> Option<(usize, bool)> { + utf16_index::boundary_at(s, idx) +} + /// SSO-safe `s[key]`: takes the receiver as a **NaN-boxed JSValue** rather than /// an already-unboxed `StringHeader*`. /// @@ -695,26 +703,45 @@ pub extern "C" fn js_string_code_point_at(s: *const StringHeader, index: i32) -> } } - // Non-ASCII: bounded WTF-8 walk (#6085) — the old `str_data.chars()` loop - // read continuation bytes past an exact-sized payload ending in a truncated - // multi-byte lead. Allocation-free either way. - let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; - let mut utf16_pos = 0usize; - let mut i = 0usize; - while i < bytes.len() { - let (advance, units, cp) = crate::string::wtf8_step(bytes, i); - if units > 0 && utf16_pos + units > idx { - if units == 1 || utf16_pos == idx { - // Either a BMP code point, or the START of a surrogate pair — - // which per spec is the whole code point. - return cp as f64; - } - // Index lands on the low surrogate half — return the bare unit. - let v = cp.wrapping_sub(0x10000); - return (0xDC00 + (v & 0x3FF)) as f64; + // Non-ASCII: go through the same lazy sparse index + cursor `charCodeAt` + // uses (#10055/#10067). This function used to walk the WTF-8 payload from + // byte 0 on every call, so a sequential scan over a string holding even one + // non-ASCII character was O(n^2) — #10656. `charCodeAt` and `s[i]` were + // moved off that walk by #10067; `codePointAt` was left on it, which is why + // a natively compiled `tsc` spent ~85% of its run in this function + // (`lib.dom.d.ts` carries 45 non-ASCII characters in 1.87 MB). + // + // No bespoke decoding is needed: `codePointAt` is *defined* on code units, + // so the spec algorithm is two indexed reads. `unit_at` keeps the bounded + // WTF-8 stepping that #6085 needs, so the truncated-payload guarantee is + // preserved — a missing continuation byte still decodes from what is + // present rather than over-reading. + let first = match utf16_unit_at(s, idx) { + Some(unit) => unit, + None => return f64::from_bits(crate::value::TAG_UNDEFINED), + }; + // A lone/low surrogate, a BMP code point, or a leading surrogate with + // nothing after it: the unit is the answer. + if !is_leading_surrogate(first) || idx + 1 >= u16len { + return first as f64; + } + match utf16_unit_at(s, idx + 1) { + Some(second) if is_trailing_surrogate(second) => { + let high = (first as u32 - 0xD800) << 10; + let low = second as u32 - 0xDC00; + (0x10000 + high + low) as f64 } - utf16_pos += units; - i += advance; + // Unpaired leading surrogate — per spec the code unit itself. + _ => first as f64, } - f64::from_bits(crate::value::TAG_UNDEFINED) +} + +#[inline] +fn is_leading_surrogate(unit: u16) -> bool { + (0xD800..0xDC00).contains(&unit) +} + +#[inline] +fn is_trailing_surrogate(unit: u16) -> bool { + (0xDC00..0xE000).contains(&unit) } diff --git a/crates/perry-runtime/src/string/char_ops/utf16_index.rs b/crates/perry-runtime/src/string/char_ops/utf16_index.rs index c66e617da1..171ba0a280 100644 --- a/crates/perry-runtime/src/string/char_ops/utf16_index.rs +++ b/crates/perry-runtime/src/string/char_ops/utf16_index.rs @@ -11,7 +11,6 @@ use super::*; use std::cell::RefCell; const CHECKPOINT_BYTES: usize = 128; -const CACHE_ENTRIES: usize = 4; #[derive(Clone, Copy, Default)] struct Position { @@ -29,7 +28,11 @@ struct Index { } impl Index { - fn unit_at(&mut self, bytes: &[u8], idx: usize) -> Option { + /// Locate the code point containing UTF-16 index `idx`, returning its + /// position along with the decoded step. Shared by `unit_at` and + /// `boundary_at` so both pay the same amortised seek and both maintain the + /// same cursor and checkpoints. + fn seek(&mut self, bytes: &[u8], idx: usize) -> Option<(Position, usize, u32)> { let mut pos = self.cursor; // Nearby forward reads use the cursor, including the second half of // an astral character. Other seeks start at the nearest checkpoint. @@ -49,7 +52,7 @@ impl Index { let (advance, units, cp) = decode_step(bytes, pos.byte as usize); if units > 0 && pos.utf16 as usize + units > idx { self.cursor = pos; - return Some(code_unit(cp, units, idx == pos.utf16 as usize)); + return Some((pos, units, cp)); } // A truncated tail can advance past byte_len; never save an // out-of-payload cursor (or narrow that offset with a wrapping cast). @@ -59,6 +62,18 @@ impl Index { self.cursor = pos; None } + + fn unit_at(&mut self, bytes: &[u8], idx: usize) -> Option { + let (pos, units, cp) = self.seek(bytes, idx)?; + Some(code_unit(cp, units, idx == pos.utf16 as usize)) + } + + /// Byte offset of the code point containing `idx`, and whether `idx` is + /// its low surrogate half — i.e. `slice_range::Boundary` in its raw parts. + fn boundary_at(&mut self, bytes: &[u8], idx: usize) -> Option<(usize, bool)> { + let (pos, units, _) = self.seek(bytes, idx)?; + Some((pos.byte as usize, units == 2 && idx != pos.utf16 as usize)) + } } #[inline] @@ -82,29 +97,68 @@ fn decode_step(bytes: &[u8], i: usize) -> (usize, usize, u32) { wtf8_step(bytes, i) } -struct IndexCache { - entries: [Index; CACHE_ENTRIES], - hot: usize, - next: usize, -} +/// #10688: an owner-keyed map rather than a fixed array of slots. +/// +/// The array held `CACHE_ENTRIES` indexes and evicted round-robin, so a +/// program interleaving indexed access across more strings than that evicted +/// the entry it was about to need on every single access and rebuilt from +/// scratch forever — measured at 1,224x once K exceeded the slot count, with +/// no gradual degradation. Capacity is the defect, so there is no capacity: +/// entries live until their string dies, and `prune_dead_utf16_indexes` +/// (already driven by the collector) reclaims them. +/// +/// The map is keyed by a string identity the GC *rewrites* when it relocates +/// an object, so `scan_utf16_index_roots_mut` must rehash after the visitor +/// runs — see there. +type IndexCache = crate::fast_hash::PtrHashMap; -impl Default for IndexCache { - fn default() -> Self { - Self { - entries: std::array::from_fn(|_| Index::default()), - hot: 0, - next: 0, - } - } -} crate::perry_thread_local! { - static UTF16_INDEX_CACHE: RefCell = RefCell::new(IndexCache::default()); + static UTF16_INDEX_CACHE: RefCell = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); } /// Caller has validated the header and UTF-16 index. Small strings bypass the /// cache: in particular, consuming a character returned by `s[i]` must not evict /// the source string. ASCII callers retain their existing direct byte access. +/// Byte offset (and low-surrogate-half flag) for UTF-16 index `idx`, through +/// the same cache `unit_at` uses. #10685: `slice_range::copy_utf16_range` +/// resolved its start boundary with `advance(bytes, Boundary::default(), start)` +/// — a walk from byte 0 on every call — so slicing a non-ASCII string at +/// increasing offsets was O(n^2), which is the shape TypeScript's scanner has. +pub(super) fn boundary_at(s: *const StringHeader, idx: usize) -> Option<(usize, bool)> { + let byte_len = unsafe { (*s).byte_len }; + let bytes = unsafe { slice::from_raw_parts(string_data(s), byte_len as usize) }; + if bytes.len() < CHECKPOINT_BYTES || idx == 0 { + // Short strings and a zero start do not need the cache: the caller's + // own walk is already O(1)-ish, and consuming a slice must not evict + // the source string from a four-entry cache. + return None; + } + UTF16_INDEX_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let owner = s as usize; + let utf16_len = unsafe { (*s).utf16_len }; + let entry = cache.entry(owner).or_insert_with(|| Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }); + // A uniquely owned string can be appended to in place, which + // invalidates every recorded offset. + if entry.byte_len != byte_len || entry.utf16_len != utf16_len { + *entry = Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }; + } + entry.boundary_at(bytes, idx) + }) +} + pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { let byte_len = unsafe { (*s).byte_len }; let bytes = unsafe { slice::from_raw_parts(string_data(s), byte_len as usize) }; @@ -124,19 +178,16 @@ pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { UTF16_INDEX_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); let owner = s as usize; - let slot = if cache.entries[cache.hot].owner == owner { - cache.hot - } else if let Some(slot) = cache.entries.iter().position(|entry| entry.owner == owner) { - slot - } else { - let slot = cache.next; - cache.next = (slot + 1) % CACHE_ENTRIES; - slot - }; - cache.hot = slot; - let entry = &mut cache.entries[slot]; let utf16_len = unsafe { (*s).utf16_len }; - if entry.owner != owner || entry.byte_len != byte_len || entry.utf16_len != utf16_len { + let entry = cache.entry(owner).or_insert_with(|| Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }); + // A uniquely owned string can be appended to in place, which + // invalidates every recorded offset. + if entry.byte_len != byte_len || entry.utf16_len != utf16_len { *entry = Index { owner, byte_len, @@ -150,19 +201,27 @@ pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { pub(crate) fn scan_utf16_index_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { UTF16_INDEX_CACHE.with(|cache| { - for entry in &mut cache.borrow_mut().entries { - visitor.visit_metadata_usize_slot(&mut entry.owner); + let mut cache = cache.borrow_mut(); + // The visitor may relocate the string each entry describes, which + // changes the very address the map is keyed by. Drain first, let the + // owners be rewritten, then reinsert so the keys and the `owner` + // fields agree again. + let mut moved: Vec<(usize, Index)> = cache.drain().collect(); + for (key, index) in &mut moved { + visitor.visit_metadata_usize_slot(key); + index.owner = *key; + } + for (key, index) in moved { + cache.insert(key, index); } }); } pub(crate) fn prune_dead_utf16_indexes(is_dead_owner: &dyn Fn(usize) -> bool) { UTF16_INDEX_CACHE.with(|cache| { - for entry in &mut cache.borrow_mut().entries { - if entry.owner != 0 && is_dead_owner(entry.owner) { - *entry = Index::default(); - } - } + cache + .borrow_mut() + .retain(|&owner, _| owner != 0 && !is_dead_owner(owner)); }); } @@ -174,13 +233,15 @@ thread_local! { #[cfg(test)] pub(crate) fn test_utf16_index_entries() -> Vec<(usize, usize)> { UTF16_INDEX_CACHE.with(|cache| { - cache + let mut entries: Vec<(usize, usize)> = cache .borrow() - .entries .iter() - .filter(|entry| entry.owner != 0) - .map(|entry| (entry.owner, entry.checkpoints.len())) - .collect() + .filter(|(&owner, _)| owner != 0) + .map(|(&owner, index)| (owner, index.checkpoints.len())) + .collect(); + // HashMap iteration order is not stable; callers compare snapshots. + entries.sort_unstable(); + entries }) } diff --git a/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs b/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs index fed6f718bc..c82c6b8f46 100644 --- a/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs +++ b/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs @@ -148,10 +148,22 @@ fn cache_distinguishes_strings_and_invalidates_in_place_appends() { prune_dead_utf16_indexes(&|_| true); } +/// #10688: the index used to live in a fixed four-slot array that evicted +/// round-robin, so interleaving indexed access across more strings than that +/// rebuilt from scratch on every access — 1,224x, as a step function at the +/// fifth string. There is no capacity now, so this asserts the replacement +/// guarantee: **an index survives no matter how many other strings are +/// indexed alongside it.** +/// +/// It also keeps the original invariant this test carried, which is unrelated +/// to capacity and still load-bearing: consuming a one-character string +/// produced by `char_at` must not disturb the source string's index. #[test] -fn cache_eviction_is_bounded_and_short_strings_do_not_evict_sources() { +fn indexes_survive_any_number_of_interleaved_strings() { prune_dead_utf16_indexes(&|_| true); - for i in 0..CACHE_ENTRIES * 3 { + const STRINGS: usize = 16; // comfortably past the old four-slot capacity + let mut sources = Vec::new(); + for i in 0..STRINGS { let text = format!( "{}{}", "中".repeat(256), @@ -162,8 +174,150 @@ fn cache_eviction_is_bounded_and_short_strings_do_not_evict_sources() { let before = test_utf16_index_entries(); let ch = js_string_char_at(s, 256); assert_eq!(js_string_char_code_at(ch, 0), (0x400 + i) as f64); - assert_eq!(test_utf16_index_entries(), before); - assert!(before.len() <= CACHE_ENTRIES); + assert_eq!( + test_utf16_index_entries(), + before, + "a short string from char_at must not disturb the source's index" + ); + sources.push((s, 0x400 + i)); + } + // Every index is still resident: no eviction happened at any depth. + assert_eq!( + test_utf16_index_entries().len(), + STRINGS, + "all {STRINGS} indexes must survive; the old array held only four" + ); + // And every one still answers correctly, cheaply, in a second pass. + for (s, expected) in &sources { + assert_eq!(js_string_char_code_at(*s, 256), *expected as f64); } prune_dead_utf16_indexes(&|_| true); + assert!( + test_utf16_index_entries().is_empty(), + "the collector's prune hook must reclaim them" + ); +} + +/// #10656: `codePointAt` used to walk the WTF-8 payload from byte 0 on every +/// call, so a scan over a string holding one non-ASCII character was O(n^2). +/// These pin the spec behaviour across the cached-index path that replaced it: +/// a BMP code point, the start of a surrogate pair (the whole code point), the +/// low half (the bare trailing surrogate), and an unpaired leading surrogate. +#[test] +fn code_point_at_matches_the_spec_through_the_cached_index() { + // Long enough to exercise the checkpoint/cursor path, not the short-string + // fallback, and non-ASCII so it cannot take the ASCII fast path. + let mut text = String::new(); + for _ in 0..200 { + text.push_str("\u{e9}abcdefghij0123456789"); + } + let astral_at = text.chars().count(); + text.push('\u{1F600}'); // surrogate pair + text.push('z'); + + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let units: Vec = text.encode_utf16().collect(); + + // Walk forwards (the sequential case tsc hits) and compare every index + // against an independent UTF-16 expansion of the same text. + for (idx, &unit) in units.iter().enumerate() { + let got = crate::string::js_string_code_point_at(s, idx as i32); + let expected = if (0xD800..0xDC00).contains(&unit) && idx + 1 < units.len() { + let second = units[idx + 1]; + if (0xDC00..0xE000).contains(&second) { + 0x10000 + (((unit as u32 - 0xD800) << 10) | (second as u32 - 0xDC00)) + } else { + unit as u32 + } + } else { + unit as u32 + }; + assert_eq!(got, expected as f64, "codePointAt({idx})"); + } + + // The surrogate pair specifically: start yields the astral code point, the + // low half yields the bare trailing surrogate. + let pair_start = units.len() - 3; + assert_eq!( + crate::string::js_string_code_point_at(s, pair_start as i32), + 128512.0_f64 + ); + assert!( + (0xDC00..0xE000).contains(&(crate::string::js_string_code_point_at(s, pair_start as i32 + 1) as u32 as u16)) + ); + let _ = astral_at; + + // Out of bounds stays undefined. + let oob = crate::string::js_string_code_point_at(s, units.len() as i32); + assert_eq!(oob.to_bits(), crate::value::TAG_UNDEFINED); +} + +/// Random access must agree with sequential access: the cursor optimises the +/// forward case, and a backward seek must not return a stale answer. +#[test] +fn code_point_at_is_order_independent() { + let mut text = String::new(); + for _ in 0..150 { + text.push_str("x\u{e9}yz"); + } + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let n = text.encode_utf16().count(); + + let forward: Vec = (0..n) + .map(|i| crate::string::js_string_code_point_at(s, i as i32)) + .collect(); + let backward: Vec = (0..n) + .rev() + .map(|i| crate::string::js_string_code_point_at(s, i as i32)) + .collect(); + for (i, value) in backward.iter().rev().enumerate() { + assert_eq!(*value, forward[i], "index {i} differs by traversal order"); + } +} + +/// #10685: `copy_utf16_range` resolved its start boundary by walking from byte +/// 0 on every call, so slicing a non-ASCII string at increasing offsets was +/// O(n^2). `boundary_at` must agree with that walk at every index — including +/// the low half of a surrogate pair, where `low` selects the split copy path. +#[test] +fn boundary_at_matches_a_walk_from_zero() { + let mut text = String::new(); + for _ in 0..80 { + text.push_str("\u{e9}abcdefghij0123456789"); + } + text.push('\u{1F600}'); + text.push_str("tail\u{e9}"); + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let bytes = unsafe { + std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize) + }; + let n = text.encode_utf16().count(); + + for idx in 0..n { + let walked = crate::string::slice_range::advance( + bytes, + crate::string::slice_range::Boundary::default(), + idx, + ); + if let Some((byte, low)) = super::boundary_at(s, idx) { + assert_eq!(byte, walked.byte, "byte offset at {idx}"); + assert_eq!(low, walked.low, "low-surrogate flag at {idx}"); + } + } +} + +/// The cursor optimises forward seeks; a backward seek must not reuse it. +#[test] +fn boundary_at_is_order_independent() { + let mut text = String::new(); + for _ in 0..80 { + text.push_str("x\u{e9}yz"); + } + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let n = text.encode_utf16().count(); + let forward: Vec<_> = (0..n).map(|i| super::boundary_at(s, i)).collect(); + let backward: Vec<_> = (0..n).rev().map(|i| super::boundary_at(s, i)).collect(); + for (i, value) in backward.iter().rev().enumerate() { + assert_eq!(*value, forward[i], "index {i} differs by traversal order"); + } } diff --git a/crates/perry-runtime/src/string/slice_range.rs b/crates/perry-runtime/src/string/slice_range.rs index e4fbd12577..0fe5a4702f 100644 --- a/crates/perry-runtime/src/string/slice_range.rs +++ b/crates/perry-runtime/src/string/slice_range.rs @@ -33,7 +33,16 @@ pub(super) fn copy_utf16_range(s: *const StringHeader, start: u32, end: u32) -> return string_copy_range(s, start as usize, end - start, end - start, 0); } let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; - let first = advance(bytes, Boundary::default(), start as usize); + // #10685: resolve the start boundary through the cached UTF-16 index + // rather than walking from byte 0 on every call. Slicing a non-ASCII + // string at increasing offsets — TypeScript's scanner, and every other + // tokenizer — was O(n^2) because of that walk. `None` keeps the original + // behaviour for short payloads and `start == 0`, where the walk is already + // cheap and the cache should not be disturbed. + let first = match char_ops::utf16_boundary_at(s, start as usize) { + Some((byte, low)) => Boundary { byte, low }, + None => advance(bytes, Boundary::default(), start as usize), + }; // A suffix's end is already known: do not scan the entire remaining string. let last = if end == unsafe { (*s).utf16_len } { Boundary {