From 056d79d07ccf9d19af41674c2972c05338aec3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 07:39:02 +0200 Subject: [PATCH 1/9] perf(runtime): drop the saturated buffer address filter BUFFER_LIKE_ADDR_FILTER ends every measured claude-code run with all 1,024 bits set and rejects nothing, while costing three hash rounds and up to three dependent loads on every one of ~26.6 M admitted probes per run. Its adoption note asked the capacity question and answered it from a 400-character reply: 213 cumulative registrations, live_max 201, true positives 0.207% of admits, predicting a 10.0% false-positive rate. An ordinary command (startup, two real Read calls, streamed reply) falsifies both premises. The population is 15x larger - 3,232 cumulative admissions and 1,618 live against 1,024 bits - so the filter saturates, and every rejection comes from the window in front of it at 15.51%, not the 25.94% the note quotes. And 88.87% of admitted probes find a real registered buffer, against 0.207% before, so a filter cannot remove work the registry genuinely has to do. Six interleaved pairs, one binary and one environment variable: minimum command CPU 1.22 -> 1.19 s (-2.46%), medians 1.28 -> 1.20 s, paired median -3.60%, faster in five pairs and tied in the sixth, peak RSS maxima 664.5 -> 635.2 MiB. Node anchor in the same session 0.41/0.42 s. The min/max window stays and keeps doing all of the rejecting, including the debug-build machine-check that re-derives every rejection from the authoritative tables. Deleting a negative accelerator cannot produce a wrong answer. (cherry picked from commit b13d3824dfbabbc5884a46208ae173bd4083f326) --- crates/perry-runtime/src/buffer/header.rs | 92 ++++++++++------------- 1 file changed, 39 insertions(+), 53 deletions(-) diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index c3b2c0a4d6..804bb30531 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -227,55 +227,44 @@ static BUFFER_LIKE_EVER_REGISTERED: RegistryLatch = RegistryLatch::new(); /// [`RegistryAddrWindow`] for the ordering rule that makes it so. static BUFFER_LIKE_ADDR_WINDOW: RegistryAddrWindow = RegistryAddrWindow::new(); -/// The set filter behind the window, for the addresses `[lo, hi]` cannot -/// discriminate. -/// -/// The window's 98.0 % rejection rate above is measured on `claude-code -/// --help`, which registers **10** buffers. On a streaming turn cc registers -/// **213**, scattered across a **527 MB** span — so `[lo, hi]` covers half a -/// gigabyte of ordinary heap and stops rejecting. `PERRY_BUFFER_DIAG`, one -/// 400-character reply: -/// -/// ```text -/// probes=34,603,009 admits=25,627,160 (74.06 %) rejected=8,975,849 (25.94 %) -/// true_positives=53,109 (0.207 % of admits) -/// window [0x5b718eb73e8, 0x5b739e1c0b8] span 527.4 MB -/// registrations=213 unregistrations=12 live_max=201 -/// ``` -/// -/// 25.6 million out-of-line probes per reply, 99.79 % of which find nothing. -/// That is the failure [`RegistryAddrFilter`] was built for after #9272 -/// (`is_registered_symbol`: a window rejects 38.3 %, the filter 99.58 %) — its -/// entries are ordinary heap objects interleaved with everything else, which -/// its doc comment names as the case a window cannot serve. -/// -/// **The capacity question this structure demands was asked before adopting -/// it.** `RegistryAddrFilter` accrues bits per ADMISSION and never clears them, -/// so a high-churn set saturates it — the trap #9807 documented for the -/// per-object layout filter, which held 162,258 keys against 4,096 bits and -/// answered "may hold" to every probe. Buffers are not that case: probing is -/// hot but registration is rare, and **213 cumulative admissions against 1,024 -/// bits and 3 hashes is a 10.0 % false-positive rate**, so the filter rejects -/// about nine of every ten addresses the window admits. The counter that says -/// so ships with it. -/// -/// The window stays in front: two static loads reject 25.94 % for less than -/// the filter's three hashes cost. -static BUFFER_LIKE_ADDR_FILTER: crate::registry_latch::RegistryAddrFilter = - crate::registry_latch::RegistryAddrFilter::new(); - -/// `PERRY_BUFFER_ADDR_FILTER=0` restores the window-only probe, so one binary -/// carries both and the A/B is one environment variable. -fn buffer_addr_filter_enabled() -> bool { - use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - !matches!( - std::env::var("PERRY_BUFFER_ADDR_FILTER").as_deref(), - Ok("0") | Ok("off") | Ok("false") - ) - }) -} +// The set filter that used to sit behind the window was REMOVED on 2026-09-12, +// measured. Its own adoption note asked the capacity question and answered it +// from a 400-character reply: 213 cumulative registrations, live_max 201, and +// `true_positives=53,109 (0.207 % of admits)`, giving a predicted 10.0 % +// false-positive rate — "the filter rejects about nine of every ten addresses +// the window admits". +// +// Both premises fail on an ordinary command (startup, two real `Read` tool +// calls, streamed reply). `PERRY_BUFFER_DIAG`, two rows: +// +// probes=31,457,281 admits=26,577,900 (84.49 %) rejected=4,879,381 (15.51 %) +// true_positives=23,620,613 (88.873135 % of admits) +// registrations=3232 unregistrations=1907 live_max=1618 +// +// * The population is 15x larger than assumed — 3,232 cumulative admissions +// and 1,618 live against 1,024 bits — so the filter ended every row with +// ALL 1,024 BITS SET. It rejected nothing: every rejection in the row above +// comes from `BUFFER_LIKE_ADDR_WINDOW` in front of it, at 15.51 %, not the +// 25.94 % the old note quoted. +// * The question's answer is usually YES here. 88.87 % of admitted probes +// find a real registered buffer, against 0.207 % on the `--help`-shaped +// workload the note measured. A filter cannot remove work the registry +// genuinely has to do, so even a correctly sized one could only have taken +// the ~2.96 M false positives per row off the slow path. +// +// So the structure cost three hash rounds and up to three dependent loads on +// every one of ~26.6 M admitted probes per run and bought zero rejections. +// Removing it is worth 2.46 % of minimum command CPU and 3.60 % paired median: +// six interleaved pairs, one binary, the filter's own env-var arm against the +// default, 1.22 -> 1.19 s minimum, faster in five pairs and tied in the sixth, +// peak RSS no worse. The window stays — two static loads that reject 15.51 % +// for less than the filter's three hashes cost. +// +// The general rule, because this is the second owner of this type measured the +// same night: an occupancy number prices a filter only together with the +// TRUE-POSITIVE RATE of what it admits. The sibling canonical-handle owner was +// saturated the same way but resolved only 0.021 % of its admissions, and there +// the remedy was the opposite one — size the structure to its population. #[cfg(test)] thread_local! { @@ -316,7 +305,6 @@ pub(crate) fn note_buffer_like_registered(addr: usize) { // checks the latch and then the window, so both must already cover this // address by the time it becomes findable. BUFFER_LIKE_ADDR_WINDOW.admit(addr); - BUFFER_LIKE_ADDR_FILTER.admit(addr); BUFFER_LIKE_EVER_REGISTERED.arm(); } @@ -466,7 +454,6 @@ pub fn register_buffer(ptr: *const BufferHeader) { // the idle fast path and denies it. See `crate::registry_latch`. let addr = ptr as usize; BUFFER_LIKE_ADDR_WINDOW.admit(addr); - BUFFER_LIKE_ADDR_FILTER.admit(addr); BUFFER_LIKE_EVER_REGISTERED.arm(); BUFFER_ADDR_RANGE.with(|r| { let (lo, hi) = r.get(); @@ -508,8 +495,7 @@ pub fn is_registered_buffer(addr: usize) -> bool { // call, the thread-local resolution, the `RefCell` borrow or the hash. // Every writer widens the window before it publishes, which is what makes // rejecting sound; see `BUFFER_LIKE_ADDR_WINDOW`. - let admitted = BUFFER_LIKE_ADDR_WINDOW.may_contain(addr) - && (!buffer_addr_filter_enabled() || BUFFER_LIKE_ADDR_FILTER.may_contain(addr)); + let admitted = BUFFER_LIKE_ADDR_WINDOW.may_contain(addr); if crate::hot_diag::buffer_on() { crate::hot_diag::buffer_note_probe(addr, admitted, BUFFER_LIKE_ADDR_WINDOW.bounds()); } From 20cd3dd095516e22e5b162e36f2dd0f986c348f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:35:51 +0200 Subject: [PATCH 2/9] perf(runtime): bulk-copy Uint8Array/Buffer.prototype.set instead of per-byte view lookups (#10088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_buffer_set_bytes paid a view-registry lookup per byte for a Buffer source, and a per-element dispatch/coercion call per byte for a TypedArray source, materializing the result into an intermediate Vec before copying it into the target a second time. For a Buffer source, or a same-element-width (1-byte) TypedArray source (Int8Array/Uint8Array/Uint8ClampedArray), resolve the source's raw byte span once via the existing view::resolve_data_ptr / typedarray::data_ptr resolvers and copy it straight into the target with ptr::copy (a memmove, so a source/destination overlap through a shared backing buffer stays correct). Array/Object sources and wider/BigInt TypedArray sources are unchanged. Conflict resolved on the train: this branch is based on train161's `main`, and train162's #10071 (zero-copy Buffer/Uint8Array subarrays) has since deleted `view::propagate_written_range_from_receiver` outright — views now share their backing storage, so a write through the target's data pointer is already visible to every view of it and there is nothing to propagate. The bulk-copy fast path and its `ptr::copy` overlap handling are kept verbatim; the two propagate calls are dropped because the function no longer exists. --- crates/perry-runtime/src/buffer/access.rs | 60 +++++++- ...test_gap_10088_uint8array_set_bulk_copy.ts | 142 ++++++++++++++++++ 2 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 test-files/test_gap_10088_uint8array_set_bulk_copy.ts diff --git a/crates/perry-runtime/src/buffer/access.rs b/crates/perry-runtime/src/buffer/access.rs index 569be4c703..7b50411753 100644 --- a/crates/perry-runtime/src/buffer/access.rs +++ b/crates/perry-runtime/src/buffer/access.rs @@ -171,6 +171,44 @@ unsafe fn collect_buffer_set_bytes(source: BufferSetSource, source_len: usize) - bytes } +/// Resolve a raw byte span for sources whose elements need no per-index +/// coercion: a `Buffer`/`Uint8Array` source, or a same-element-width +/// (1-byte-per-element) `TypedArray` source (`Int8Array`, `Uint8Array`, +/// `Uint8ClampedArray`) — for these kinds the stored byte already equals +/// `to_uint8` of the read element (two's-complement reinterpretation for +/// `Int8Array`, identity for the other two), so the underlying bytes can be +/// copied directly. Returns `None` for `Array`/`Object` sources (need +/// per-index `ToNumber`/property-read coercion) and for wider or BigInt +/// `TypedArray` kinds (need per-element numeric coercion) — those fall back +/// to [`collect_buffer_set_bytes`]. +/// +/// Resolving through [`super::view::resolve_data_ptr`] / [`crate:: +/// typedarray::data_ptr`] here (once) rather than through [`js_buffer_get`] +/// / [`crate::typedarray::js_typed_array_get`] per byte (#10088) is what +/// collapses the view-registry lookup from O(n) to O(1) per call. +unsafe fn bulk_copy_source_ptr(source: BufferSetSource) -> Option<*const u8> { + match source { + BufferSetSource::Buffer(ptr) => { + if ptr.is_null() { + None + } else { + Some(super::view::resolve_data_ptr(ptr)) + } + } + BufferSetSource::TypedArray(ptr) => { + let kind = crate::typedarray::lookup_typed_array_kind(ptr as usize)?; + matches!( + kind, + crate::typedarray::KIND_INT8 + | crate::typedarray::KIND_UINT8 + | crate::typedarray::KIND_UINT8_CLAMPED + ) + .then(|| crate::typedarray::data_ptr(ptr)) + } + BufferSetSource::Array(_) | BufferSetSource::Object(_) | BufferSetSource::Empty => None, + } +} + /// Read the byte at `index`, resolving a registered view to its ultimate /// backing buffer. Returns `None` for a null receiver or an out-of-range /// index (`index < 0` or `index >= length`). Shared by the native i32 @@ -313,10 +351,24 @@ pub extern "C" fn js_buffer_set_from_value( super::numeric::throw_out_of_range(); } - let bytes = collect_buffer_set_bytes(source, source_len); - if !bytes.is_empty() { - let target_data = buffer_data_mut(target).add(offset); - ptr::copy_nonoverlapping(bytes.as_ptr(), target_data, bytes.len()); + match bulk_copy_source_ptr(source) { + Some(src_data) if source_len > 0 => { + // `ptr::copy` (memmove) rather than `copy_nonoverlapping`: + // `src_data` can legitimately point into the same backing + // buffer `target` is a view of (or vice versa), e.g. + // `buf.set(buf.subarray(2))`, so source and destination + // ranges may overlap for real. + let target_data = buffer_data_mut(target).add(offset); + ptr::copy(src_data, target_data, source_len); + } + Some(_) => {} + None => { + let bytes = collect_buffer_set_bytes(source, source_len); + if !bytes.is_empty() { + let target_data = buffer_data_mut(target).add(offset); + ptr::copy_nonoverlapping(bytes.as_ptr(), target_data, bytes.len()); + } + } } } diff --git a/test-files/test_gap_10088_uint8array_set_bulk_copy.ts b/test-files/test_gap_10088_uint8array_set_bulk_copy.ts new file mode 100644 index 0000000000..034751b5b9 --- /dev/null +++ b/test-files/test_gap_10088_uint8array_set_bulk_copy.ts @@ -0,0 +1,142 @@ +// #10088 — Uint8Array/Buffer.prototype.set(source, offset) went through a +// per-byte view-table lookup (and, for Buffer/TypedArray sources, an +// intermediate Vec) instead of a bulk copy. The fix resolves the view +// indirection once per call for Buffer and same-element-width (1-byte) +// TypedArray sources and copies the raw span directly. This exercises every +// source arm plus the overlap / view-coherency guarantees the bulk path must +// preserve. + +// `instanceof`, not `e.constructor.name`: a pre-existing, unrelated gap in +// the ERR_OUT_OF_RANGE error path (#buffer/numeric.rs's `throw_range_error_ +// code`) leaves `.constructor` undefined on that particular RangeError, so +// asserting the class this way stays independent of that separate bug. +function r(fn: () => unknown): string { + try { + return "ok:" + String(fn()); + } catch (e: any) { + if (e instanceof RangeError) return "throw:RangeError"; + if (e instanceof TypeError) return "throw:TypeError"; + return "throw:" + (e && e.name ? e.name : String(e)); + } +} + +function show(a: Uint8Array): string { + return Array.from(a).join(","); +} + +// ---- Buffer-to-Buffer (the bulk-copy fast path) ---- +{ + const dest = Buffer.alloc(6); + const src = Buffer.from([10, 20, 30]); + dest.set(src, 2); + console.log("buffer-to-buffer:", show(dest)); +} + +// ---- Uint8Array-to-Uint8Array ---- +{ + const dest = new Uint8Array(5); + const src = new Uint8Array([1, 2, 3]); + dest.set(src, 1); + console.log("u8-to-u8:", show(dest)); +} + +// ---- Int8Array source: negative values must wrap to the same byte a +// two's-complement reinterpretation gives (-1 -> 255, -128 -> 128). ---- +{ + const dest = new Uint8Array(4); + const src = new Int8Array([-1, -128, 127, 0]); + dest.set(src); + console.log("int8-source:", show(dest)); +} + +// ---- Uint8ClampedArray source: already-clamped bytes copy as-is. ---- +{ + const dest = new Uint8Array(3); + const src = new Uint8ClampedArray([0, 128, 255]); + dest.set(src); + console.log("uint8clamped-source:", show(dest)); +} + +// ---- Multi-byte TypedArray source: still needs per-element ToNumber-style +// coercion (NOT the bulk path) — Float64Array carrying fractional/huge/NaN +// values. ---- +{ + const dest = new Uint8Array(4); + const src = new Float64Array([300, -1, NaN, 3.9]); + dest.set(src); + console.log("float64-source:", show(dest)); +} + +// ---- Plain Array source needing ToUint8 wrapping. ---- +{ + const dest = new Uint8Array(5); + dest.set([-1, 256, 300.9, NaN, Infinity]); + console.log("array-source:", show(dest)); +} + +// ---- Array-like Object source with index-named properties. ---- +{ + const dest = new Uint8Array(3); + dest.set({ length: 3, 0: 7, 1: 8, 2: 9 } as any); + console.log("object-source:", show(dest)); +} + +// ---- Zero-length source: no-op, destination untouched. ---- +{ + const dest = new Uint8Array([1, 2, 3]); + dest.set(new Uint8Array(0), 1); + console.log("zero-length-source:", show(dest)); +} + +// ---- Out-of-range offset: RangeError, target untouched. ---- +{ + const dest = new Uint8Array(3); + console.log("out-of-range-offset:", r(() => dest.set(new Uint8Array(2), 5))); + console.log("out-of-range-offset target:", show(dest)); +} + +// ---- BigInt-kind source mixed with a Number-kind target must still throw +// (unaffected by the bulk path — BigInt64/BigUint64 never take it). ---- +{ + const dest = new Uint8Array(3); + console.log("bigint-mix:", r(() => dest.set(new BigInt64Array([1n, 2n]) as any))); +} + +// ---- Overlap: forward shift (`buf.set(buf.subarray(2))`, dest offset < the +// subarray's own start — reading must observe the ORIGINAL bytes throughout, +// like memmove). ---- +{ + const buf = new Uint8Array([1, 2, 3, 4, 5, 6]); + buf.set(buf.subarray(2), 0); + console.log("overlap-forward:", show(buf)); +} + +// ---- Overlap: backward shift (dest offset > source's own start). ---- +{ + const buf = new Uint8Array([1, 2, 3, 4, 5, 6]); + buf.set(buf.subarray(0, 4), 2); + console.log("overlap-backward:", show(buf)); +} + +// ---- Overlap: source fully nested inside the destination's own window. ---- +{ + const buf = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + buf.set(buf.subarray(2, 5), 3); + console.log("overlap-nested:", show(buf)); +} + +// ---- #1205 view coherency: a direct write to the BACKING buffer (the +// codegen fast path for a statically-typed Buffer.alloc local) must still be +// visible to a `set()` that reads through a registered view of it. ---- +{ + const backing = Buffer.alloc(4); + backing[0] = 1; + backing[1] = 2; + backing[2] = 3; + backing[3] = 4; + const view = backing.subarray(1, 3); + backing[2] = 99; // direct write to the backing AFTER the view was created + const dest = Buffer.alloc(2); + dest.set(view); + console.log("view-coherency:", show(dest)); +} From e7ab2fc71cb6ba39980ed5d4ef8ac0c03088184a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:36:38 +0200 Subject: [PATCH 3/9] changelog: add fragment for #10096 (cherry picked from commit c9fb41291422840bc4984bf0f989d6954b3f711b) --- changelog.d/10096-uint8array-set-bulk-copy.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10096-uint8array-set-bulk-copy.md diff --git a/changelog.d/10096-uint8array-set-bulk-copy.md b/changelog.d/10096-uint8array-set-bulk-copy.md new file mode 100644 index 0000000000..1e396f4fc0 --- /dev/null +++ b/changelog.d/10096-uint8array-set-bulk-copy.md @@ -0,0 +1,17 @@ +Fixed `Uint8Array`/`Buffer.prototype.set(source, offset)` paying a view-registry +lookup (and, for `Buffer`/`TypedArray` sources, an extra `Vec` copy) per +byte instead of per call, reaching 171x Node at 100k bytes and 82x at 1M +(#10088). `js_buffer_set_from_value` now resolves a `Buffer` source's view +indirection once via `view::resolve_data_ptr` (already the codebase's +established span resolver, used by `bun_ffi`/`DataView`) and, for a +same-element-width (1-byte) `TypedArray` source (`Int8Array`, `Uint8Array`, +`Uint8ClampedArray`), reads its raw byte span directly via +`typedarray::data_ptr` — the stored byte already equals what `to_uint8` of the +read element would give for all three kinds. The single resulting span is then +copied straight into the target with `ptr::copy` (a memmove), which also +correctly handles the case where the resolved source span physically overlaps +the destination (e.g. `buf.set(buf.subarray(2), 0)`) without needing an +intermediate buffer. `Array`/`Object` sources and wider or BigInt-kind +`TypedArray` sources are untouched — they still need per-element +coercion/property reads. 1M-byte `Uint8Array.set` measured ~1.2x Node locally, +down from the issue's ~82x. From 85812f48c2722e0ded102066ad7c6d30207c1c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 07:42:38 +0200 Subject: [PATCH 4/9] perf(string): drop localeCompare's per-comparison allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primary (case-insensitive) collation pass built two fresh lowercased `String`s with `str::to_lowercase` and compared those, so every comparison paid two heap allocations and two full Unicode case-mapping passes over both operands to answer a question usually decided by the first character — and a sort pays that O(n log n) times. Walk the two lowercased scalar streams in lockstep instead, stopping at the first difference, with a byte-level loop for the leading all-ASCII run. On the non-ASCII path `locale_compare_canonical` also stops materializing two NFC `String`s per comparison: an `is_nfc_quick` check (a table lookup per scalar, no allocation) skips the rewrite for text that is already NFC, which covers precomposed letters, CJK and emoji. The ordering is deliberately unchanged. U+03A3 is the one scalar whose lowercase mapping is context-dependent (final sigma ς vs medial σ); the walk reports it rather than guessing and falls back to `str::to_lowercase`, which implements the Final_Sigma rule. Tests pin both halves of that claim: `matches_the_allocating_reference_{on_every_pair,on_random_strings}` are differential against the exact formulation this replaces, over a corpus spanning ASCII, Latin-1 accented letters in both spellings, CJK, emoji, combining marks, the two special case mappings and WTF-8 lone surrogates, and assert the corpus reaches all three arms; `locale_compare_is_a_strict_weak_ordering` proves reflexivity, antisymmetry and transitivity over every triple, so `Array.prototype.sort` stays well-defined; `canonical_equivalents_stay_equal` and `documented_guarantees_hold` cover canonical equivalence, case-only differences, empty/prefix/long-common-prefix pairs and the documented divergence from ICU. Also make the limitation public and accurate. `docs/typescript-parity-gaps.md` listed `localeCompare()` and `toLocaleLowerCase()`/`toLocaleUpperCase()` as "Missing (needs Intl)"; all three are implemented. The new note and the rewritten `js_string_locale_compare` doc comment state what the ordering actually guarantees — canonical equivalence, case-insensitive code point order, a lowercase-first case tiebreak — and what it does not: no collation weights, no locale tailoring, and an order that differs from Node for accented letters, symbols and emoji, by design rather than by omission. Refs #10094 (cherry picked from commit c8efcde5a05037bebfb8e82d0fee502ff068cbe9) --- crates/perry-runtime/src/string/compare.rs | 529 ++++++++++++++++++++- docs/typescript-parity-gaps.md | 21 +- 2 files changed, 540 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index 852670652c..5bbed33365 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -727,7 +727,18 @@ fn locale_compare_canonical(a: &str, b: &str, compare: fn(&str, &str) -> f64) -> } #[cfg(feature = "string-normalize")] { - use unicode_normalization::UnicodeNormalization; + use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization}; + // Non-ASCII text is still usually *already* NFC — precomposed letters, + // CJK and emoji all are; only combining marks and decomposable + // singletons are not. The quick check is a table lookup per scalar and + // allocates nothing, so only text that genuinely needs rewriting pays + // for the two `String`s. (#10094: this ran on every comparison, and a + // sort pays it O(n log n) times.) + if is_nfc_quick(a.chars()) == IsNormalized::Yes + && is_nfc_quick(b.chars()) == IsNormalized::Yes + { + return compare(a, b); + } let a_nfc: String = a.nfc().collect(); let b_nfc: String = b.nfc().collect(); compare(&a_nfc, &b_nfc) @@ -736,15 +747,146 @@ fn locale_compare_canonical(a: &str, b: &str, compare: fn(&str, &str) -> f64) -> compare(a, b) } +/// One step of the streaming lowercase view that the primary collation pass +/// walks instead of materializing `str::to_lowercase`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum LowerStep { + /// The next scalar of the lowercased form. + Char(char), + /// Input exhausted. + End, + /// U+03A3 GREEK CAPITAL LETTER SIGMA — the one *contextual* (but + /// language-independent) lowercase mapping in `SpecialCasing.txt`: it + /// becomes ς at the end of a word and σ everywhere else, which needs the + /// `Cased` / `Case_Ignorable` properties of the surrounding text. This + /// walk reports it instead of guessing, and the caller falls back to + /// `str::to_lowercase`, which implements the rule. + Contextual, +} + +/// `str::to_lowercase` as a borrow-only iterator over scalars. +/// +/// `str::to_lowercase` is exactly `chars().flat_map(char::to_lowercase)` apart +/// from the final-sigma rule above, so walking two of these in lockstep +/// answers the primary comparison without materializing either lowercased +/// string — and, because the walk stops at the first difference, usually +/// without case-mapping more than the first scalar or two. +struct LowerChars<'a> { + rest: std::str::Chars<'a>, + /// Tail of a one-to-many expansion. U+0130 (`İ` → `i` + U+0307) is the + /// only unconditional one, but `char::to_lowercase` is allowed up to + /// three scalars and this holds whatever it yields. + pending: Option, +} + +impl<'a> LowerChars<'a> { + fn new(s: &'a str) -> Self { + LowerChars { + rest: s.chars(), + pending: None, + } + } + + fn next(&mut self) -> LowerStep { + if let Some(pending) = self.pending.as_mut() { + if let Some(c) = pending.next() { + return LowerStep::Char(c); + } + self.pending = None; + } + let c = match self.rest.next() { + Some(c) => c, + None => return LowerStep::End, + }; + // ASCII is one-to-one and needs no case table. + if c.is_ascii() { + return LowerStep::Char(c.to_ascii_lowercase()); + } + if c == GREEK_CAPITAL_SIGMA { + return LowerStep::Contextual; + } + let mut expansion = c.to_lowercase(); + let first = expansion.next().unwrap_or(c); + self.pending = Some(expansion); + LowerStep::Char(first) + } +} + +/// U+03A3, the only scalar whose lowercase mapping depends on its context. +const GREEK_CAPITAL_SIGMA: char = '\u{03A3}'; + +/// Primary (case-insensitive) collation pass: order the two inputs exactly as +/// `a.to_lowercase().cmp(&b.to_lowercase())` would, without allocating either +/// lowercased form. +/// +/// Comparing the lowercased scalar streams by code point is equivalent to +/// `String::cmp`, because UTF-8 (and WTF-8) byte order and code point order +/// agree. +/// +/// Returns `None` when a context-dependent mapping is reached before the +/// answer is decided — the caller's signal to fall back to the allocating +/// comparison, which resolves the final-sigma rule properly. +fn locale_primary_cmp(a: &str, b: &str) -> Option { + // ASCII maps one-to-one under `to_lowercase`, so while both sides are + // ASCII the lowercased streams stay byte-aligned with the inputs and a + // plain byte walk decides the comparison without decoding anything. + let (a_bytes, b_bytes) = (a.as_bytes(), b.as_bytes()); + let common = a_bytes.len().min(b_bytes.len()); + let mut i = 0; + while i < common && a_bytes[i].is_ascii() && b_bytes[i].is_ascii() { + let x = a_bytes[i].to_ascii_lowercase(); + let y = b_bytes[i].to_ascii_lowercase(); + if x != y { + return Some(x.cmp(&y)); + } + i += 1; + } + // Everything before `i` was ASCII on both sides, so `i` is a scalar + // boundary in both strings and both lowercased streams are `i` scalars in. + locale_primary_cmp_scalars(&a[i..], &b[i..]) +} + +fn locale_primary_cmp_scalars(a: &str, b: &str) -> Option { + use std::cmp::Ordering; + let mut ai = LowerChars::new(a); + let mut bi = LowerChars::new(b); + loop { + match (ai.next(), bi.next()) { + (LowerStep::Contextual, _) | (_, LowerStep::Contextual) => return None, + (LowerStep::End, LowerStep::End) => return Some(Ordering::Equal), + (LowerStep::End, LowerStep::Char(_)) => return Some(Ordering::Less), + (LowerStep::Char(_), LowerStep::End) => return Some(Ordering::Greater), + (LowerStep::Char(x), LowerStep::Char(y)) => { + if x != y { + return Some(x.cmp(&y)); + } + } + } + } +} + /// Approximate the Unicode default collation with a two-pass comparison: /// first case-insensitive (so the character class wins) and then /// case-sensitive with lowercase < uppercase (matching V8's default ICU /// behavior where 'a' < 'A'). +/// +/// Both passes are allocation-free and short-circuit at the first difference; +/// see [`locale_primary_cmp`]. The ordering is deliberately unchanged from the +/// allocating formulation it replaced (#10094). fn locale_compare_default(a_str: &str, b_str: &str) -> f64 { - // Case-insensitive primary comparison - let a_lower = a_str.to_lowercase(); - let b_lower = b_str.to_lowercase(); - match a_lower.cmp(&b_lower) { + // Case-insensitive primary comparison. + let primary = match locale_primary_cmp(a_str, b_str) { + Some(ordering) => ordering, + None => { + // Final sigma reached before the answer was decided. Rare enough + // to be worth two allocations rather than a second, divergent copy + // of the Final_Sigma rule here. + let a_lower = a_str.to_lowercase(); + let b_lower = b_str.to_lowercase(); + a_lower.cmp(&b_lower) + } + }; + match primary { std::cmp::Ordering::Less => return -1.0, std::cmp::Ordering::Greater => return 1.0, std::cmp::Ordering::Equal => {} @@ -776,9 +918,38 @@ fn locale_compare_default(a_str: &str, b_str: &str) -> f64 { } } -/// String.prototype.localeCompare(other) — returns negative/zero/positive number. -/// We don't ship a true ICU collator, but canonical equivalence is a mandatory -/// part of the String.prototype.localeCompare contract. +/// `String.prototype.localeCompare(other)` — returns a negative number, zero, +/// or a positive number. +/// +/// **What this guarantees, and what it deliberately does not.** Perry ships no +/// collation table — no DUCET or CLDR root weights — and no locale tailoring: +/// the `locales` argument is accepted and ignored. The ordering is +/// *approximate by design*, not an unimplemented path. See #10094 and the +/// `localeCompare()` row of `docs/typescript-parity-gaps.md`, which record the +/// decision not to link ICU data (~27 MB) for this. What it does guarantee: +/// +/// 1. **Canonical equivalence.** Decomposed and precomposed spellings of the +/// same text compare equal — a mandatory part of the `localeCompare` +/// contract (see [`locale_compare_canonical`]). +/// 2. **Primary: case-insensitive *code point* order** — the order of the two +/// `toLowerCase` forms, including the contextual final-sigma rule. +/// 3. **Tertiary: case.** Strings that differ only in case order lowercase +/// first, matching the default Unicode tertiary weight (`'a' < 'A'`). +/// +/// Because the primary key is the code point rather than a collation weight, +/// the result differs from Node/ICU wherever root collation reorders the code +/// point space: accented letters sort after the whole unaccented alphabet +/// instead of beside their base letter (`ä` is U+00E4, above `z` at U+007A), +/// and symbols and emoji sort after letters instead of before them. So +/// `"ä".localeCompare("😀")` is negative here and positive in Node. Note that +/// the "correct" answer is locale-dependent even with a table — German sorts +/// `ä` with `a`, Swedish after `z` — which is part of why one untailored table +/// was not judged worth its bytes. +/// +/// The relation is nonetheless a strict weak ordering (in fact a total order +/// on distinct canonical forms), so `Array.prototype.sort` results are +/// well-defined; `locale_compare_is_a_strict_weak_ordering` proves it over a +/// mixed-script corpus. #[no_mangle] pub extern "C" fn js_string_locale_compare(a: *const StringHeader, b: *const StringHeader) -> f64 { let a_valid = is_valid_string_ptr(a); @@ -1275,6 +1446,348 @@ mod numeric_collation_tests { } } +/// #10094: the primary collation pass stopped materializing two lowercased +/// `String`s per comparison. These tests pin the two properties that makes +/// safe — the ordering is byte-for-byte what the allocating formulation +/// produced, and the relation is a strict weak ordering so +/// `Array.prototype.sort` stays well-defined. +#[cfg(test)] +mod locale_collation_tests { + use super::{locale_compare_canonical, locale_compare_default, locale_primary_cmp}; + use std::cmp::Ordering; + + /// The formulation this replaced, kept verbatim as the oracle: lowercase + /// both sides with `str::to_lowercase` and compare the results. If the + /// streaming walk ever disagrees with this, the ordering has moved. + fn reference_compare(a_str: &str, b_str: &str) -> f64 { + let a_lower = a_str.to_lowercase(); + let b_lower = b_str.to_lowercase(); + match a_lower.cmp(&b_lower) { + Ordering::Less => return -1.0, + Ordering::Greater => return 1.0, + Ordering::Equal => {} + } + let mut ai = a_str.chars(); + let mut bi = b_str.chars(); + loop { + match (ai.next(), bi.next()) { + (None, None) => return 0.0, + (None, Some(_)) => return -1.0, + (Some(_), None) => return 1.0, + (Some(ca), Some(cb)) => { + if ca == cb { + continue; + } + let a_lower = ca.is_lowercase(); + let b_lower = cb.is_lowercase(); + if a_lower && !b_lower { + return -1.0; + } + if !a_lower && b_lower { + return 1.0; + } + return if (ca as u32) < (cb as u32) { -1.0 } else { 1.0 }; + } + } + } + } + + /// A WTF-8 lone surrogate, which `string_as_str` hands the comparator as a + /// `&str` exactly like this. Not representable as a Rust string literal. + fn wtf8(bytes: &'static [u8]) -> &'static str { + unsafe { std::str::from_utf8_unchecked(bytes) } + } + + /// Spans every class the issue names: ASCII (incl. case-only and + /// long-common-prefix pairs), Latin-1 accented letters in both precomposed + /// and decomposed spellings, CJK, emoji, bare combining marks, the two + /// special case mappings (U+0130, U+03A3), and lone surrogates. + fn corpus() -> Vec<&'static str> { + vec![ + "", + "a", + "A", + "b", + "B", + "z", + "Z", + "ab", + "aB", + "Ab", + "AB", + "abc", + "abd", + "abcd", + "aBcDa", + "aBcDz", + "aBcDB", + "aBcD7", + "record-000000000001", + "record-000000000002", + "Record-000000000001", + "0", + "9", + " ", + "~", + "\u{7f}", + "ä", + "Ä", + "a\u{308}", + "A\u{308}", + "ö", + "Ö", + "o\u{308}", + "é", + "è", + "ß", + "\u{1e9e}", + "\u{308}", + "\u{323}", + "a\u{308}\u{323}", + "a\u{323}\u{308}", + "İ", + "i\u{307}", + "ı", + "I", + "i", + "Σ", + "σ", + "ς", + "ΣΑ", + "ΑΣ", + "ΟΔΟΣ", + "Οδος", + "οδος", + "漢", + "字", + "漢字", + "日本語", + "ä中😀Öa", + "ä中😀Öz", + "ä中😀ÖB", + "ä中😀Ö7", + "😀", + "🚀", + "\u{10ffff}", + "ä:123", + "Ö:123", + "😀:9", + wtf8(&[0xED, 0xA0, 0x80]), + wtf8(&[0xED, 0xB0, 0x80]), + wtf8(&[b'a', 0xED, 0xA0, 0x80]), + ] + } + + /// Behaviour preservation, the issue's first acceptance criterion: the + /// allocation-free walk must return exactly what the two-`to_lowercase` + /// formulation returned, on every ordered pair. The corpus must also reach + /// all three arms — the ASCII byte loop, the scalar walk, and the + /// contextual fallback — or a green run would prove nothing. + #[test] + fn matches_the_allocating_reference_on_every_pair() { + let corpus = corpus(); + let (mut ascii_arm, mut scalar_arm, mut contextual_arm) = (0usize, 0usize, 0usize); + for a in &corpus { + for b in &corpus { + assert_eq!( + locale_compare_default(a, b), + reference_compare(a, b), + "locale_compare_default({a:?}, {b:?})" + ); + if locale_primary_cmp(a, b).is_none() { + contextual_arm += 1; + } else if a.is_ascii() && b.is_ascii() { + ascii_arm += 1; + } else { + scalar_arm += 1; + } + } + } + assert!(ascii_arm > 0, "corpus never took the ASCII byte loop"); + assert!(scalar_arm > 0, "corpus never took the scalar walk"); + assert!( + contextual_arm > 0, + "corpus never reached the final-sigma fallback" + ); + } + + /// xorshift32, so a failure is reproducible from the seed alone. + fn xorshift(state: &mut u32) -> u32 { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + *state + } + + /// Randomized differential coverage of shapes the hand-written corpus does + /// not enumerate: mixed-script strings, shared prefixes of every length, + /// and case expansions landing at arbitrary offsets. + #[test] + fn matches_the_allocating_reference_on_random_strings() { + const ALPHABET: [&str; 16] = [ + "a", "B", "z", "7", "-", "ä", "Ö", "ß", "İ", "Σ", "ς", "\u{308}", "漢", "😀", "\u{7f}", + "i\u{307}", + ]; + let mut state: u32 = 0x1234_5678; + for _ in 0..20_000 { + let shared = xorshift(&mut state) % 6; + let mut prefix = String::new(); + for _ in 0..shared { + prefix.push_str(ALPHABET[(xorshift(&mut state) % 16) as usize]); + } + let mut pair = [prefix.clone(), prefix]; + for s in pair.iter_mut() { + let tail = xorshift(&mut state) % 5; + for _ in 0..tail { + s.push_str(ALPHABET[(xorshift(&mut state) % 16) as usize]); + } + } + let (a, b) = (&pair[0], &pair[1]); + assert_eq!( + locale_compare_default(a, b), + reference_compare(a, b), + "locale_compare_default({a:?}, {b:?})" + ); + } + } + + /// Sort-safety. `Array.prototype.sort` is only well-defined for a + /// consistent comparator, so an approximate ordering still has to be a + /// strict weak ordering: irreflexive-equal, antisymmetric, and transitive. + /// Checked through `locale_compare_canonical`, which is what + /// `js_string_locale_compare` actually calls. + #[test] + fn locale_compare_is_a_strict_weak_ordering() { + let corpus = corpus(); + let cmp = |a: &str, b: &str| { + let v = locale_compare_canonical(a, b, locale_compare_default); + if v < 0.0 { + Ordering::Less + } else if v > 0.0 { + Ordering::Greater + } else { + Ordering::Equal + } + }; + for a in &corpus { + assert_eq!(cmp(a, a), Ordering::Equal, "cmp({a:?}, itself)"); + for b in &corpus { + assert_eq!( + cmp(a, b), + cmp(b, a).reverse(), + "antisymmetry broken for ({a:?}, {b:?})" + ); + } + } + // Transitivity of both `<` and the equivalence it induces, over every + // triple. + for a in &corpus { + for b in &corpus { + let ab = cmp(a, b); + for c in &corpus { + let bc = cmp(b, c); + let ac = cmp(a, c); + if ab == Ordering::Equal && bc == Ordering::Equal { + assert_eq!( + ac, + Ordering::Equal, + "equivalence not transitive: {a:?} ~ {b:?} ~ {c:?}" + ); + } + if ab != Ordering::Greater && bc != Ordering::Greater { + assert_ne!( + ac, + Ordering::Greater, + "order not transitive: {a:?} <= {b:?} <= {c:?}" + ); + } + } + } + } + } + + /// The contract the doc comment now states out loud, spelled out as + /// assertions so a future rewrite has to face them. + #[test] + fn documented_guarantees_hold() { + let cmp = |a: &str, b: &str| locale_compare_canonical(a, b, locale_compare_default); + // Identical, empty, and prefix pairs. + assert_eq!(cmp("", ""), 0.0); + assert_eq!(cmp("abc", "abc"), 0.0); + assert_eq!(cmp("", "a"), -1.0); + assert_eq!(cmp("a", ""), 1.0); + assert_eq!(cmp("abc", "abcd"), -1.0); + assert_eq!(cmp("abcd", "abc"), 1.0); + // Differing only after a long common prefix. + let prefix = "x".repeat(512); + assert_eq!(cmp(&format!("{prefix}a"), &format!("{prefix}b")), -1.0); + assert_eq!(cmp(&format!("{prefix}b"), &format!("{prefix}a")), 1.0); + // Case-only differences: lowercase first (tertiary weight). + assert_eq!(cmp("a", "A"), -1.0); + assert_eq!(cmp("A", "a"), 1.0); + assert_eq!(cmp("aBc", "AbC"), -1.0); + assert_eq!(cmp("ä", "Ä"), -1.0); + // Primary beats tertiary: the letter class wins over case. + assert_eq!(cmp("B", "a"), 1.0); + assert_eq!(cmp("a", "B"), -1.0); + // The documented divergence from ICU, asserted rather than implied: + // code point order puts accented letters after `z` and emoji last. + assert_eq!(cmp("ä", "z"), 1.0); + assert_eq!(cmp("ä", "😀"), -1.0); + assert_eq!(cmp("Ö", "字"), -1.0); + } + + /// Canonical equivalence is a mandatory part of the contract, so it has to + /// survive the rewrite of the pass that runs after it. + #[cfg(feature = "string-normalize")] + #[test] + fn canonical_equivalents_stay_equal() { + for (a, b) in [ + ("o\u{308}", "ö"), + ("O\u{308}", "Ö"), + ("a\u{308}\u{323}", "a\u{323}\u{308}"), + ("\u{1111}\u{1171}\u{11b6}", "퓛"), + ("Å", "A\u{30a}"), + ("ä中😀Öa", "a\u{308}中😀O\u{308}a"), + ] { + assert_eq!( + locale_compare_canonical(a, b, locale_compare_default), + 0.0, + "{a:?} vs {b:?}" + ); + // …and the case tiebreak still applies across spellings. + let upper_a = a.to_uppercase(); + assert!( + locale_compare_canonical(a, &upper_a, locale_compare_default) <= 0.0, + "{a:?} vs {upper_a:?}" + ); + } + } + + /// Final sigma is the one mapping the streaming walk refuses to guess. + /// It must both report the fallback and get the answer right. + #[test] + fn final_sigma_falls_back_and_stays_correct() { + // "ΟΔΟΣ".to_lowercase() is "οδος" — word-final Σ becomes ς. + assert_eq!("ΟΔΟΣ".to_lowercase(), "οδος"); + assert!(locale_primary_cmp("ΟΔΟΣ", "οδος").is_none()); + assert_eq!(locale_compare_default("ΟΔΟΣ", "οδος"), 1.0); // case tiebreak + for (a, b) in [ + ("ΟΔΟΣ", "οδος"), + ("ΟΔΟΣ", "οδοσ"), + ("Σ", "σ"), + ("Σ", "ς"), + ("ΣΑ", "σα"), + ("aΣ", "aς"), + ] { + assert_eq!(locale_compare_default(a, b), reference_compare(a, b)); + assert_eq!(locale_compare_default(b, a), reference_compare(b, a)); + } + // A Σ *after* the deciding position must not force the fallback. + assert!(locale_primary_cmp("aΣ", "bΣ").is_some()); + } +} + #[cfg(test)] mod tests_sso_helpers { use super::*; diff --git a/docs/typescript-parity-gaps.md b/docs/typescript-parity-gaps.md index 788df20abf..bdb22ba73f 100644 --- a/docs/typescript-parity-gaps.md +++ b/docs/typescript-parity-gaps.md @@ -152,13 +152,30 @@ Everything the Perry compiler is missing for absolute parity with Node.js runnin | `fromCharCode` | ✓ | | `at()` | Missing | | `normalize()` | Listed but may not work | -| `localeCompare()` | Missing (needs Intl) | -| `toLocaleLowerCase()` / `toLocaleUpperCase()` | Missing (needs Intl) | +| `localeCompare()` | ✓ — implemented, approximate ordering (no collation table); see the note below | +| `toLocaleLowerCase()` / `toLocaleUpperCase()` | ✓ — BCP 47 `locales` validation plus `tr`/`az`/`lt` casing tailoring (#2781) | | `codePointAt()` | Missing | | `fromCodePoint()` | Missing | | `raw()` | Missing | | `isWellFormed()` / `toWellFormed()` | Missing | +**`localeCompare()` — implemented, with an ordering that is approximate by design.** +Both this row and the `toLocale{Lower,Upper}Case()` row above previously read "Missing +(needs Intl)"; both methods exist. `localeCompare` preserves canonical equivalence +(decomposed and precomposed spellings compare equal), then orders case-insensitively by +**code point**, with a case tiebreak that puts lowercase first. It ships **no collation +table** — no DUCET or CLDR root weights — and no locale tailoring; the `locales` +argument is accepted and ignored. So the order differs from Node/ICU wherever root +collation reorders the code point space: accented letters sort after the whole +unaccented alphabet rather than beside their base letter (`ä` is U+00E4, above `z`), and +symbols and emoji sort after letters rather than before them — `"ä".localeCompare("😀")` +is negative here and positive in Node. That is an accepted limitation for the same +reason the rest of `Intl` is deferred ("Full `Intl` — requires ICU data (~27MB)", +further down this page), not an unimplemented path — and one untailored table would not +settle it anyway, since German sorts `ä` with `a` and Swedish sorts it after `z`. The +relation is a strict weak ordering, so `Array.prototype.sort` results are well-defined. +Tracked in [#10094](https://github.com/PerryTS/perry/issues/10094). + ### Object | Method | Status | From 4889133b0c9880feda7376d26f4f2c0cf6185f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 07:58:44 +0200 Subject: [PATCH 5/9] changelog: fragment for #10111 (localeCompare allocations + parity doc) (cherry picked from commit 759302d21af3ed00b5ea2ad09b16d581e75cb344) --- .../10111-locale-compare-allocations.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 changelog.d/10111-locale-compare-allocations.md diff --git a/changelog.d/10111-locale-compare-allocations.md b/changelog.d/10111-locale-compare-allocations.md new file mode 100644 index 0000000000..1ba8a66c71 --- /dev/null +++ b/changelog.d/10111-locale-compare-allocations.md @@ -0,0 +1,66 @@ +`String.prototype.localeCompare` no longer allocates on a comparison (#10094). +The primary (case-insensitive) pass built two fresh lowercased `String`s with +`str::to_lowercase` and compared those, so every comparison paid two heap +allocations and two full Unicode case-mapping passes over both operands to +answer a question that is usually decided by the first character — and a sort +pays that O(n log n) times. It now walks the two lowercased scalar streams in +lockstep and stops at the first difference, with a byte-level loop for the +leading all-ASCII run. On the non-ASCII path, `locale_compare_canonical` also +stops materializing two NFC `String`s per comparison: an `is_nfc_quick` check +(a table lookup per scalar, no allocation) skips the rewrite for text that is +already NFC, which covers precomposed letters, CJK and emoji. + +**The ordering is deliberately unchanged.** Perry ships no collation table and +no locale tailoring, and that stays true here — #10094 records the decision. +U+03A3 is the one scalar whose lowercase mapping is context-dependent (final +sigma `ς` vs medial `σ`); the walk reports it rather than guessing, and falls +back to `str::to_lowercase`, which implements the Final_Sigma rule. + +Tests pin both halves of that claim. +`matches_the_allocating_reference_{on_every_pair,on_random_strings}` are +differential against the exact `to_lowercase`-materializing formulation this +replaces, over a corpus spanning ASCII, Latin-1 accented letters in both +spellings, CJK, emoji, combining marks, the two special case mappings and +WTF-8 lone surrogates, and assert the corpus reaches all three arms. +`locale_compare_is_a_strict_weak_ordering` proves reflexivity, antisymmetry and +transitivity over every triple, so `Array.prototype.sort` stays well-defined. +`canonical_equivalents_stay_equal` and `documented_guarantees_hold` cover +canonical equivalence, case-only differences, empty/prefix/long-common-prefix +pairs, and the documented divergence from ICU. + +Measured on a quiet M1 mini (load ~1.7) against Node v26.5.1, base and fixed +runtimes built from the same tree with the same compiler package set, the two +arms and Node interleaved, three rounds per point: + +| workload | n | base | fixed | +|---|---:|---:|---:| +| `sort-objects-locale-key-unicode` | 100 | 2.61× Node | **0.79× Node** | +| `sort-objects-locale-key-unicode` | 10,000 | 2.86× | **0.82×** | +| `sort-objects-locale-key-unicode` | 100,000 | 2.92× | **0.85×** | +| `sort-objects-locale-key-unicode` | 1,000,000 | TIMEOUT (>60 s; 7.26 s/run uncapped) | **0.98× (2.62 s)** | +| `string-locale-compare-unicode` | 1,000,000 | 6.80× | **4.54×** | +| `string-locale-compare-ascii` | 1,000,000 | 8.17× | **7.77×** | + +Perry-side checksums are byte-identical to the base runtime at every size, +including at n=1,000,000 where the base only completes with the harness +timeout lifted, and including the sort checksums that differ from Node's by +design. + +The `string-locale-compare-ascii` row barely moves because its remaining cost +is not the comparison. It calls `localeCompare(other, 'en-US')`, and a +`locales` argument makes codegen emit `js_string_validate_collator_args` on +every call, which re-runs `CanonicalizeLocaleList` plus the +`InitializeCollator` option reads. Dropping just that argument from the same +workload takes the base runtime from 290.3 ms to 61.0 ms at n=1,000,000, so +~79% of that benchmark is spec-side-effect validation rather than collation. +With the argument gone, the comparator's own gain is visible: 1.71× → 1.42× +Node on ASCII and 3.77× → 1.83× Node on Unicode. + +Also corrects `docs/typescript-parity-gaps.md`, which listed both +`localeCompare()` and `toLocaleLowerCase()`/`toLocaleUpperCase()` as "Missing +(needs Intl)". All three are implemented. The new note, and the rewritten +`js_string_locale_compare` doc comment, state what `localeCompare` actually +guarantees — canonical equivalence, case-insensitive code point order, a +lowercase-first case tiebreak — and what it does not: no collation weights, no +locale tailoring, and an order that differs from Node for accented letters, +symbols and emoji, by design rather than by omission. From ba1a1cb43843aa260d4c03d49492b7f69b13ab4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 08:31:46 +0200 Subject: [PATCH 6/9] test(string): drop WTF-8 lone surrogates from the locale-compare corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus forged them with `from_utf8_unchecked`, because a lone surrogate has no valid `&str` spelling and `locale_compare_default` takes `&str`. `chars()` over such a slice yields a value that is not a valid `char`, and std's UB precondition check catches that in a debug build: CI's `cargo-test` job (debug profile) aborted with SIGABRT instead of reporting an ordering. A `--release` run does not enable the check, which is why this was green locally. That is a property of the runtime's WTF-8-as-`&str` view (`string_as_str`), unchanged by this PR and identical on both sides of the differential, so the entries could only ever have proven the checker works. The sound byte-level lone-surrogate coverage stays where its helper takes `&[u8]`: `utf16_cmp_ascii_fast_path_tests::lone_surrogates_fall_back_to_byte_order`. The corpus doc comment now says so. Verified on the debug profile CI actually uses: `cargo test -p perry-runtime` → 3606 passed, 0 failed, 4 ignored. (cherry picked from commit 6e0b8dee61356b8a5e429cc9121f3e64c78b0113) --- crates/perry-runtime/src/string/compare.rs | 29 ++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index 5bbed33365..e0edb0e007 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -1492,16 +1492,22 @@ mod locale_collation_tests { } } - /// A WTF-8 lone surrogate, which `string_as_str` hands the comparator as a - /// `&str` exactly like this. Not representable as a Rust string literal. - fn wtf8(bytes: &'static [u8]) -> &'static str { - unsafe { std::str::from_utf8_unchecked(bytes) } - } - - /// Spans every class the issue names: ASCII (incl. case-only and - /// long-common-prefix pairs), Latin-1 accented letters in both precomposed - /// and decomposed spellings, CJK, emoji, bare combining marks, the two - /// special case mappings (U+0130, U+03A3), and lone surrogates. + /// Spans every class the issue names *except* WTF-8 lone surrogates: + /// ASCII (incl. case-only and long-common-prefix pairs), Latin-1 accented + /// letters in both precomposed and decomposed spellings, CJK, emoji, bare + /// combining marks, and the two special case mappings (U+0130, U+03A3). + /// + /// Lone surrogates are deliberately absent. This comparator takes `&str`, + /// and a lone surrogate is not representable as one: forging it with + /// `from_utf8_unchecked` makes `chars()` yield a value that is not a valid + /// `char`, which std's UB precondition check catches in a debug build — + /// the test aborts with SIGABRT rather than reporting an ordering. That is + /// a property of the runtime's WTF-8-as-`&str` view (`string_as_str`), + /// unchanged by this rewrite and identical on both sides of the + /// differential, so a corpus entry could only prove the checker works. The + /// byte-level lone-surrogate coverage that *is* sound lives in + /// `utf16_cmp_ascii_fast_path_tests::lone_surrogates_fall_back_to_byte_order`, + /// where the helper takes `&[u8]`. fn corpus() -> Vec<&'static str> { vec![ "", @@ -1572,9 +1578,6 @@ mod locale_collation_tests { "ä:123", "Ö:123", "😀:9", - wtf8(&[0xED, 0xA0, 0x80]), - wtf8(&[0xED, 0xB0, 0x80]), - wtf8(&[b'a', 0xED, 0xA0, 0x80]), ] } From adb5e29e6100dd382bbe02a7a64f95d3edf67839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 07:06:02 +0200 Subject: [PATCH 7/9] perf(hir): skip the iterator protocol for proven-array destructuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[x, y] = [y, x]` and `const [a, b] = pair(i)` lowered through the full spec iterator protocol — `GetIterator`, one `iteratorNextResult` per element (each allocating a `{ value, done }` result object), two property reads off that result, and `IteratorClose` — none of which is observable for an array whose iteration protocol is the pristine builtin. #10086 measured a flat 76x Node for the swap and 78x for the destructured return. The lowering now emits both arms and branches on the same runtime guard `for…of` over a proven array uses (`Expr::ArrayIterationPatched`). A spread-free array literal written in place has its elements spilled into temps before the guard and read straight from them, so the array is never built; a source whose static type proves a plain Array is read by index with `length` re-read per element, exactly as `IteratorStep` does. The branch is per element, not around the whole pattern: a pattern DECLARES bindings, so lowering it twice would give each binding two `LocalId`s, and both arms have to keep the spec's interleaving (`let [a = f(), b] = src` evaluates `f()` between producing element 0 and element 1). A rest element, a nested pattern, an empty pattern, a generator / Set / Map / string source and anything without a static array proof keep the unguarded iterator lowering, statement-for-statement identical to before. The guard itself had a hole, which this closes: a replaced `%ArrayIteratorPrototype%.next` is detected per `.next()` call, so an arm that never calls `.next()` could not see it — `for…of` over a proven array has iterated unpatched elements since #7760. The exported byte (renamed `PERRY_ARRAY_ITERATION_NOT_PRISTINE`) is now also set when the array-iterator prototype object escapes to user code through `Object.getPrototypeOf` / `Reflect.getPrototypeOf`, the only way to name it in order to patch it. Setting it on escape rather than on the write is deliberate: the object is ordinary, so a precise hook would have to cover every mutation funnel and missing one fails silently toward a wrong answer. `ARRAY_PROTO_ITERATOR_MODIFIED` keeps its original narrow meaning, so the spread and `js_get_iterator` paths are unchanged. (cherry picked from commit 9ad197404a218d330ceb4ba9f0b4b685a7e5cf68) --- .../10112-array-destructuring-fast-path.md | 62 ++++ .../perry-codegen/src/expr/literals_vars.rs | 2 +- .../src/runtime_decls/objects.rs | 2 +- .../perry-hir/src/destructuring/array_fast.rs | 330 ++++++++++++++++++ .../src/destructuring/assignment_stmt.rs | 41 ++- crates/perry-hir/src/destructuring/mod.rs | 5 +- .../src/destructuring/pattern_binding.rs | 53 ++- .../perry-hir/src/destructuring/var_decl.rs | 49 ++- crates/perry-hir/src/ir/expr.rs | 19 +- .../tests/array_destructuring_fast_path.rs | 156 +++++++++ .../src/array/indexing_support.rs | 53 ++- crates/perry-runtime/src/array/mod.rs | 5 +- .../src/object/iterator_prototypes.rs | 34 ++ .../src/object/object_ops/prototype.rs | 12 + ...gap_10086_array_destructuring_fast_path.ts | 318 +++++++++++++++++ 15 files changed, 1086 insertions(+), 55 deletions(-) create mode 100644 changelog.d/10112-array-destructuring-fast-path.md create mode 100644 crates/perry-hir/src/destructuring/array_fast.rs create mode 100644 crates/perry-hir/tests/array_destructuring_fast_path.rs create mode 100644 test-files/test_gap_10086_array_destructuring_fast_path.ts diff --git a/changelog.d/10112-array-destructuring-fast-path.md b/changelog.d/10112-array-destructuring-fast-path.md new file mode 100644 index 0000000000..9699fd341d --- /dev/null +++ b/changelog.d/10112-array-destructuring-fast-path.md @@ -0,0 +1,62 @@ +### Performance + +Array destructuring no longer drives the spec iterator protocol when the source +is a spread-free array literal written in place or a value whose static type +proves a plain `Array`. Both `[x, y] = [y, x]` and `const [a, b] = pair(i)` +previously paid a `GetIterator` call, one `iteratorNextResult` per element — +each allocating a `{ value, done }` result object — two property reads off that +result, and an `IteratorClose`, none of which is observable for an array whose +`Array.prototype[Symbol.iterator]` has not been replaced. + +The lowering now emits both arms and branches on the same runtime guard that +`for…of` over a proven array uses (`Expr::ArrayIterationPatched`, a volatile +read of the runtime's sticky `PERRY_ARRAY_PROTO_ITERATOR_PATCHED` byte); no +runtime change was needed. For a literal source the fast arm spills the +literal's elements into temps and never builds the array at all, so the swap +loop's one GC allocation per iteration disappears — a 10,000,000-iteration +`[a, b] = [b, a]` loop runs 7,952 collections before the change and 6 after, +the same 6 it runs at 10,000 iterations. For a proven array it reads elements +by index, re-reading `length` per element exactly as `IteratorStep` does. + +The branch is per element rather than around the whole pattern. A pattern +DECLARES bindings, so lowering it twice would give each binding two `LocalId`s; +per-element branching also preserves the spec's interleaving, which an eager +"pull N values, then bind" arm would lose — `let [a = f(), b] = src` still +evaluates `f()` between producing element 0 and element 1. + +Measured against Node 26.5.1 on an Apple M1 Max (host under heavy concurrent +build load, so the absolute times are inflated; checksums matched at every +size): + +| workload | n | before | after | speedup | before ÷ node | after ÷ node | +|---|---:|---:|---:|---:|---:|---:| +| `iteration-destructuring-swap` | 1,000 | 2.393 ms | 0.057 ms | 42.2x | 81.8x | 1.94x | +| `iteration-destructuring-swap` | 100,000 | 334.6 ms | 7.38 ms | 45.3x | 104.1x | 2.30x | +| `iteration-destructuring-swap` | 1,000,000 | 3071.9 ms | 105.0 ms | 29.3x | 102.6x | 3.51x | +| `iteration-destructure-return` | 1,000 | 5.199 ms | 0.076 ms | 68.2x | 101.0x | 1.48x | +| `iteration-destructure-return` | 100,000 | 457.0 ms | 7.70 ms | 59.3x | 61.5x | 1.04x | +| `iteration-destructure-return` | 1,000,000 | 4673.5 ms | 191.3 ms | 24.4x | 90.7x | 3.71x | + +A rest element, a nested pattern, an empty pattern, a generator, a `Set` / `Map` +/ string and any source without a static array proof keep the unguarded iterator +lowering, statement-for-statement identical to before. + +### Fixed + +A `for…of` over a statically-proven array ignored a replaced +`%ArrayIteratorPrototype%.next`, iterating the unpatched elements where Node +iterates the patched ones — the half of the iteration protocol #7760's guard did +not cover, because the runtime detects a replaced `next` per `.next()` call and +an index loop never calls it. + +The exported guard byte (renamed `PERRY_ARRAY_ITERATION_NOT_PRISTINE`, since it +no longer means only "`Array.prototype[Symbol.iterator]` was replaced") is now +also set when the array-iterator prototype object escapes to user code through +`Object.getPrototypeOf` / `Reflect.getPrototypeOf` — the only way to name that +object in order to patch it. Setting it on escape rather than on the write is +deliberate: the object is an ordinary object, so a precise hook would have to +cover every mutation funnel and missing one fails silently toward a wrong +answer, whereas over-approximating costs the index arm only in programs that +introspect an array iterator. The Rust-side `ARRAY_PROTO_ITERATOR_MODIFIED` bool +keeps its original narrow meaning, so the spread and `js_get_iterator` +delegation paths are unchanged. diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index a1d125ec12..007ac9c00b 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -1208,7 +1208,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // predictable branch PER LOOP, never per iteration. Expr::ArrayIterationPatched => { let blk = ctx.block(); - let flag = blk.load_volatile(crate::types::I8, "@PERRY_ARRAY_PROTO_ITERATOR_PATCHED"); + let flag = blk.load_volatile(crate::types::I8, "@PERRY_ARRAY_ITERATION_NOT_PRISTINE"); let widened = blk.zext(crate::types::I8, &flag, crate::types::I32); Ok(super::i32_bool_to_nanbox(blk, &widened)) } diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 52f88598c1..686f8abf6b 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -58,7 +58,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // byte directly in the inline plain-array index guard. module.add_external_global("PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED", I8); // #7760: set when `Array.prototype[Symbol.iterator]` is replaced. - module.add_external_global("PERRY_ARRAY_PROTO_ITERATOR_PATCHED", I8); + module.add_external_global("PERRY_ARRAY_ITERATION_NOT_PRISTINE", I8); // Process-wide count of threads with an active incremental marking // barrier. Persistent shadow-slot updates use zero as an authoritative // fast skip before calling the TLS-backed root barrier. diff --git a/crates/perry-hir/src/destructuring/array_fast.rs b/crates/perry-hir/src/destructuring/array_fast.rs new file mode 100644 index 0000000000..be25cb093e --- /dev/null +++ b/crates/perry-hir/src/destructuring/array_fast.rs @@ -0,0 +1,330 @@ +//! #10086: the non-iterator arm of array destructuring. +//! +//! Both destructuring entry points (`[a, b] = rhs` as a statement and +//! `const [a, b] = rhs`) lower an array pattern through the full spec iterator +//! protocol: `GetIterator`, one `iteratorNextResult` per element (each of which +//! allocates a `{ value, done }` result object), two property reads off that +//! result, and `IteratorClose`. For a plain array that is the whole cost — the +//! measured swap (`[x, y] = [y, x]`) spent 76x Node on machinery that is, for an +//! unpatched `Array.prototype[Symbol.iterator]`, entirely unobservable. +//! +//! The patch is a RUNTIME fact and the choice of lowering is a COMPILE-TIME one, +//! so — exactly as #7760 item 1 did for `for…of` over a proven array — the only +//! correct answer is to emit both and branch on [`Expr::ArrayIterationPatched`], +//! the volatile `i8` read of the runtime's sticky +//! `PERRY_ARRAY_ITERATION_NOT_PRISTINE` byte. +//! +//! Two properties shaped this differently from the `for…of` guard: +//! +//! * The branch is PER ELEMENT, not around the whole pattern. A destructuring +//! pattern DECLARES bindings (`const [a, b] = …`); lowering the pattern +//! twice would allocate two different `LocalId`s for `a`, and every use +//! after the `if` would resolve to whichever arm was lowered last. +//! Branching only where the element VALUE is produced keeps one binding per +//! leaf. +//! * Branching per element is also what keeps the lowering spec-exact. Both +//! arms interleave element production with the pattern's own work, so +//! `let [a = f(), b] = src` still evaluates `f()` between producing element +//! 0 and producing element 1 — an eager "pull N values, then bind" fast arm +//! would not. +//! +//! The iterator arm is what the pre-existing lowering emitted apart from +//! `GetIterator` moving behind the guard, so a patched iterator behaves exactly +//! as it did before. +//! +//! The guard covers BOTH ways array iteration stops being the builtin protocol. +//! A replaced or deleted `Array.prototype[Symbol.iterator]` already set that +//! byte (#7760). A replaced `%ArrayIteratorPrototype%.next` did not: the runtime +//! detects it per `.next()` call, which an arm that never calls `.next()` cannot +//! observe. #10086 also publishes the byte when the array-iterator prototype +//! object escapes to user code — the only way to name it in order to patch it — +//! so both arms decline. That closed the same hole in the `for…of` index loop, +//! which had it since #7760. Spread keeps its own Rust-side proof +//! (`array::dense_spread_source`) and is unchanged. +//! +//! Element production on the fast arm comes in two shapes ([`FastElements`]): +//! an index read for a statically-proven array, and — when the source is a +//! spread-free array literal written in place — the literal's already-spilled +//! element temps, which removes the array ALLOCATION as well (the swap's one GC +//! allocation per iteration). + +use super::*; + +/// How one array pattern reaches its elements. +pub(crate) enum ArraySource { + /// No proof that the source is a plain array — drive the spec iterator + /// protocol on this expression, unguarded. What every array pattern did + /// before #10086, and what a nested pattern, a rest element or an unproven + /// source still does. + Iterator(Expr), + /// The source admits the non-iterator arm: [`FastPlan`] carries both the + /// runtime guard and the fast element source. + Guarded(FastPlan), +} + +impl ArraySource { + /// The initializer for the pattern's iterator local. + pub(crate) fn iter_init(&self) -> Expr { + match self { + ArraySource::Iterator(source) => Expr::GetIterator(Box::new(source.clone())), + ArraySource::Guarded(plan) => plan.guarded_get_iterator(), + } + } + + /// Produce element `idx` into `value_id`. `iter_pull` is the caller's + /// existing iterator-step sequence, used verbatim on the protocol arm. + pub(crate) fn pull(&self, idx: usize, value_id: LocalId, iter_pull: Vec) -> Vec { + match self { + ArraySource::Iterator(_) => iter_pull, + ArraySource::Guarded(plan) => vec![plan.guarded_pull(idx, value_id, iter_pull)], + } + } + + /// `IteratorClose`, run only where an iterator was actually created. + pub(crate) fn close(&self, close: Stmt) -> Stmt { + match self { + ArraySource::Iterator(_) => close, + ArraySource::Guarded(plan) => plan.guarded_close(close), + } + } +} + +/// Where the fast arm reads element `i` from. +pub(crate) enum FastElements { + /// A local holding a statically-proven plain Array. Element `i` is + /// `i < src.length ? src[i] : undefined` — the bounds test is what makes an + /// out-of-range element `undefined` (so a destructuring default still + /// fires) instead of whatever the backing store answers past its end. + /// `length` is re-read per element because the spec's `IteratorStep` does: + /// a default initializer that truncates the array must be visible to the + /// next element. + Indexed(LocalId), + /// One local per element of a spread-free array literal, spilled in source + /// order BEFORE the guard (so each element expression is evaluated exactly + /// once, whichever arm runs). Element `i` past the end is `undefined`, the + /// value the iterator would have produced once exhausted. + Scalars(Vec), +} + +/// The guarded-pull plan for one array pattern. +pub(crate) struct FastPlan { + /// Boolean local holding the once-evaluated [`Expr::ArrayIterationPatched`]. + /// Read once per element; the branch is loop-invariant and perfectly + /// predicted. + use_iter: LocalId, + /// The expression the iterator arm calls `GetIterator` on. Evaluated ONLY on + /// that arm — for the `Scalars` shape it is the array literal rebuilt from + /// the spilled temps, so the fast arm allocates no array at all. + iter_source: Expr, + elements: FastElements, +} + +impl FastPlan { + /// The fast arm's value for element `idx`. + fn value(&self, idx: usize) -> Expr { + match &self.elements { + FastElements::Scalars(temps) => match temps.get(idx) { + Some(id) => Expr::LocalGet(*id), + None => Expr::Undefined, + }, + FastElements::Indexed(src) => Expr::Conditional { + condition: Box::new(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::Number(idx as f64)), + right: Box::new(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::LocalGet(*src)), + property: "length".to_string(), + }), + }), + then_expr: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(*src)), + index: Box::new(Expr::Number(idx as f64)), + }), + else_expr: Box::new(Expr::Undefined), + }, + } + } + + /// `__use ? GetIterator() : undefined` — the iterator (and, for the + /// literal shape, the array it iterates) is materialized only when the + /// protocol is actually patched. A `Conditional` rather than a mutable + /// local written inside an `if` keeps the iterator binding an immutable + /// `Let`, the same shape the unguarded lowering emitted. + pub(crate) fn guarded_get_iterator(&self) -> Expr { + Expr::Conditional { + condition: Box::new(Expr::LocalGet(self.use_iter)), + then_expr: Box::new(Expr::GetIterator(Box::new(self.iter_source.clone()))), + else_expr: Box::new(Expr::Undefined), + } + } + + /// `if (__use) { } else { value = }`. + /// `iter_pull` is the caller's existing per-element iterator sequence, + /// unchanged. + pub(crate) fn guarded_pull(&self, idx: usize, value_id: LocalId, iter_pull: Vec) -> Stmt { + Stmt::If { + condition: Expr::LocalGet(self.use_iter), + then_branch: iter_pull, + else_branch: Some(vec![Stmt::Expr(Expr::LocalSet( + value_id, + Box::new(self.value(idx)), + ))]), + } + } + + /// `if (__use) { }` — there is nothing to close on the fast + /// arm, which never created an iterator. + pub(crate) fn guarded_close(&self, close: Stmt) -> Stmt { + Stmt::If { + condition: Expr::LocalGet(self.use_iter), + then_branch: vec![close], + else_branch: None, + } + } +} + +fn fresh(ctx: &mut LoweringContext, ty: Type) -> (LocalId, String) { + let id = ctx.fresh_local(); + let name = format!("__destruct_fast_{}", id); + ctx.locals.push((name.clone(), id, ty)); + (id, name) +} + +fn push_guard(ctx: &mut LoweringContext, out: &mut Vec) -> LocalId { + let (id, name) = fresh(ctx, Type::Boolean); + out.push(Stmt::Let { + id, + name, + ty: Type::Boolean, + mutable: false, + init: Some(Expr::ArrayIterationPatched), + }); + id +} + +/// Plan for a spread-free array-literal source: spill every element into a temp +/// in source order (evaluated exactly once, on either arm), then read the guard. +pub(crate) fn plan_for_literal( + ctx: &mut LoweringContext, + elems: &[&ast::Expr], + out: &mut Vec, +) -> Result { + let mut temps = Vec::with_capacity(elems.len()); + for elem in elems { + let value = lower_expr(ctx, elem)?; + let (id, name) = fresh(ctx, Type::Any); + out.push(Stmt::Let { + id, + name, + ty: Type::Any, + mutable: false, + init: Some(value), + }); + temps.push(id); + } + let use_iter = push_guard(ctx, out); + Ok(FastPlan { + use_iter, + iter_source: Expr::Array(temps.iter().map(|id| Expr::LocalGet(*id)).collect()), + elements: FastElements::Scalars(temps), + }) +} + +/// Plan for a source whose static type proves a plain Array: spill it into one +/// local that BOTH arms read, then read the guard. +pub(crate) fn plan_for_proven_array( + ctx: &mut LoweringContext, + source: Expr, + out: &mut Vec, +) -> FastPlan { + // `Array(Any)` (not `Any`): an `Any`-typed temp routes element reads through + // the typed-element fast path, which answers a miss with `0` rather than + // `undefined` and so breaks destructuring defaults. Same choice, for the + // same reason, as `emit_for_of_pattern_binding`'s temp. + let ty = Type::Array(Box::new(Type::Any)); + let (src_id, src_name) = fresh(ctx, ty.clone()); + out.push(Stmt::Let { + id: src_id, + name: src_name, + ty, + mutable: false, + init: Some(source), + }); + let use_iter = push_guard(ctx, out); + FastPlan { + use_iter, + iter_source: Expr::LocalGet(src_id), + elements: FastElements::Indexed(src_id), + } +} + +/// A spread-free array literal's element expressions, or `None` for anything +/// else. Holes are rejected: a hole is a genuinely ABSENT index whose read walks +/// the prototype chain, which substituting `undefined` would not do. +pub(crate) fn spread_free_array_literal(expr: &ast::Expr) -> Option> { + let ast::Expr::Array(arr) = expr else { + return None; + }; + let mut out = Vec::with_capacity(arr.elems.len()); + for elem in &arr.elems { + let elem = elem.as_ref()?; + if elem.spread.is_some() { + return None; + } + out.push(elem.expr.as_ref()); + } + Some(out) +} + +/// Does `expr`'s static type prove a plain Array? The same predicate the +/// `for…of` desugar uses to decide it may read `.length` / `[i]` directly +/// (`stmt_loops.rs`'s `proven_array`). +pub(crate) fn proven_array(ctx: &LoweringContext, expr: &ast::Expr) -> bool { + match infer_type_from_expr(expr, ctx) { + Type::Array(_) => true, + Type::Generic { base, .. } => base == "Array", + _ => false, + } +} + +/// Can every element of this pattern be produced by index? +/// +/// A rest element cannot: draining the remainder through the iterator builds a +/// DENSE array, while the obvious index-side equivalent (`slice`) preserves +/// holes, so the two disagree on a sparse source. A pattern with a rest element +/// keeps the unguarded iterator lowering. +pub(crate) fn pattern_admits_index_reads(elems: &[Option]) -> bool { + !elems.iter().any(|e| matches!(e, Some(ast::Pat::Rest(_)))) +} + +/// #10086: build the guarded non-iterator plan for a destructuring source, or +/// `None` when this pattern/source pair keeps the plain iterator lowering. +/// Returns the setup statements the plan depends on (the literal's element +/// spills or the source spill, plus the guard read) alongside it. +/// +/// `source` is lowered ONLY on the paths that return `Some`, so a caller that +/// falls back still lowers it exactly once. +pub(crate) fn plan_for_source( + ctx: &mut LoweringContext, + elems: &[Option], + source: &ast::Expr, +) -> Result, FastPlan)>> { + // An empty pattern (`[] = x`) reads no element, so the fast arm would touch + // the source not at all — and `GetIterator` is the only thing that makes + // `[] = ` throw. Keep the protocol so it still does. + if elems.is_empty() || !pattern_admits_index_reads(elems) { + return Ok(None); + } + let mut setup = Vec::new(); + if let Some(literal) = spread_free_array_literal(source) { + let plan = plan_for_literal(ctx, &literal, &mut setup)?; + return Ok(Some((setup, plan))); + } + if proven_array(ctx, source) { + let lowered = lower_expr(ctx, source)?; + let plan = plan_for_proven_array(ctx, lowered, &mut setup); + return Ok(Some((setup, plan))); + } + Ok(None) +} diff --git a/crates/perry-hir/src/destructuring/assignment_stmt.rs b/crates/perry-hir/src/destructuring/assignment_stmt.rs index 742a4dfe70..31ff39dd5a 100644 --- a/crates/perry-hir/src/destructuring/assignment_stmt.rs +++ b/crates/perry-hir/src/destructuring/assignment_stmt.rs @@ -1,5 +1,6 @@ //! Lowering of destructuring assignment statements (e.g. `[a, b] = expr` as a statement). +use super::array_fast::{self, ArraySource}; use super::*; #[derive(Clone)] @@ -47,6 +48,18 @@ pub(crate) fn lower_destructuring_assignment_stmt( pat: &ast::AssignTargetPat, rhs: &ast::Expr, ) -> Result> { + // #10086: `[x, y] = [y, x]` and `[a, b] = ` do not need the + // iterator protocol unless `Array.prototype[Symbol.iterator]` is patched. + if let ast::AssignTargetPat::Array(arr_pat) = pat { + if let Some((mut result, plan)) = array_fast::plan_for_source(ctx, &arr_pat.elems, rhs)? { + result.extend(lower_array_assignment_from_expr( + ctx, + arr_pat, + ArraySource::Guarded(plan), + )?); + return Ok(result); + } + } let rhs_expr = lower_expr(ctx, rhs)?; let (tmp_id, tmp_name) = fresh_destruct_local(ctx, "destruct", Type::Any); @@ -71,9 +84,11 @@ pub(crate) fn lower_destructuring_assignment_stmt_from_local( source_id: LocalId, ) -> Result> { match pat { - ast::AssignTargetPat::Array(arr_pat) => { - lower_array_assignment_from_expr(ctx, arr_pat, Expr::LocalGet(source_id)) - } + ast::AssignTargetPat::Array(arr_pat) => lower_array_assignment_from_expr( + ctx, + arr_pat, + ArraySource::Iterator(Expr::LocalGet(source_id)), + ), ast::AssignTargetPat::Object(obj_pat) => { lower_object_assignment_from_expr(ctx, obj_pat, Expr::LocalGet(source_id)) } @@ -84,7 +99,7 @@ pub(crate) fn lower_destructuring_assignment_stmt_from_local( fn lower_array_assignment_from_expr( ctx: &mut LoweringContext, arr_pat: &ast::ArrayPat, - source: Expr, + source: ArraySource, ) -> Result> { let (iter_id, iter_name) = fresh_destruct_local(ctx, "destruct_iter", Type::Any); let (done_id, done_name) = fresh_destruct_local(ctx, "destruct_done", Type::Boolean); @@ -95,7 +110,7 @@ fn lower_array_assignment_from_expr( name: iter_name, ty: Type::Any, mutable: false, - init: Some(Expr::GetIterator(Box::new(source))), + init: Some(source.iter_init()), }, Stmt::Let { id: done_id, @@ -107,7 +122,7 @@ fn lower_array_assignment_from_expr( ]; let mut body = Vec::new(); - for elem in &arr_pat.elems { + for (idx, elem) in arr_pat.elems.iter().enumerate() { if let Some(ast::Pat::Rest(rest_pat)) = elem { // AssignmentRestElement evaluates its target and drains every // remaining iterator value into a fresh Array, then performs the @@ -152,18 +167,20 @@ fn lower_array_assignment_from_expr( if let Some(elem_pat) = elem { let (prepare, target, default_value) = prepare_target_with_default(ctx, elem_pat)?; body.extend(prepare); - body.extend(iterator_next_value_stmts(ctx, iter_id, done_id, value_id)); + let pull = iterator_next_value_stmts(ctx, iter_id, done_id, value_id); + body.extend(source.pull(idx, value_id, pull)); let assigned = value_with_default(ctx, Expr::LocalGet(value_id), default_value)?; body.extend(assign_prepared_target(ctx, target, assigned)?); } else { - body.extend(iterator_next_value_stmts(ctx, iter_id, done_id, value_id)); + let pull = iterator_next_value_stmts(ctx, iter_id, done_id, value_id); + body.extend(source.pull(idx, value_id, pull)); } } - let close_stmt = Stmt::Expr(runtime_iterator_call( + let close_stmt = source.close(Stmt::Expr(runtime_iterator_call( "iteratorCloseIfNotDone", vec![Expr::LocalGet(iter_id), Expr::LocalGet(done_id)], - )); + ))); let (exc_id, exc_name) = fresh_destruct_local(ctx, "destruct_error", Type::Any); result.push(Stmt::Try { body, @@ -552,7 +569,9 @@ fn assign_prepared_target( receiver: Box::new(object), strict: ctx.current_strict, })]), - PreparedTarget::Array(arr) => lower_array_assignment_from_expr(ctx, &arr, value), + PreparedTarget::Array(arr) => { + lower_array_assignment_from_expr(ctx, &arr, ArraySource::Iterator(value)) + } PreparedTarget::Object(obj) => lower_object_assignment_from_expr(ctx, &obj, value), PreparedTarget::Skip => Ok(Vec::new()), } diff --git a/crates/perry-hir/src/destructuring/mod.rs b/crates/perry-hir/src/destructuring/mod.rs index 550a15e43b..2b8e7671d1 100644 --- a/crates/perry-hir/src/destructuring/mod.rs +++ b/crates/perry-hir/src/destructuring/mod.rs @@ -4,6 +4,8 @@ //! declarations with destructuring patterns. //! //! Organized into topical sub-modules: +//! - [`array_fast`] — #10086: the guarded non-iterator arm shared by the +//! assignment and declaration array-pattern lowerings. //! - [`helpers`] — small utility predicates / pattern recognizers shared //! across the other sub-modules (e.g. `useState` tuple rewrite, //! recursive AST scans). @@ -23,6 +25,7 @@ use crate::lower::{lower_expr, LoweringContext}; use crate::lower_patterns::*; use crate::lower_types::*; +mod array_fast; mod assignment_expr; mod assignment_stmt; mod helpers; @@ -35,7 +38,7 @@ pub(crate) use assignment_stmt::{ lower_destructuring_assignment_stmt, lower_destructuring_assignment_stmt_from_local, }; pub(crate) use helpers::{ast_expr_contains_function_expr, rewrite_use_state_tuple}; -pub(crate) use pattern_binding::lower_pattern_binding; +pub(crate) use pattern_binding::{lower_array_pattern_binding_guarded, lower_pattern_binding}; pub(crate) use var_decl::for_init_decl_type; pub(crate) use var_decl::lower_var_decl_with_destructuring; pub(crate) use var_decl_sources::resolvable_native_module_for_spec; diff --git a/crates/perry-hir/src/destructuring/pattern_binding.rs b/crates/perry-hir/src/destructuring/pattern_binding.rs index 586ece6fa8..09547bcc76 100644 --- a/crates/perry-hir/src/destructuring/pattern_binding.rs +++ b/crates/perry-hir/src/destructuring/pattern_binding.rs @@ -1,5 +1,6 @@ //! Recursive lowering of binding patterns (`let { a, b } = expr`). +use super::array_fast::{self, ArraySource}; use super::*; fn is_global_this_value(ctx: &LoweringContext, expr: &Expr) -> bool { @@ -163,7 +164,7 @@ fn iterator_next_value_stmts( fn lower_array_pattern_binding( ctx: &mut LoweringContext, arr_pat: &ast::ArrayPat, - source: Expr, + source: ArraySource, mutable: bool, is_var_decl: bool, result: &mut Vec, @@ -174,7 +175,7 @@ fn lower_array_pattern_binding( name: iter_name, ty: Type::Any, mutable: false, - init: Some(Expr::GetIterator(Box::new(source))), + init: Some(source.iter_init()), }); let (done_id, done_name) = fresh_destruct_local(ctx, Type::Boolean); result.push(Stmt::Let { @@ -186,7 +187,7 @@ fn lower_array_pattern_binding( }); let mut body: Vec = Vec::new(); - for elem in &arr_pat.elems { + for (idx, elem) in arr_pat.elems.iter().enumerate() { match elem { // Elision (`[, x]`) — advance the iterator and discard the value. None => { @@ -198,7 +199,8 @@ fn lower_array_pattern_binding( mutable: true, init: Some(Expr::Undefined), }); - body.extend(iterator_next_value_stmts(ctx, iter_id, done_id, value_id)); + let pull = iterator_next_value_stmts(ctx, iter_id, done_id, value_id); + body.extend(source.pull(idx, value_id, pull)); } // Rest element (`[...rest]`) — drain the remainder into an array. Some(ast::Pat::Rest(rest_pat)) => { @@ -237,7 +239,8 @@ fn lower_array_pattern_binding( mutable: true, init: Some(Expr::Undefined), }); - body.extend(iterator_next_value_stmts(ctx, iter_id, done_id, value_id)); + let pull = iterator_next_value_stmts(ctx, iter_id, done_id, value_id); + body.extend(source.pull(idx, value_id, pull)); // A `Pat::Assign` element carries a default initializer that is // evaluated lazily, only when the pulled value is `undefined`. @@ -277,10 +280,10 @@ fn lower_array_pattern_binding( // Close the iterator: on any abrupt completion from the body (default // initializer / nested pattern throwing), and again on normal completion // when the iterator was not exhausted. - let close_stmt = Stmt::Expr(runtime_iterator_call( + let close_stmt = source.close(Stmt::Expr(runtime_iterator_call( "iteratorCloseIfNotDone", vec![Expr::LocalGet(iter_id), Expr::LocalGet(done_id)], - )); + ))); let (exc_id, exc_name) = fresh_destruct_local(ctx, Type::Any); result.push(Stmt::Try { body, @@ -331,6 +334,33 @@ pub(crate) fn lower_pattern_binding( Ok(result) } +/// #10086: `const [a, b] = ` — bind the pattern through the guarded non-iterator arm. +/// +/// A declaration entry point of its own rather than a flag on +/// [`lower_pattern_binding`], because the proof is a property of the TOP-level +/// pattern and its initializer: a nested pattern's source is an element of +/// unknown type, and every recursive call keeps the plain iterator lowering. +/// `setup` holds the statements the plan already emitted (the literal's element +/// spills, or the source spill, plus the guard read) and is extended in place. +pub(crate) fn lower_array_pattern_binding_guarded( + ctx: &mut LoweringContext, + arr_pat: &ast::ArrayPat, + plan: array_fast::FastPlan, + mutable: bool, + is_var_decl: bool, + setup: &mut Vec, +) -> Result<()> { + lower_array_pattern_binding( + ctx, + arr_pat, + ArraySource::Guarded(plan), + mutable, + is_var_decl, + setup, + ) +} + pub(crate) fn lower_pattern_binding_into( ctx: &mut LoweringContext, pat: &ast::Pat, @@ -485,7 +515,14 @@ pub(crate) fn lower_pattern_binding_into( // Array binding patterns use the iterator protocol (GetIterator / // IteratorStep / IteratorValue / IteratorClose), per spec — not raw // index reads. See `lower_array_pattern_binding`. - lower_array_pattern_binding(ctx, arr_pat, source, mutable, is_var_decl, result) + lower_array_pattern_binding( + ctx, + arr_pat, + ArraySource::Iterator(source), + mutable, + is_var_decl, + result, + ) } ast::Pat::Object(obj_pat) => { // Materialize source into a temp diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index 81e30a2d9b..920a424882 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -429,20 +429,47 @@ pub(crate) fn lower_var_decl_with_destructuring( // [value, setter_closure] 2-element array. Without this, the // regular destructure path indexes a scalar return as if it were // an array — both elements come out undefined. - let init_expr = + let use_state_tuple = if let (ast::Pat::Array(_), Some(init)) = (&decl.name, decl.init.as_ref()) { - if let Some(rewritten) = rewrite_use_state_tuple(ctx, init) { - rewritten - } else { - lower_expr(ctx, init)? - } + rewrite_use_state_tuple(ctx, init) } else { - decl.init - .as_ref() - .map(|e| lower_expr(ctx, e)) - .transpose()? - .ok_or_else(|| anyhow!("Destructuring requires an initializer"))? + None }; + + // #10086: `const [a, b] = [x, y]` / `= ` + // binds through the guarded non-iterator arm — no iterator object, + // no `{ value, done }` result object per element, and for a literal + // source no array allocation at all. Decided BEFORE the initializer + // is lowered, because the literal shape spills each element into + // its own temp instead of materializing the array. + if use_state_tuple.is_none() { + if let (ast::Pat::Array(arr_pat), Some(init)) = (pattern, decl.init.as_ref()) { + if let Some((mut stmts, plan)) = + super::array_fast::plan_for_source(ctx, &arr_pat.elems, init)? + { + lower_array_pattern_binding_guarded( + ctx, + arr_pat, + plan, + mutable, + is_var_decl, + &mut stmts, + )?; + result.extend(stmts); + return Ok(result); + } + } + } + + let init_expr = match use_state_tuple { + Some(rewritten) => rewritten, + None => decl + .init + .as_ref() + .map(|e| lower_expr(ctx, e)) + .transpose()? + .ok_or_else(|| anyhow!("Destructuring requires an initializer"))?, + }; let stmts = lower_pattern_binding(ctx, pattern, init_expr, mutable, is_var_decl)?; result.extend(stmts); } diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index ed4a85ecc4..375066fd0e 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2374,14 +2374,19 @@ pub enum Expr { /// `operand[Symbol.iterator]()` when iterable, else the operand itself (a /// generator object already *is* its iterator). Lowers to `js_get_iterator`. GetIterator(Box), - /// #7760: is `Array.prototype[Symbol.iterator]` currently replaced? + /// #7760 / #10086: can array iteration still be PROVEN to be the pristine + /// builtin protocol? /// - /// Reads the runtime's `PERRY_ARRAY_PROTO_ITERATOR_PATCHED` flag. Emitted - /// once at the ENTRY of a `for…of` over a statically-proven array, to pick - /// between the index loop (`__i < __arr.length`) and the lazy - /// iterator-protocol loop. Checking once is what the spec wants — `for…of` - /// performs GetIterator exactly once — and it keeps the cost off the - /// per-iteration path. + /// Reads the runtime's `PERRY_ARRAY_ITERATION_NOT_PRISTINE` flag, which is + /// set when `Array.prototype[Symbol.iterator]` is replaced or deleted + /// (#7760) and when the array-iterator prototype object escapes to user + /// code, after which `%ArrayIteratorPrototype%.next` may be patched + /// (#10086). Emitted once at the ENTRY of a `for…of` over a + /// statically-proven array, to pick between the index loop + /// (`__i < __arr.length`) and the lazy iterator-protocol loop, and once per + /// array destructuring that takes the non-iterator arm. Checking once is + /// what the spec wants — iteration performs GetIterator exactly once — and + /// it keeps the cost off the per-iteration path. /// /// A dedicated node rather than a call so codegen emits a single volatile /// `i8` load (the `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED` shape) instead diff --git a/crates/perry-hir/tests/array_destructuring_fast_path.rs b/crates/perry-hir/tests/array_destructuring_fast_path.rs new file mode 100644 index 0000000000..d49b1ba69f --- /dev/null +++ b/crates/perry-hir/tests/array_destructuring_fast_path.rs @@ -0,0 +1,156 @@ +//! #10086: array destructuring over a spread-free array literal or a +//! statically-proven array must lower to the guarded non-iterator arm, and +//! everything else must keep the plain spec iterator protocol. +//! +//! These assert the LOWERING DECISION, which is what the perf fix is; the +//! behavioural half (evaluation order, defaults, holes, rest, nested patterns, +//! generators, a patched `Array.prototype[Symbol.iterator]`) is +//! `test-files/test_gap_10086_array_destructuring_fast_path.ts`, compared +//! byte-for-byte against node. + +use perry_diagnostics::SourceCache; +use perry_hir::{lower_module, Module}; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = + perry_parser::parse_typescript_with_cache(&src, "destructure_fast.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "destructure_fast.ts").expect("lowering succeeds") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +fn hir(src: &str) -> String { + format!("{:#?}", lower(src)) +} + +/// The guard itself: `Expr::ArrayIterationPatched`, the volatile read of the +/// runtime's sticky `PERRY_ARRAY_ITERATION_NOT_PRISTINE` byte. +const GUARD: &str = "ArrayIterationPatched"; + +/// `[x, y] = [y, x]` — the measured swap. The guard must be read BEFORE the +/// iterator is resolved, which is the whole point: `GetIterator` takes the +/// rebuilt array literal as its operand, so a guard that dominates it means +/// neither the iterator NOR the array is materialized on the fast arm. +#[test] +fn swap_through_an_array_literal_is_guarded_and_allocates_nothing_up_front() { + let dump = hir("let x = 1; let y = 2; [x, y] = [y, x]; console.log(x, y);"); + let guard = dump + .find(GUARD) + .expect("a literal-source swap must read the array-iteration guard"); + let get_iterator = dump + .find("GetIterator(") + .expect("the protocol arm must still exist for a patched prototype"); + assert!( + guard < get_iterator, + "the guard must dominate GetIterator (and therefore the array literal \ + it iterates); guard at {guard}, GetIterator at {get_iterator}" + ); +} + +/// `const [a, b] = [f(), g()]` — the declaration form of the same shape. +#[test] +fn declaration_from_an_array_literal_is_guarded() { + let dump = + hir("function f(): number { return 1; }\nconst [a, b] = [f(), 2];\nconsole.log(a, b);"); + assert!( + dump.contains(GUARD), + "a literal-source declaration must read the array-iteration guard" + ); +} + +/// `const [a, b] = pair(x)` where `pair(): number[]` — the pair does escape +/// (it is a real array returned across a call), so it is still allocated; what +/// goes away is the iterator object and the per-element `{ value, done }` +/// result. The fast arm reads it by index. +#[test] +fn declaration_from_a_proven_array_reads_by_index() { + let dump = hir( + "function pair(v: number): number[] { return [v, v + 1]; }\n\ + const [a, b] = pair(3);\nconsole.log(a, b);", + ); + assert!( + dump.contains(GUARD), + "a proven-array source must read the array-iteration guard" + ); + assert!( + dump.contains("IndexGet"), + "the fast arm of a proven-array source must read elements by index" + ); +} + +/// An assignment whose source is a proven array takes the same arm. +#[test] +fn assignment_from_a_proven_array_is_guarded() { + let dump = hir( + "const src: number[] = [1, 2];\nlet a = 0; let b = 0;\n[a, b] = src;\nconsole.log(a, b);", + ); + assert!( + dump.contains(GUARD), + "a proven-array assignment must be guarded" + ); +} + +/// A source with no static array proof — a custom iterable, a generator, an +/// `any` — keeps the plain iterator protocol, unguarded. Emitting the fast arm +/// here would read `.length` / `[0]` off something that has neither. +#[test] +fn an_unproven_source_keeps_the_plain_iterator_protocol() { + let dump = + hir("declare const it: any;\nlet a = 0; let b = 0;\n[a, b] = it;\nconsole.log(a, b);"); + assert!( + !dump.contains(GUARD), + "an unproven source must not take the non-iterator arm" + ); + assert!( + dump.contains("GetIterator("), + "an unproven source must still drive the iterator protocol" + ); +} + +/// A generator call is not a proven array either. +#[test] +fn a_generator_source_keeps_the_plain_iterator_protocol() { + let dump = hir("function* g() { yield 1; yield 2; }\nconst [a, b] = g();\nconsole.log(a, b);"); + assert!( + !dump.contains(GUARD), + "a generator source must not take the non-iterator arm" + ); +} + +/// A rest element keeps the iterator drain: the protocol builds a DENSE array +/// while the index-side equivalent (`slice`) preserves holes, so the two +/// disagree on a sparse source. +#[test] +fn a_rest_element_keeps_the_iterator_drain() { + let dump = + hir("const src: number[] = [1, 2, 3];\nconst [a, ...rest] = src;\nconsole.log(a, rest);"); + assert!( + !dump.contains(GUARD), + "a rest pattern must keep the unguarded iterator lowering" + ); +} + +/// A spread inside the literal makes the element count dynamic, so the literal +/// shape does not apply. (`[...xs]` is still an array, so the PROVEN-array arm +/// may take it — what must not happen is treating `xs`'s elements as if they +/// were the literal's.) +#[test] +fn a_spread_in_the_literal_does_not_take_the_scalar_arm() { + let dump = hir( + "const xs: number[] = [1, 2];\nlet a = 0; let b = 0;\n[a, b] = [...xs];\nconsole.log(a, b);", + ); + // Whichever arm it takes, the spread must still be materialized into a real + // array before anything reads element 0. + assert!( + dump.contains("ArraySpread") || dump.contains("Spread"), + "the spread must still build a real array" + ); +} diff --git a/crates/perry-runtime/src/array/indexing_support.rs b/crates/perry-runtime/src/array/indexing_support.rs index 0780319ebe..a7cbc349df 100644 --- a/crates/perry-runtime/src/array/indexing_support.rs +++ b/crates/perry-runtime/src/array/indexing_support.rs @@ -135,24 +135,53 @@ pub(crate) fn object_prototype_has_index_flag() -> bool { /// hot-path shape as `ARRAY_PROTO_HAS_INDEX` above. pub(super) static ARRAY_PROTO_ITERATOR_MODIFIED: AtomicBool = AtomicBool::new(false); -/// The same fact as [`ARRAY_PROTO_ITERATOR_MODIFIED`], exported so GENERATED -/// code can read it (#7760 item 1). +/// Sticky, exported so GENERATED code can read it: array iteration can no +/// longer be PROVEN to be the pristine builtin protocol. /// -/// `for…of` over a statically-proven array desugars to an index loop -/// (`__i < __arr.length` / `__arr[__i]`) in HIR lowering, which never consults -/// the iteration protocol — so a patched `Array.prototype[Symbol.iterator]` was -/// ignored there even after the spread paths were fixed (#7542). The loop now -/// branches on this flag ONCE at entry, which is also what the spec wants: -/// `for…of` performs GetIterator exactly once, so a patch landing mid-loop must -/// not change the iterator already in hand. +/// Two independent facts set it, and generated code must decline its +/// non-iterator fast arm for either: +/// +/// * [`ARRAY_PROTO_ITERATOR_MODIFIED`] — `Array.prototype[Symbol.iterator]` +/// was replaced or deleted (#7760 item 1). `for…of` over a statically-proven +/// array desugars to an index loop (`__i < __arr.length` / `__arr[__i]`) in +/// HIR lowering, which never consults the iteration protocol, so such a +/// patch was ignored there even after the spread paths were fixed (#7542). +/// * The array-iterator PROTOTYPE object was handed to user code (#10086; see +/// `object::iterator_prototypes::note_array_iterator_prototype_exposed`). +/// A replaced `%ArrayIteratorPrototype%.next` is detected per `.next()` call +/// (`prototype_next_is_canonical`), which a fast arm that never calls +/// `.next()` cannot observe — and the only way to reach that object in order +/// to patch it is `Object.getPrototypeOf` / `Reflect.getPrototypeOf`. So the +/// flag is set the moment the object escapes, whether or not it is then +/// patched: over-approximating costs the fast arm only in programs that +/// introspect array iterators, while under-approximating would silently +/// return unpatched elements. +/// +/// Both consumers — the `for…of` index loop and #10086's array-destructuring +/// arm — branch on it ONCE, which is also what the spec wants: iteration +/// performs GetIterator exactly once, so a patch landing mid-loop must not +/// change the iterator already in hand. /// /// A separate `u8` global rather than exposing the `AtomicBool`: codegen emits /// a plain volatile `i8` load, the same shape as /// `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED`, so the fast arm pays one load and /// a predictable branch per LOOP — not per iteration — and the index loop /// itself is emitted byte-identically to before. +/// +/// NOTE the asymmetry with [`ARRAY_PROTO_ITERATOR_MODIFIED`]: that bool keeps +/// its exact original meaning (the `Symbol.iterator` slot was written) and still +/// gates the Rust-side spread / `js_get_iterator` delegation. Only this byte +/// carries the broader "not provably pristine" fact. #[no_mangle] -pub static PERRY_ARRAY_PROTO_ITERATOR_PATCHED: AtomicU8 = AtomicU8::new(0); +pub static PERRY_ARRAY_ITERATION_NOT_PRISTINE: AtomicU8 = AtomicU8::new(0); + +/// Publish "array iteration is no longer provably pristine" to generated code. +/// Release-ordered so a reader that observes the `1` also observes the +/// prototype exposure / write that preceded it. +#[inline] +pub(crate) fn note_array_iteration_not_pristine() { + PERRY_ARRAY_ITERATION_NOT_PRISTINE.store(1, Ordering::Release); +} /// Record (if `obj` is `Array.prototype` and `sym_key` is the well-known /// `Symbol.iterator`) that the array iteration protocol has been tampered @@ -165,9 +194,7 @@ pub(crate) fn note_array_proto_iterator_write(obj: usize, sym_key: usize) { && sym_key == crate::symbol::well_known_symbol("iterator") as usize { ARRAY_PROTO_ITERATOR_MODIFIED.store(true, Ordering::Relaxed); - // Publish to generated code. Release so a loop that observes the `1` - // also observes the prototype write that preceded it. - PERRY_ARRAY_PROTO_ITERATOR_PATCHED.store(1, Ordering::Release); + note_array_iteration_not_pristine(); } } diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 39fdbee176..efe0dc67d7 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -167,8 +167,9 @@ pub(crate) use self::indexing_support::test_keys_array_slot_fallbacks; pub(crate) use self::indexing_support::{ array_proto_iterator_modified, invalidate_array_index_fast_path, keys_array_len_capped_to_capacity, keys_array_slot, note_array_index_write, - note_array_proto_iterator_write, note_object_prototype_index_write, - object_prototype_has_index_flag, PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED, + note_array_iteration_not_pristine, note_array_proto_iterator_write, + note_object_prototype_index_write, object_prototype_has_index_flag, + PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED, }; pub use self::is_array::js_array_is_array; pub(crate) use self::iter_methods::throw_reduce_of_empty; diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index 8094cc9a18..7a2c2ef299 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -66,6 +66,40 @@ pub(crate) static REGEXP_STRING_ITERATOR_PROTOTYPE_PTR: super::RealmAtomicI64 = pub(crate) static ITERATOR_HELPER_PROTOTYPE_PTR: super::RealmAtomicI64 = super::RealmAtomicI64::new(&ITERATOR_HELPER_PROTOTYPE_PTR_SLOT); +/// #10086: the array-iterator prototype object is about to be handed to user +/// code, so `%ArrayIteratorPrototype%.next` may be replaced at any point after +/// this. Publish that to generated code. +/// +/// A replaced `next` is detected per `.next()` call by +/// [`prototype_next_is_canonical`] — which a non-iterator fast arm (the +/// `for…of` index loop, #10086's array-destructuring arm) never reaches, +/// because it never calls `.next()`. There is no cheap sticky signal for the +/// write itself: the object is an ordinary `ObjectHeader`, so a precise hook +/// would have to cover every mutation funnel (assignment, computed assignment, +/// `defineProperty`, `delete`, `Object.assign`) and MISSING one fails silently, +/// in the direction of a wrong answer. +/// +/// Escape is the choke point instead. User code cannot patch an object it +/// cannot name, and in Perry the only way to name this one is +/// `Object.getPrototypeOf` / `Reflect.getPrototypeOf` (both +/// `js_object_get_prototype_of`; `iter.__proto__` answers `undefined` here). +/// So the flag is set when the object escapes, patched or not. The cost lands +/// only on programs that introspect an array iterator — and those are exactly +/// the programs about to patch one. +pub(crate) fn note_array_iterator_prototype_exposed(value: f64) { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return; + } + let addr = jv.as_pointer::() as i64; + if addr == 0 { + return; + } + if ARRAY_ITERATOR_PROTOTYPE_PTR.load(Ordering::Acquire) == addr { + crate::array::note_array_iteration_not_pristine(); + } +} + /// Resolve and validate the implicit-`this` object shared by the family /// prototype thunks. Keeping the raw-address probe here gives both the generic /// family dispatcher and the helper-specific brand check one audited path. diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index a4f8c418d3..6e8417efb8 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -186,6 +186,18 @@ pub extern "C" fn js_object_create(proto_value: f64) -> f64 { /// Refs #420 / #618 followup. #[no_mangle] pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { + let proto = get_prototype_of_resolved(obj_value); + // #10086: this is the ONE place a prototype object reaches user code, so + // it is also the only place the array-iterator prototype can escape to be + // patched. Publishing here is what lets the `for…of` index loop and the + // array-destructuring fast arm decline a possibly-patched + // `%ArrayIteratorPrototype%.next`, which neither can observe otherwise. + crate::object::iterator_prototypes::note_array_iterator_prototype_exposed(proto); + proto +} + +/// The resolution itself; see [`js_object_get_prototype_of`]. +fn get_prototype_of_resolved(obj_value: f64) -> f64 { const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; // #2820: `Object.getPrototypeOf(null | undefined)` throws TypeError // (`Cannot convert undefined or null to object`). Class refs and heap diff --git a/test-files/test_gap_10086_array_destructuring_fast_path.ts b/test-files/test_gap_10086_array_destructuring_fast_path.ts new file mode 100644 index 0000000000..379fd153c8 --- /dev/null +++ b/test-files/test_gap_10086_array_destructuring_fast_path.ts @@ -0,0 +1,318 @@ +// #10086: array destructuring no longer drives the iterator protocol for a +// spread-free array literal or a statically-proven array — it reads the +// elements directly, behind a runtime guard on +// `Array.prototype[Symbol.iterator]`. Every observable behaviour of the old +// lowering has to survive that: evaluation order and count, holes, defaults, +// rest, nested patterns, member-expression targets, a real iterator for +// anything that is not a plain array, and the patched-prototype case itself. + +const out: string[] = []; +function log(label: string, value: unknown): void { + out.push(label + "=" + String(value)); +} + +// --- once-evaluation and source order ------------------------------------- +let calls = ""; +function f(): number { + calls += "f"; + return 1; +} +function g(): number { + calls += "g"; + return 2; +} +let a = 0; +let b = 0; +[a, b] = [f(), g()]; +log("order.calls", calls); +log("order.a", a); +log("order.b", b); + +// --- swap: aliased and identical targets ---------------------------------- +let x = 7; +let y = 19; +[x, y] = [y, x]; +log("swap.x", x); +log("swap.y", y); +let same = 0; +[same, same] = [1, 2]; +log("swap.same", same); + +// A swap in a loop must stay a swap (the literal is gone, the values are not). +let p = 1; +let q = 2; +let acc = 0; +for (let i = 0; i < 5; i++) { + [p, q] = [q, p]; + acc = acc * 10 + p; +} +log("swap.loop", acc); +log("swap.loop.p", p); +log("swap.loop.q", q); + +// --- holes ---------------------------------------------------------------- +const src3 = [10, 20, 30]; +let h1 = 0; +let h2 = 0; +[, h1, h2] = src3; +log("hole.h1", h1); +log("hole.h2", h2); +const [, hole2] = src3; +log("hole.decl", hole2); + +// --- defaults ------------------------------------------------------------- +const short: number[] = [5]; +const [d1 = 1, d2 = 2] = short; +log("default.d1", d1); +log("default.d2", d2); +let e1 = 0; +let e2 = 0; +[e1 = 1, e2 = 2] = short; +log("default.e1", e1); +log("default.e2", e2); +// A genuine NaN element is NOT undefined, so the default must not fire. +const [nanKept = 99] = [NaN]; +log("default.nan", nanKept); +// An explicit `undefined` element does fire it. +const [undefFires = 42] = [undefined]; +log("default.undefined", undefFires); +// The spec re-reads the source length on every element, so a default that +// truncates the array must be visible to the next element. +const shrink: any[] = [undefined, 2]; +const [s1 = (((shrink as any).length = 1), 5), s2 = 7] = shrink; +log("default.shrink.s1", s1); +log("default.shrink.s2", s2); + +// --- rest ----------------------------------------------------------------- +const [r1, ...rest] = [1, 2, 3, 4]; +log("rest.r1", r1); +log("rest.rest", rest.join(",")); +let ra = 0; +let restTail: number[] = []; +[ra, ...restTail] = [9, 8, 7]; +log("rest.ra", ra); +log("rest.tail", restTail.join(",")); + +// --- nested patterns ------------------------------------------------------ +const [[n1], { n2 }] = [[3], { n2: 4 }] as [number[], { n2: number }]; +log("nested.n1", n1); +log("nested.n2", n2); +let m1 = 0; +let m2 = 0; +[[m1], { n2: m2 }] = [[5], { n2: 6 }] as [number[], { n2: number }]; +log("nested.m1", m1); +log("nested.m2", m2); + +// --- member-expression targets -------------------------------------------- +const obj: any = { x: 0, y: 0 }; +[obj.x, obj.y] = [11, 12]; +log("member.x", obj.x); +log("member.y", obj.y); +const keyOrder: string[] = []; +const box: any = {}; +function keyA(): string { + keyOrder.push("a"); + return "ka"; +} +function keyB(): string { + keyOrder.push("b"); + return "kb"; +} +[box[keyA()], box[keyB()]] = [21, 22]; +log("member.computed", box.ka + ":" + box.kb + ":" + keyOrder.join("")); + +// --- the source array still exists when it escapes ------------------------- +function pair(v: number): number[] { + return [v, v + 1]; +} +const escaped = pair(1); +const [ea, eb] = escaped; +escaped.push(99); +log("escape.ea", ea); +log("escape.eb", eb); +log("escape.array", escaped.join(",")); +let captured: number[] = []; +function capture(): number[] { + const made = [30, 31]; + captured = made; + return made; +} +const [ca, cb] = capture(); +captured.push(32); +log("capture.ca", ca); +log("capture.cb", cb); +log("capture.array", captured.join(",")); +log("capture.identity", String(captured.length === 3)); + +// --- a shorter array than the pattern ------------------------------------- +const [t1, t2, t3] = [1]; +log("short.t1", t1); +log("short.t2", String(t2)); +log("short.t3", String(t3)); + +// --- the iterator protocol is intact for everything that is not an array --- +let nextCalls = 0; +const custom: any = {}; +custom[Symbol.iterator] = function () { + return { + next: function () { + nextCalls++; + return { done: nextCalls > 3, value: nextCalls * 100 }; + }, + }; +}; +const [c1, c2] = custom; +log("custom.c1", c1); +log("custom.c2", c2); +log("custom.next", nextCalls); + +function* gen(): Generator { + yield 1; + yield 2; + yield 3; +} +const [gA, gB] = gen(); +log("gen.a", gA); +log("gen.b", gB); + +const set = new Set([4, 5, 6]); +const [sA, sB] = set; +log("set.a", sA); +log("set.b", sB); + +const map = new Map([["k", 1]]); +const [[mk, mv]] = map; +log("map.k", mk); +log("map.v", mv); + +const [strA, strB] = "hi"; +log("string.a", strA); +log("string.b", strB); + +// An iterator that closes: `return()` must run when the pattern stops early. +let closed = 0; +const closing: any = {}; +closing[Symbol.iterator] = function () { + let i = 0; + return { + next: function () { + i++; + return { done: false, value: i }; + }, + return: function () { + closed++; + return { done: true }; + }, + }; +}; +const [z1] = closing; +log("close.z1", z1); +log("close.count", closed); + +// --- inside an async function and a generator ------------------------------ +// The async-to-generator transform boxes body locals into shared cells, so the +// guarded per-element shape has to survive being split into generator states. +async function asyncCase(): Promise { + const src: number[] = [41, 42]; + const [a, b] = src; + await Promise.resolve(0); + let c = 0; + let d = 0; + [c, d] = [b, a]; + const [e, f2 = 7] = [c]; + return a + ":" + b + ":" + c + ":" + d + ":" + e + ":" + f2; +} + +function* genCase(): Generator { + const src: number[] = [51, 52]; + const [a, b] = src; + yield a + ":" + b; + let c = 0; + let d = 0; + [c, d] = [b, a]; + yield c + ":" + d; +} + +// A destructuring inside a loop inside a generator: the guard must not break +// the loop's state split. +function* genLoop(): Generator { + let p = 1; + let q = 2; + for (let i = 0; i < 3; i++) { + [p, q] = [q, p]; + yield p; + } +} + +const genOut: string[] = []; +for (const v of genCase()) genOut.push(v); +log("gen.case", genOut.join("|")); +const genLoopOut: number[] = []; +for (const v of genLoop()) genLoopOut.push(v); +log("gen.loop", genLoopOut.join(",")); + +// --- a patched %ArrayIteratorPrototype%.next is still honoured ------------- +// The non-iterator arm never calls `.next()`, so it cannot observe a patched +// one. The runtime publishes "array iteration is not provably pristine" the +// moment this prototype object escapes through `Object.getPrototypeOf`, which +// is what makes both arms below decline it. Sticky in Perry, so this runs after +// everything that must exercise the fast arm. +function patchedNextChecks(): void { + const arrayIterProto: any = Object.getPrototypeOf([][Symbol.iterator]()); + const originalNext = arrayIterProto.next; + arrayIterProto.next = function (this: any) { + const r = originalNext.call(this); + if (!r.done) r.value = (r.value as number) * 2; + return r; + }; + const src: number[] = [1, 2]; + const [na, nb] = src; + log("patchedNext.proven", na + ":" + nb); + let nc = 0; + let nd = 0; + [nc, nd] = [3, 4]; + log("patchedNext.literal", nc + ":" + nd); + const forOf: number[] = []; + for (const v of src) forOf.push(v); + log("patchedNext.forof", forOf.join(",")); + arrayIterProto.next = originalNext; + const [ra, rb] = src; + log("patchedNext.restored", ra + ":" + rb); +} + +// --- a patched Array.prototype[Symbol.iterator] is still honoured ---------- +// Sticky in Perry: everything after this point takes the protocol arm, so it +// runs last — after the async case has resolved, so that case still exercises +// the fast arm. +function patchedPrototypeChecks(): void { + const original = (Array.prototype as any)[Symbol.iterator]; + let patchedCalls = 0; + (Array.prototype as any)[Symbol.iterator] = function () { + patchedCalls++; + let i = 0; + return { + next: () => { + i++; + return { done: i > 2, value: i * 1000 }; + }, + }; + }; + let pa = 0; + let pb = 0; + [pa, pb] = [1, 2]; + log("patched.literal", pa + ":" + pb); + const plain = [7, 8]; + const [pc, pd] = plain; + log("patched.proven", pc + ":" + pd); + log("patched.calls", patchedCalls); + (Array.prototype as any)[Symbol.iterator] = original; + const [qa, qb] = [1, 2]; + log("restored", qa + ":" + qb); +} + +asyncCase().then((v) => { + log("async.case", v); + patchedNextChecks(); + patchedPrototypeChecks(); + console.log(out.join("\n")); +}); From 2a46c6f859b1eea341dc39d8ca832301f09e9ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:36:59 +0200 Subject: [PATCH 8/9] changelog: add fragment for #10108 The PR changed crates/ without one, which the changeset gate requires. Body summarised from the commit message's own measurements. --- .../10108-buffer-addr-filter-removal.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 changelog.d/10108-buffer-addr-filter-removal.md diff --git a/changelog.d/10108-buffer-addr-filter-removal.md b/changelog.d/10108-buffer-addr-filter-removal.md new file mode 100644 index 0000000000..ca819abb43 --- /dev/null +++ b/changelog.d/10108-buffer-addr-filter-removal.md @@ -0,0 +1,19 @@ +Removed `BUFFER_LIKE_ADDR_FILTER`, the 1,024-bit set filter sitting behind the +buffer registry's address window. It was measured saturated: on an ordinary +claude-code command (startup, two `Read` calls, a streamed reply) the population +is 3,232 cumulative admissions and 1,618 live against 1,024 bits, so every bit +ends the run set and the filter rejects nothing — while still costing three hash +rounds and up to three dependent loads on each of ~26.6M admitted probes. The +adoption note's premises came from a single 400-character reply (213 +registrations, 0.207% true positives) and do not hold at ordinary command scale, +where 88.87% of admitted probes find a real registered buffer, i.e. work the +registry genuinely has to do and no filter can remove. + +The `RegistryAddrWindow` min/max bound stays and continues to do all of the +rejecting (15.51% on the measured command), including the debug-build machine +check that re-derives every rejection from the authoritative tables. Removing a +negative accelerator cannot change an answer, only the time taken to reach it. + +Six interleaved pairs, one binary and one environment variable: minimum command +CPU 1.22s -> 1.19s (-2.46%), medians 1.28s -> 1.20s, paired median -3.60%, +faster in five pairs and tied in the sixth; peak RSS maxima 664.5 -> 635.2 MiB. From b4d2491941a9d84bbc387088ffa8d6e4ba281dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:38:14 +0200 Subject: [PATCH 9/9] chore: bump workspace version to 0.5.1538 Train164 (#10096, #10108, #10111, #10112) lands on main at 0.5.1537; none of the PRs bumped the version, which is the maintainer's job at merge time. Cargo.lock regenerated so every workspace member's inherited version moves with it. --- CLAUDE.md | 2 +- Cargo.lock | 158 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 81 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c82586ea28..9df6b7438a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1537 +**Current Version:** 0.5.1538 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 9dc8e400fb..0cb1384333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5695,7 +5695,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "base64 0.22.1", @@ -5759,7 +5759,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-dispatch", "serde", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "cc", "libc", @@ -5776,7 +5776,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "aho-corasick", "anyhow", @@ -5794,7 +5794,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-hir", @@ -5802,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-hir", @@ -5810,7 +5810,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-dispatch", @@ -5819,7 +5819,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-hir", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "base64 0.22.1", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-hir", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "async-trait", @@ -5876,14 +5876,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "serde", "serde_json", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1537" +version = "0.5.1538" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5902,7 +5902,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "clap", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "block2", "objc2", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "argon2", "perry-ffi", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "reqwest", @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "bcrypt", "perry-ffi", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "rusqlite", @@ -5961,7 +5961,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "scraper", @@ -5969,7 +5969,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "perry-runtime", @@ -5977,7 +5977,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "chrono", "cron", @@ -5987,7 +5987,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "chrono", "perry-ffi", @@ -5995,7 +5995,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "rust_decimal", @@ -6003,7 +6003,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "serde_json", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6019,7 +6019,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "perry-runtime", @@ -6027,14 +6027,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "bytes", "http-body-util", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "bytes", "lazy_static", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "bytes", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "lazy_static", "perry-ffi", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "lru", "perry-ffi", @@ -6127,7 +6127,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "chrono", "perry-ffi", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "bson", "futures-util", @@ -6147,7 +6147,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "chrono", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "nanoid", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "bytes", "perry-ffi", @@ -6183,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "lettre", "perry-ffi", @@ -6212,7 +6212,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "fancy-regex", "notify", @@ -6224,7 +6224,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "printpdf", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "sqlx", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "perry-runtime", @@ -6250,7 +6250,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "governor", "perry-ffi", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "fast_image_resize", "image", @@ -6269,7 +6269,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "lazy_static", "perry-ffi", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-ffi", @@ -6298,7 +6298,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "perry-runtime", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "uuid", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "regex", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "futures-util", "lazy_static", @@ -6338,7 +6338,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "brotli", "flate2", @@ -6348,7 +6348,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6358,7 +6358,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-api-manifest", @@ -6377,11 +6377,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1537" +version = "0.5.1538" [[package]] name = "perry-parser" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-diagnostics", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "ahash", "anyhow", @@ -6457,14 +6457,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1537" +version = "0.5.1538" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1537" +version = "0.5.1538" [[package]] name = "perry-ui-tvos" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1537" +version = "0.5.1538" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 815e7989cd..7b84e5b224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -336,7 +336,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1537" +version = "0.5.1538" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"