From 6250601779e2b12117b2d2c7e12a90fdce71ab8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:27:52 +0200 Subject: [PATCH 01/19] perf(runtime): specialized raw-f64 scan for indexOf/includes on numeric arrays (#10092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic per-element loop routed every candidate through js_jsvalue_equals/js_jsvalue_same_value_zero — both #[no_mangle] extern "C" call boundaries the optimizer cannot inline, re-deriving the element's type on every slot. On a proven-numeric dense array (RawF64 layout: no holes, no NaN-boxed pointers) this collapses to a bounded f64 compare loop, hoisting includes's NaN-equals-NaN check out of the loop. A/B on this host: ~18x faster at n=1M, ~12x at n=100k, ~9x at n=1k, checksums identical. Falls back to the existing generic walk for exotic iteration (index accessors/sparse storage/prototype indices) and mixed-kind arrays. Claude-Session: https://claude.ai/code/session_01SAYSYr7R7CwCF3EuAjSdWu (cherry picked from commit eb2b2ab41d08a40dd23d8b2152ab57452d853ead) --- crates/perry-runtime/src/array/search.rs | 172 +++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/crates/perry-runtime/src/array/search.rs b/crates/perry-runtime/src/array/search.rs index 007ca0a365..cefad5b0c7 100644 --- a/crates/perry-runtime/src/array/search.rs +++ b/crates/perry-runtime/src/array/search.rs @@ -105,6 +105,70 @@ fn forward_start_index(length: i64, from_index: f64, has_from: i32) -> Option Option { + if crate::array::array_iteration_is_exotic(arr) { + return None; + } + // Proves (or disproves) that every slot in `[0, length)` is a raw f64 + // number with no holes. Establishing this once lets every subsequent + // search/index op on the same array skip straight to an O(1) flag test — + // the same amortization `array_numeric_raw_f64_get` relies on. + if !super::header::ensure_array_numeric_raw_f64(arr as *mut ArrayHeader) { + return None; + } + // The array is now proven dense raw-f64: a non-numeric search value + // (string/object/bool/undefined/BigInt) can never equal any element. + let Some(search) = super::header::value_bits_to_number(value.to_bits()) else { + return Some(-1); + }; + let elements = array_elements_ptr(arr); + if same_value_zero && search.is_nan() { + for i in start..length { + if (*elements.add(i as usize)).is_nan() { + return Some(i); + } + } + } else { + for i in start..length { + if *elements.add(i as usize) == search { + return Some(i); + } + } + } + Some(-1) +} + /// indexOf for arrays, using jsvalue comparison (handles NaN-boxed strings /// correctly). `from_index` / `has_from` implement the optional ECMA-262 /// `fromIndex` argument (#2804); `has_from == 0` searches from index 0. @@ -154,6 +218,9 @@ pub extern "C" fn js_array_indexOf_jsvalue( Some(s) => s, None => return -1, }; + if let Some(result) = numeric_raw_f64_search(arr, value, start, length, false) { + return result; + } let elements_ptr = array_elements_ptr(arr); let exotic = crate::array::array_iteration_is_exotic(arr); for i in start..length { @@ -335,6 +402,9 @@ pub extern "C" fn js_array_includes_jsvalue( Some(s) => s, None => return 0, }; + if let Some(result) = numeric_raw_f64_search(arr, value, start, length, true) { + return if result >= 0 { 1 } else { 0 }; + } let elements_ptr = array_elements_ptr(arr); // `Array.prototype.includes` uses SameValueZero (ECMA-262 §23.1.3.16), @@ -420,3 +490,105 @@ mod typed_search_tests { assert_eq!(js_array_last_index_of_jsvalue(arr, f64::NAN, 0.0, 0), -1); } } + +/// #10092: the specialized `numeric_raw_f64_search` scan used by +/// `indexOf`/`includes` on a proven-numeric dense array must match the +/// generic per-element semantics exactly. +#[cfg(test)] +mod numeric_fast_path_tests { + use super::*; + use crate::array::{js_array_alloc, js_array_push_f64}; + + fn numbers(values: &[f64]) -> *mut ArrayHeader { + let mut arr = js_array_alloc(values.len() as u32); + for &v in values { + arr = js_array_push_f64(arr, v); + } + arr + } + + /// Strict equality (`indexOf`) never matches NaN, while SameValueZero + /// (`includes`) treats NaN as equal to NaN. + #[test] + fn nan_split_between_indexof_and_includes() { + let arr = numbers(&[1.0, f64::NAN, 3.0]); + assert_eq!(js_array_indexOf_jsvalue(arr, f64::NAN, 0.0, 0), -1); + assert_eq!(js_array_includes_jsvalue(arr, f64::NAN, 0.0, 0), 1); + } + + /// `+0`/`-0` are interchangeable for both algorithms — only NaN diverges. + #[test] + fn zero_and_negative_zero_are_interchangeable() { + let zero = numbers(&[0.0]); + assert_eq!(js_array_includes_jsvalue(zero, -0.0, 0.0, 0), 1); + let neg_zero = numbers(&[-0.0]); + assert_eq!(js_array_indexOf_jsvalue(neg_zero, 0.0, 0.0, 0), 0); + } + + /// A hole reads as `undefined` for `includes` but is skipped (never + /// `undefined`-equal) for `indexOf`. An array with a hole must NOT take + /// the raw-f64 fast path — `ensure_array_numeric_raw_f64` must reject it. + #[test] + fn holes_keep_the_generic_undefined_semantics() { + let mut arr = js_array_alloc(2); + arr = js_array_push_f64(arr, f64::from_bits(crate::value::TAG_HOLE)); + arr = js_array_push_f64(arr, 1.0); + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + assert_eq!(js_array_includes_jsvalue(arr, undefined, 0.0, 0), 1); + assert_eq!(js_array_indexOf_jsvalue(arr, undefined, 0.0, 0), -1); + } + + /// A mixed-kind array (numbers plus a string) must fail the numeric proof + /// and fall back to the generic per-element walk rather than reporting a + /// false miss. + #[test] + fn mixed_kind_array_falls_back_to_generic_search() { + let needle_bytes = b"needle"; + let mut arr = js_array_alloc(3); + arr = js_array_push_f64(arr, 1.0); + let s1 = + crate::string::js_string_from_bytes(needle_bytes.as_ptr(), needle_bytes.len() as u32); + arr = js_array_push_f64(arr, crate::value::js_nanbox_string(s1 as i64)); + arr = js_array_push_f64(arr, 3.0); + + let s2 = + crate::string::js_string_from_bytes(needle_bytes.as_ptr(), needle_bytes.len() as u32); + let needle = crate::value::js_nanbox_string(s2 as i64); + assert_eq!(js_array_indexOf_jsvalue(arr, needle, 0.0, 0), 1); + assert_eq!(js_array_includes_jsvalue(arr, needle, 0.0, 0), 1); + assert_eq!(js_array_indexOf_jsvalue(arr, 3.0, 0.0, 0), 2); + assert_eq!(js_array_includes_jsvalue(arr, 9.0, 0.0, 0), 0); + } + + /// `fromIndex` (including negative and out-of-range) must still be + /// honored once the fast path takes over. + #[test] + fn from_index_is_honored_by_the_fast_path() { + let arr = numbers(&[1.0, 2.0, 3.0, 2.0, 1.0]); + assert_eq!(js_array_indexOf_jsvalue(arr, 2.0, 2.0, 1), 3); + assert_eq!(js_array_indexOf_jsvalue(arr, 2.0, -2.0, 1), 3); + assert_eq!(js_array_includes_jsvalue(arr, 1.0, 2.0, 1), 1); + assert_eq!(js_array_includes_jsvalue(arr, 1.0, f64::INFINITY, 1), 0); + } + + /// A guaranteed-absent search value must scan the full length and report + /// a miss; a value present at the first index must early-exit correctly. + #[test] + fn full_scan_miss_and_first_index_hit() { + let arr = numbers(&[10.0, 20.0, 30.0]); + assert_eq!(js_array_indexOf_jsvalue(arr, 999.0, 0.0, 0), -1); + assert_eq!(js_array_includes_jsvalue(arr, 999.0, 0.0, 0), 0); + assert_eq!(js_array_indexOf_jsvalue(arr, 10.0, 0.0, 0), 0); + } + + /// A non-numeric search value against a proven-numeric array can never + /// match — exercises the fast path's own early-return branch (distinct + /// from the hole/undefined case above, which must NOT take this path). + #[test] + fn non_numeric_search_value_against_numeric_array_never_matches() { + let arr = numbers(&[1.0, 2.0, 3.0]); + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + assert_eq!(js_array_indexOf_jsvalue(arr, undefined, 0.0, 0), -1); + assert_eq!(js_array_includes_jsvalue(arr, undefined, 0.0, 0), 0); + } +} From a809549bf5a394a43958c65c0ef931f3a99ebae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:28:43 +0200 Subject: [PATCH 02/19] docs: add changelog fragment for #10120 Claude-Session: https://claude.ai/code/session_01SAYSYr7R7CwCF3EuAjSdWu (cherry picked from commit dd360ff9e3486a5e456bd8f7ba3112b82d23b93f) --- changelog.d/10120-array-numeric-search.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/10120-array-numeric-search.md diff --git a/changelog.d/10120-array-numeric-search.md b/changelog.d/10120-array-numeric-search.md new file mode 100644 index 0000000000..fc1107a1a5 --- /dev/null +++ b/changelog.d/10120-array-numeric-search.md @@ -0,0 +1,8 @@ +Fixed `Array.prototype.indexOf`/`includes` on a proven-numeric dense array +(no holes, no NaN-boxed pointers) costing up to ~20x Node: every element was +routed through `js_jsvalue_equals`/`js_jsvalue_same_value_zero`, both +`#[no_mangle] extern "C"` call boundaries the optimizer can't inline. A +specialized scan now collapses the search to a bounded `f64` compare loop +when that proof holds, falling back to the existing generic walk otherwise +(exotic iteration, mixed-kind arrays). Measured ~9-16x faster on this host +across n=1k..1M, with identical results (#10092). From b83c42e4bdea284edc1616452c79c3f63bc49a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:14:27 +0200 Subject: [PATCH 03/19] perf(runtime): add ASCII fast path to toLowerCase/toUpperCase (#10090) case_convert ran every input, including pure ASCII, through a scalar wtf8_step decode / per-char to_lowercase()/to_uppercase() iterator / re-encode loop, costing 30-33x Node on a 1M-char all-ASCII string. Gate on a real per-byte bytes.is_ascii() scan (not the is_ascii_string byte_len==utf16_len aggregate proxy, which can lie for malformed WTF-8) and use to_ascii_lowercase()/to_ascii_uppercase() for a vectorizable byte-table transform instead. Non-ASCII input, locale-aware casing, and WTF-8/lone-surrogate handling are untouched. Claude-Session: https://claude.ai/code/session_013naeTjgijAXt8PwpQEKkbu (cherry picked from commit fa80c4ad6f95fac8bcad86888a814138ec9ab078) --- .../10090-string-case-ascii-fastpath.md | 22 +++++ crates/perry-runtime/src/string/slice_ops.rs | 25 ++++++ crates/perry-runtime/src/string/tests.rs | 56 ++++++++++++ .../src/string/tests_guard_page.rs | 39 ++++++++ ...st_gap_10090_string_case_ascii_fastpath.ts | 90 +++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 changelog.d/10090-string-case-ascii-fastpath.md create mode 100644 test-files/test_gap_10090_string_case_ascii_fastpath.ts diff --git a/changelog.d/10090-string-case-ascii-fastpath.md b/changelog.d/10090-string-case-ascii-fastpath.md new file mode 100644 index 0000000000..e14ef64a24 --- /dev/null +++ b/changelog.d/10090-string-case-ascii-fastpath.md @@ -0,0 +1,22 @@ +### `toLowerCase`/`toUpperCase` gained an ASCII fast path (#10090) + +`case_convert` (the shared implementation behind `String.prototype.toLowerCase` +and `toUpperCase`) previously ran every input — including pure ASCII — through +a scalar `wtf8_step` decode, a per-character `char::to_lowercase()` / +`to_uppercase()` iterator, and a re-encode loop. On a 1M-character all-ASCII +string this cost 30-33x what Node takes for the same input. + +`case_convert` now checks whether the input is pure ASCII with a real +per-byte scan (`bytes.is_ascii()`) and, if so, produces the result with a +single vectorizable `to_ascii_lowercase()`/`to_ascii_uppercase()` byte-table +transform instead. Everything else — Unicode special casing (`ß`→`SS`, +Cherokee, Deseret, the default-locale `İ`→`i` + combining dot, Greek final +sigma), WTF-8 lone-surrogate round-tripping, and locale-aware casing in +`locale.rs` — is unchanged and continues through the original scalar loop. + +The ASCII check deliberately does **not** reuse the existing `is_ascii_string` +helper (an O(1) `byte_len == utf16_len` proxy used elsewhere in this file): +that aggregate can be true for malformed WTF-8 where a stray continuation +byte and a truncated multi-byte lead cancel out in the unit count, even +though the bytes are not ASCII. A regression test +(`case_convert_rejects_the_aggregate_ascii_lie`) locks this in. diff --git a/crates/perry-runtime/src/string/slice_ops.rs b/crates/perry-runtime/src/string/slice_ops.rs index d233e56f80..8f6ffdc41b 100644 --- a/crates/perry-runtime/src/string/slice_ops.rs +++ b/crates/perry-runtime/src/string/slice_ops.rs @@ -340,6 +340,31 @@ fn case_convert(s: *const StringHeader, upper: bool) -> *mut StringHeader { return js_string_from_bytes(ptr::null(), 0); } let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; + + // ASCII fast path (#10090): default-locale ASCII case mapping is + // context-free and strictly 1-byte-in/1-byte-out, so skip the scalar + // wtf8_step decode / per-char to_lowercase()/to_uppercase() iterator + // construction / re-encode loop entirely and let `to_ascii_lowercase`/ + // `to_ascii_uppercase` do a vectorizable byte-table transform instead. + // + // Must gate on `bytes.is_ascii()` (a real per-byte scan), NOT the + // `is_ascii_string(s)` `byte_len == utf16_len` AGGREGATE proxy used + // elsewhere for O(1) checks: that aggregate can lie for malformed WTF-8, + // where a stray continuation byte (0 UTF-16 units) and a truncated + // multi-byte lead (2 units) cancel out to look ASCII while containing + // non-ASCII bytes (see `split_parts_get_metadata_from_their_own_bytes`). + // A genuinely all-ASCII input can never carry a lone surrogate, so the + // result's flags are trivially 0 and its utf16_len == its byte_len. + if bytes.is_ascii() { + let out = if upper { + bytes.to_ascii_uppercase() + } else { + bytes.to_ascii_lowercase() + }; + let len = out.len() as u32; + return js_string_from_bytes_known_utf16(out.as_ptr(), len, len, 0); + } + let mut out: Vec = Vec::with_capacity(bytes.len()); let mut has_lone_surrogate = false; let mut buf = [0u8; 4]; diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index eb9d6adcf7..6b62dcb235 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -1393,3 +1393,59 @@ fn header_str_checked_matches_from_utf8_on_every_payload_class() { fn string_as_bytes_for_test<'a>(s: *const StringHeader) -> &'a [u8] { unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) } } + +/// ASCII fast path (#10090): `case_convert` must produce byte-identical +/// results to the pre-fast-path scalar decode loop for pure-ASCII input, +/// including empty strings, single characters, and input already in the +/// target case. `utf16_len` must equal `byte_len` and `flags` must be 0 — +/// an all-ASCII payload can never carry `STRING_FLAG_HAS_LONE_SURROGATES`. +#[test] +fn ascii_fast_path_basic_case_conversion() { + for input in [ + "", + "a", + "A", + "aBcD1234EfGh", + "already lower", + "ALREADY UPPER", + ] { + let s = js_string_from_bytes(input.as_ptr(), input.len() as u32); + let lower = js_string_to_lower_case(s); + let upper = js_string_to_upper_case(s); + assert_eq!(string_as_str(lower), input.to_ascii_lowercase()); + assert_eq!(string_as_str(upper), input.to_ascii_uppercase()); + for out in [lower, upper] { + unsafe { + assert_eq!((*out).utf16_len, (*out).byte_len); + assert_eq!((*out).flags, 0); + } + } + } +} + +/// A lone surrogate after an ASCII prefix must still take the scalar path: +/// `bytes.is_ascii()` is false (the WTF-8 encoding of a lone surrogate uses +/// bytes >= 0x80), so the ASCII fast path is not taken, the surrogate bytes +/// round-trip verbatim, and `STRING_FLAG_HAS_LONE_SURROGATES` survives. +#[test] +fn ascii_prefix_with_trailing_lone_surrogate_preserves_flag_and_bytes() { + // "ABC" + WTF-8 lone high surrogate U+D800 (ED A0 80). + let bytes = [b'A', b'B', b'C', 0xED, 0xA0, 0x80]; + let s = js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32); + assert_ne!(unsafe { (*s).flags } & STRING_FLAG_HAS_LONE_SURROGATES, 0); + assert!(!string_as_bytes_for_test(s).is_ascii()); + + for (out, expect_ascii) in [ + (js_string_to_lower_case(s), [b'a', b'b', b'c']), + (js_string_to_upper_case(s), [b'A', b'B', b'C']), + ] { + let out_bytes = string_as_bytes_for_test(out); + assert_eq!(&out_bytes[..3], &expect_ascii); + assert_eq!(&out_bytes[3..], &[0xED, 0xA0, 0x80]); + assert_eq!( + unsafe { (*out).flags } & STRING_FLAG_HAS_LONE_SURROGATES, + STRING_FLAG_HAS_LONE_SURROGATES, + "the lone surrogate must keep the result flagged" + ); + } +} diff --git a/crates/perry-runtime/src/string/tests_guard_page.rs b/crates/perry-runtime/src/string/tests_guard_page.rs index aa2089de62..77d1970f8b 100644 --- a/crates/perry-runtime/src/string/tests_guard_page.rs +++ b/crates/perry-runtime/src/string/tests_guard_page.rs @@ -414,3 +414,42 @@ fn astral_and_truncated_astral_lead() { let _ = js_string_char_code_at(ts, 2); let _ = js_string_to_char_array(crate::value::js_nanbox_string(ts as i64).to_bits() as i64); } + +/// The ASCII fast path added for #10090 MUST gate on `bytes.is_ascii()` (a +/// real per-byte scan), not on `is_ascii_string(s)` (the `byte_len == +/// utf16_len` AGGREGATE proxy already known to lie — see +/// `split_parts_get_metadata_from_their_own_bytes`). +/// +/// `[0xC3, 0xA9, b'a', 0xF0]` is "é" + "a" + a truncated 4-byte lead. Its +/// UTF-16 unit total happens to equal its byte length (1 + 1 + 2 == 4), so +/// `is_ascii_string` wrongly reports true, even though the payload is not +/// ASCII. A gate on the aggregate would route this through +/// `to_ascii_uppercase()`, which touches only `a`-`z`/`A`-`Z` bytes and +/// leaves 0xC3/0xA9 untouched — silently skipping the real Unicode mapping +/// `é` → `É` (`C3 A9` → `C3 89`). The correct byte-level gate falls back to +/// the scalar `wtf8_step` loop, which maps `é`/`a` and copies the truncated +/// lead byte through verbatim, flush against the guard page so any +/// out-of-bounds read on that fallback also faults. +#[test] +fn case_convert_rejects_the_aggregate_ascii_lie() { + let g = GuardedString::new(&[0xC3, 0xA9, b'a', 0xF0]); + let s = g.ptr(); + assert!( + is_ascii_string(s), + "precondition: the aggregate byte_len == utf16_len check misfires" + ); + let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; + assert!( + !bytes.is_ascii(), + "precondition: the payload is NOT actually pure ASCII" + ); + + let upper = js_string_to_upper_case(s); + let upper_bytes = + unsafe { slice::from_raw_parts(string_data(upper), (*upper).byte_len as usize) }; + assert_eq!( + upper_bytes, + &[0xC3, 0x89, b'A', 0xF0], + "É (C3 89) + A + the raw truncated lead byte — NOT the input echoed back unmapped" + ); +} diff --git a/test-files/test_gap_10090_string_case_ascii_fastpath.ts b/test-files/test_gap_10090_string_case_ascii_fastpath.ts new file mode 100644 index 0000000000..a0bb4686f7 --- /dev/null +++ b/test-files/test_gap_10090_string_case_ascii_fastpath.ts @@ -0,0 +1,90 @@ +// Gap test for #10090: toLowerCase/toUpperCase gained an ASCII fast path in +// `case_convert` (crates/perry-runtime/src/string/slice_ops.rs) that skips +// the scalar wtf8_step decode / char::to_lowercase()-or-to_uppercase() +// iterator / re-encode loop when every byte of the input is ASCII, doing a +// plain byte-table transform instead. +// +// The fast path must not change behavior for anything it does not apply to. +// This file exercises exactly the boundary cases called out in the issue: +// multi-char Unicode special casing (ß, Cherokee, Deseret, final sigma, the +// default-locale İ special case), a string that is ASCII except for one +// trailing multi-byte character, and an ASCII prefix followed by a lone +// surrogate. This file is byte-compared with `node --experimental-strip-types` +// by the gap suite. + +function show(label: string, value: unknown): void { + console.log(label + ":" + JSON.stringify(value)); +} + +// Render a string as its UTF-16 code-unit sequence so a lone surrogate +// survives JSON.stringify unambiguously (matches test_gap_9409's `units`). +function units(s: string): number[] { + return Array.from({ length: s.length }, (_, i) => s.charCodeAt(i)); +} + +// ---- Pure ASCII: fast-path territory ---- +const asciiSamples = [ + "", + "a", + "A", + "aBcD1234EfGh", + "already lower", + "ALREADY UPPER", + "The Quick Brown Fox Jumps Over The Lazy Dog 0123456789 !@#$%^&*()", + "aBcD".repeat(50), +]; +for (const s of asciiSamples) { + show("ascii-lower:" + JSON.stringify(s), s.toLowerCase()); + show("ascii-upper:" + JSON.stringify(s), s.toUpperCase()); + show("ascii-lower-len:" + JSON.stringify(s), s.toLowerCase().length); + show("ascii-upper-len:" + JSON.stringify(s), s.toUpperCase().length); +} + +// ---- German sharp s: one-to-many default casing, changes .length ---- +console.log("sharp-s-upper:" + "straße".toUpperCase()); // "STRASSE" +console.log("sharp-s-upper-len:" + "straße".toUpperCase().length); // 7 (was 6) +console.log("capital-sharp-s-lower:" + "ẞ".toLowerCase()); // "ß" + +// ---- Turkic dotted/dotless I must NOT apply under the DEFAULT (non-locale) +// toLowerCase/toUpperCase - that special casing only applies to +// toLocaleLowerCase/toLocaleUpperCase("tr"/"az"), which live in locale.rs and +// are untouched by this fix. ---- +console.log("default-i-upper:" + "i".toUpperCase()); // "I" +console.log("default-I-lower:" + "I".toLowerCase()); // "i" + +// ---- Default-locale one-to-many special casing (SpecialCasing.txt, locale +// independent): CAPITAL I WITH DOT ABOVE lowercases to "i" + COMBINING DOT +// ABOVE - two code units, distinct from the Turkish-locale mapping. ---- +console.log("i-with-dot-lower:" + JSON.stringify("İ".toLowerCase())); +console.log("i-with-dot-lower-units:" + JSON.stringify(units("İ".toLowerCase()))); + +// NOTE: Greek final sigma (context-dependent Σ -> ς vs σ) is intentionally +// NOT covered here. It is a pre-existing gap in the untouched scalar path +// (Rust's char::to_lowercase() has no notion of the conditional Final_Sigma +// rule) unrelated to the ASCII fast path added by this file's issue, and +// asserting Node's correct output here would fail on main regardless of this +// fix. Tracked separately as #10116. + +// ---- Cherokee (Unicode 8.0 added case pairs) ---- +console.log("cherokee-lower:" + "Ꭰ".toLowerCase()); // U+AB70 +console.log("cherokee-upper:" + "ꭰ".toUpperCase()); // U+13A0 + +// ---- Deseret (astral, surrogate pair) ---- +console.log("deseret-lower:" + "𐐀".toLowerCase()); // U+10428 -> 𐐨 +console.log("deseret-lower-units:" + JSON.stringify(units("𐐀".toLowerCase()))); +console.log("deseret-upper:" + "𐐨".toUpperCase()); // U+10400 -> 𐐀 + +// ---- ASCII except for one trailing multi-byte character ---- +const asciiPlusOne = "hello" + "é"; // "helloé" +console.log("ascii-plus-one-upper:" + asciiPlusOne.toUpperCase()); // "HELLOÉ" +console.log("ascii-plus-one-lower:" + asciiPlusOne.toLowerCase()); // "helloé" + +// ---- ASCII prefix followed by a lone surrogate: must NOT take the ASCII +// fast path (bytes.is_ascii() is false), and the lone surrogate must survive +// verbatim through both directions. ---- +const asciiPlusLone = "ABC\ud800"; +show("ascii-plus-lone-src-units", units(asciiPlusLone)); +show("ascii-plus-lone-lower-units", units(asciiPlusLone.toLowerCase())); +show("ascii-plus-lone-upper-units", units(asciiPlusLone.toUpperCase())); +console.log("ascii-plus-lone-lower-len:" + asciiPlusLone.toLowerCase().length); +console.log("ascii-plus-lone-upper-len:" + asciiPlusLone.toUpperCase().length); From 59141882b809b35131ff726c51db38ce57a0f026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:15:42 +0200 Subject: [PATCH 04/19] chore: rename changelog fragment to PR #10117 Claude-Session: https://claude.ai/code/session_013naeTjgijAXt8PwpQEKkbu (cherry picked from commit aa1e9e16d5793570782ff4662a817bd0c90749ea) --- ...case-ascii-fastpath.md => 10117-string-case-ascii-fastpath.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10090-string-case-ascii-fastpath.md => 10117-string-case-ascii-fastpath.md} (100%) diff --git a/changelog.d/10090-string-case-ascii-fastpath.md b/changelog.d/10117-string-case-ascii-fastpath.md similarity index 100% rename from changelog.d/10090-string-case-ascii-fastpath.md rename to changelog.d/10117-string-case-ascii-fastpath.md From 5b6d7263440dceac53115ca4bafdaccc3e59fb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:18:04 +0200 Subject: [PATCH 05/19] perf(runtime): make Function.prototype.bind's name/length metadata lazy js_function_bind built the "bound " + target-name string, allocated a runtime string for it, and inserted two set_builtin_property_attrs records for .name/.length on every call, even when neither property is ever read. The attrs calls were redundant: a closure with no dynamic-prop table entry for name/length already defaults correctly (non-enumerable, non-writable, configurable) at every site that observes them. The name string is now synthesized and cached lazily, on first actual .name read, through bound_function_lazy_name - wired into the general closure property-get path, Object.getOwnPropertyDescriptor, and console.log's function formatter, so the value is correct regardless of whether bind itself ever computed it. Get(Target, "name") still runs synchronously at bind time (only the raw value, no string building) so a throwing name getter on the target still fails bind() itself, matching spec and Test262's bind/instance-name-error.js. Also roots the bind target, bound this, the name snapshot, the partial-args array, and the bound closure through a RuntimeHandleScope across every allocating call in js_function_bind, closing a latent staleness gap across the this-boxing/getter/array/closure-alloc calls. Refs #10084. Claude-Session: https://claude.ai/code/session_011B1Jqq3tKredbFkx4t7yaN (cherry picked from commit b0ae0004e474c6ccf6f498ad386d0f8e88f11144) --- .../perry-runtime/src/builtins/formatting.rs | 32 ++- crates/perry-runtime/src/closure/dispatch.rs | 2 +- .../src/closure/dispatch/bound.rs | 207 +++++++++++++----- .../src/closure/dynamic_props.rs | 15 ++ crates/perry-runtime/src/closure/mod.rs | 2 +- .../test_gap_10084_bind_lazy_name_length.ts | 114 ++++++++++ 6 files changed, 306 insertions(+), 66 deletions(-) create mode 100644 test-files/test_gap_10084_bind_lazy_name_length.ts diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index d1d7cf66ce..f3fc60928c 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -290,13 +290,31 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader registered_name_string(func_ptr as usize).filter(|n| !n.is_empty()) } }; - let label = match registry_name.or_else(|| { - props - .iter() - .find(|(k, _)| k == "name") - .and_then(|(_, v)| jsvalue_string_content(*v)) - .filter(|n| !n.is_empty()) - }) { + let label = match registry_name + .or_else(|| { + props + .iter() + .find(|(k, _)| k == "name") + .and_then(|(_, v)| jsvalue_string_content(*v)) + .filter(|n| !n.is_empty()) + }) + .or_else(|| { + // #10084: a `Function.prototype.bind` result's `.name` is built + // lazily and so may be absent from both the func-ptr registry + // (bound closures share the `BOUND_FUNCTION_FUNC_PTR` sentinel, + // never registered with a per-instance name) and the `props` + // snapshot above (taken before any read materialized it). + // Synthesize (and cache) it the same way any other reader of + // `.name` would. + unsafe { + ((*closure_ptr).func_ptr == crate::closure::BOUND_FUNCTION_FUNC_PTR).then(|| { + jsvalue_string_content(crate::closure::bound_function_lazy_name( + closure_ptr as usize, + )) + }) + } + .flatten() + }) { Some(name) => format!("[Function: {name}]"), None => "[Function (anonymous)]".to_string(), }; diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs index bdfb309264..54ec8dc8e4 100644 --- a/crates/perry-runtime/src/closure/dispatch.rs +++ b/crates/perry-runtime/src/closure/dispatch.rs @@ -21,7 +21,7 @@ mod validate; mod value_call; pub(crate) use bound::{ - bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, + bound_function_lazy_name, bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, reify_function_method_value, }; pub use bound::{dispatch_bound_function, dispatch_bound_method, js_function_bind}; diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index f1c0e8d27d..c44f61dab4 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -435,20 +435,79 @@ pub(crate) fn rebind_explicit_this(target: f64, this_arg: f64) -> f64 { f64::from_bits(crate::closure::clone_closure_rebind_this(bits, this_arg)) } -/// Read a callable's own `name` *property* as a Rust `String`, if present and a -/// String value. Covers names installed by `Object.defineProperty(fn, "name", -/// …)` and the `"bound …"` name a prior `.bind()` stores, neither of which is -/// visible through the declared-name func-ptr registry. Returns `None` when no -/// such property exists or it isn't a String. -unsafe fn read_function_name_property(closure_ptr: usize) -> Option { +/// Fallback target name for [`bound_function_lazy_name`]: the target-name +/// snapshot captured at bind time (capture slot 3) was not a String (no +/// override, or an explicit non-String `Object.defineProperty` value — both +/// collapse to the same declared-name fallback, matching the prior eager +/// behavior), so fall back to the target's *declared* name — the func-ptr +/// registry for a closure, or the class registry for a class ref. Both +/// registries are immutable for the life of the program, so resolving them +/// lazily here instead of at bind time is observationally identical. +unsafe fn bound_target_declared_name(target_value: f64) -> String { use crate::value::JSValue; - let name_val = crate::closure::closure_get_dynamic_prop(closure_ptr, "name"); - let name_jv = JSValue::from_bits(name_val.to_bits()); - if !name_jv.is_any_string() { - return None; + let target_jv = JSValue::from_bits(target_value.to_bits()); + if target_jv.is_pointer() { + let target_closure = target_jv.as_pointer::(); + if !target_closure.is_null() && (*target_closure).type_tag == CLOSURE_MAGIC { + return crate::builtins::function_name_for_ptr((*target_closure).func_ptr as usize) + .unwrap_or_default(); + } + return String::new(); } - let hdr = crate::builtins::js_string_coerce(name_val); - crate::object::has_own_helpers::str_from_string_header(hdr).map(str::to_owned) + let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { + ((target_value.to_bits() >> 48) == 0x7FFE + && crate::object::class_prototype_ref_id(target_value).is_none()) + .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) + }); + target_class_id + .and_then(crate::object::class_name_for_id) + .unwrap_or_default() +} + +/// Lazily synthesize and cache a bound function's `.name`. `ptr` must be a +/// live `BOUND_FUNCTION_FUNC_PTR` closure with no `"name"` entry in its own +/// dynamic-prop table yet (the caller — [`closure_get_dynamic_prop`], +/// `Object.getOwnPropertyDescriptor`, and the console-formatting path — all +/// check that first). Refs #10084: `js_function_bind` no longer builds +/// `"bound " + targetName` or writes it to the dynamic-prop table on every +/// call; that work happens here, once, on first actual `.name` read, and the +/// result is cached via `closure_set_dynamic_prop` so repeat reads are O(1). +/// +/// Capture slot 3 holds the raw `Get(Target, "name")` value snapshotted at +/// bind time (a String value, or a non-String sentinel — see +/// `bound_target_declared_name`); capture slot 0 holds the original bind +/// target, used for the declared-name fallback and, transitively, for a +/// chained `f.bind().bind()` (reading slot 3 of an inner bound closure +/// recurses into this same function through `closure_get_dynamic_prop`). +/// +/// GC safety: `ptr`'s address must not be trusted across the allocating +/// `js_string_coerce`/`js_string_from_bytes` calls below, so it is rooted and +/// re-derived afterward before the final cache write (mirrors +/// `js_object_get_own_property_descriptor`'s closure arm, #6943). +pub(crate) unsafe fn bound_function_lazy_name(ptr: usize) -> f64 { + use crate::value::JSValue; + + let scope = crate::gc::RuntimeHandleScope::new(); + let ptr_handle = scope.root_raw_mut_ptr(ptr as *mut u8); + let (name_value, ptr_raw) = ptr_handle.across_mut::(|| { + let closure = ptr as *const ClosureHeader; + let name_hint = js_closure_get_capture_f64(closure, 3); + let target_name = if JSValue::from_bits(name_hint.to_bits()).is_any_string() { + let hdr = crate::builtins::js_string_coerce(name_hint); + crate::object::has_own_helpers::str_from_string_header(hdr) + .map(str::to_owned) + .unwrap_or_default() + } else { + bound_target_declared_name(js_closure_get_capture_f64(closure, 0)) + }; + let bound_name = format!("bound {target_name}"); + let name_ptr = + crate::string::js_string_from_bytes(bound_name.as_ptr(), bound_name.len() as u32); + f64::from_bits(JSValue::string_ptr(name_ptr).bits()) + }); + let ptr = ptr_raw as usize; + crate::closure::closure_set_dynamic_prop(ptr, "name", name_value); + name_value } /// `Function.prototype.bind(thisArg, ...boundArgs)` — create a distinct bound @@ -457,8 +516,18 @@ unsafe fn read_function_name_property(closure_ptr: usize) -> Option { /// the BOUND_FUNCTION_FUNC_PTR sentinel; `js_closure_callN` / /// `js_native_call_value` route it through `dispatch_bound_function`. /// -/// `.name` is set to `"bound " + target.name` and `.length` to -/// `max(0, target.length - boundArgs.length)`, matching Node. Refs #2840. +/// `.name` reads as `"bound " + target.name` and `.length` as +/// `max(0, target.length - boundArgs.length)`, matching Node — but neither is +/// built eagerly (#10084). `.length`'s numeric value is cheap to compute +/// (no string work) and is stored eagerly as before; `.name`'s `"bound "` +/// string and the `set_builtin_property_attrs` calls a prior version made +/// unconditionally for both are gone from this function entirely — absent a +/// dynamic-prop table entry, a closure's `name`/`length` already default to +/// `{writable:false, enumerable:false, configurable:true}` everywhere they're +/// observed (`closure_dynamic_enumerable_props`, +/// `js_object_get_own_property_descriptor`, `closure_set_field_by_name`), so +/// those calls were redundant. `.name`'s string is built lazily by +/// `bound_function_lazy_name`, on first actual read. Refs #2840. #[no_mangle] pub unsafe extern "C" fn js_function_bind( target_value: f64, @@ -485,28 +554,67 @@ pub unsafe extern "C" fn js_function_bind( && crate::object::class_prototype_ref_id(target_value).is_none()) .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) }); - let target_closure = if target_jv.is_pointer() { + let target_is_closure = if target_jv.is_pointer() { let ptr = target_jv.as_pointer::(); if ptr.is_null() || (*ptr).type_tag != CLOSURE_MAGIC { // Preserve the existing conservative pass-through for callable // native handles that do not use the closure representation. return target_value; } - Some(ptr) + true } else if target_class_id.is_some() { // ClassRefs are callable/constructable INT32-tagged values rather // than heap closures. They still need a real BoundFunction wrapper // so `new C.bind(_, ...args)()` prepends its captured arguments. - None + false } else { return target_value; }; + // Root the bind target across every allocating call below (`this` + // boxing, `Get(Target, "name")` — which may run a user getter — the + // partial-args array, and the bound closure itself) so none of them can + // leave a stale address in the bound closure's own capture slots after a + // copying minor. + let scope = crate::gc::RuntimeHandleScope::new(); + let target_h = scope.root_nanbox_f64(target_value); + let bound_this = if args_len >= 1 && !args_ptr.is_null() { - coerce_call_this(target_value, *args_ptr) + let arg0 = *args_ptr; + target_h + .across_nanbox(|| coerce_call_this(target_h.get_nanbox_f64(), arg0)) + .0 } else { f64::from_bits(crate::value::TAG_UNDEFINED) }; + let this_h = scope.root_nanbox_f64(bound_this); + + // Spec step 12-13: `Get(Target, "name")` must run now, synchronously — a + // target whose `name` getter throws must fail `bind()` itself (Test262 + // bind/instance-name-error.js), not a later `.name` read on the bound + // function. A class target has no analogous accessor path, so + // TAG_UNDEFINED (the "no override" sentinel `bound_function_lazy_name` + // recognizes via `bound_target_declared_name`) is captured directly. This + // is the ONLY work `.name` does at bind time now — see + // `bound_function_lazy_name` for the deferred "bound " + name build. + let name_hint = if target_is_closure { + target_h + .across_nanbox(|| { + let tclosure = + JSValue::from_bits(target_h.get_nanbox_f64().to_bits()).as_pointer::(); + crate::closure::closure_get_dynamic_prop(tclosure as usize, "name") + }) + .0 + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + }; + // `name_hint` may itself be a heap string pointer (a real `Get(Target, + // "name")` result) — root it too, or it would go stale across the + // partial-args array / bound-closure allocations below, and we'd write a + // dangling capture slot 3 (exactly the "lazily-derived name retains a + // stale address" failure mode this fix must avoid). + let name_h = scope.root_nanbox_f64(name_hint); + let bound_arg_count = args_len.saturating_sub(1); // Build the partial-args array (NaN-boxed values copied as-is). @@ -520,17 +628,38 @@ pub unsafe extern "C" fn js_function_bind( } else { std::ptr::null_mut() }; + let args_h = (!bound_args_arr.is_null()).then(|| scope.root_raw_mut_ptr(bound_args_arr)); - // Allocate the bound closure with 3 capture slots. - let bound = crate::closure::js_closure_alloc(BOUND_FUNCTION_FUNC_PTR, 3); + // Allocate the bound closure with 4 capture slots: target, bound this, + // partial-args array, and the `.name` snapshot above. + let bound = crate::closure::js_closure_alloc(BOUND_FUNCTION_FUNC_PTR, 4); + let bound_h = scope.root_raw_mut_ptr(bound as *mut u8); + let bound = bound_h.get_raw_mut_ptr::(); + let target_value = target_h.get_nanbox_f64(); + let bound_this = this_h.get_nanbox_f64(); + let name_hint = name_h.get_nanbox_f64(); + let bound_args_arr = args_h + .as_ref() + .map(|h| h.get_raw_mut_ptr::()) + .unwrap_or(std::ptr::null_mut()); js_closure_set_capture_f64(bound, 0, target_value); js_closure_set_capture_f64(bound, 1, bound_this); js_closure_set_capture_ptr(bound, 2, bound_args_arr as i64); + js_closure_set_capture_f64(bound, 3, name_hint); + + // Re-derive the target closure pointer from the (possibly refreshed) + // `target_value` for the `.length` read below — `target_is_closure`'s + // classification doesn't change, but the address might have. + let target_closure = target_is_closure + .then(|| JSValue::from_bits(target_value.to_bits()).as_pointer::()); // Spec `.length` = max(0, ToIntegerOrInfinity(Get(target, "length")) - // boundArgs.length). An `Object.defineProperty(fn, "length", {value})` // override (own dynamic prop) wins over the registered declared length, - // and the value may be NaN (→ 0), ±Infinity, or beyond int32. + // and the value may be NaN (→ 0), ±Infinity, or beyond int32. This read + // is an own-data-property lookup only (no accessor/getter support), so + // unlike `.name` above it cannot run arbitrary code and needs no + // rooting of its own. let target_len_f = if let Some(target_closure) = target_closure { match crate::closure::closure_get_own_dynamic_prop(target_closure as usize, "length") { Some(v) => { @@ -569,42 +698,6 @@ pub unsafe extern "C" fn js_function_bind( ); } - // Spec `.name` = "bound " + targetName, where targetName is `Get(Target, - // "name")` (the empty string when that is not a String). Read the target's - // `name` *property* first — it reflects an `Object.defineProperty(fn, - // "name", …)` override and a previous `.bind()`'s `"bound …"` name (so - // `f.bind().bind().name` chains to `"bound bound …"`). Fall back to the - // declared name from the func-ptr registry for plain named functions, which - // don't materialize a `name` data property. - let target_name = if let Some(target_closure) = target_closure { - read_function_name_property(target_closure as usize) - .or_else(|| crate::builtins::function_name_for_ptr((*target_closure).func_ptr as usize)) - .unwrap_or_default() - } else { - target_class_id - .and_then(crate::object::class_name_for_id) - .unwrap_or_default() - }; - let bound_name = format!("bound {target_name}"); - let name_ptr = - crate::string::js_string_from_bytes(bound_name.as_ptr(), bound_name.len() as u32); - let name_value = f64::from_bits(JSValue::string_ptr(name_ptr).bits()); - crate::closure::closure_set_dynamic_prop(bound as usize, "name", name_value); - // Spec attributes for a function's own `name`/`length`: - // { writable: false, enumerable: false, configurable: true }. Without - // these the dynamic-prop `name` slot defaults to enumerable and shows - // up in for-in / Object.keys (Test262 bind/instance-name*). - crate::object::set_builtin_property_attrs( - bound as usize, - "name".to_string(), - crate::object::PropertyAttrs::new(false, false, true), - ); - crate::object::set_builtin_property_attrs( - bound as usize, - "length".to_string(), - crate::object::PropertyAttrs::new(false, false, true), - ); - crate::gc::runtime_write_barrier_root_heap_word(bound as u64); f64::from_bits(JSValue::pointer(bound as *mut u8).bits()) } diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 06e760bd7c..d2fbcaf4dd 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -838,6 +838,21 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { } return crate::closure::closure_length(ptr as *const ClosureHeader).unwrap_or(0) as f64; } + // #10084: a `Function.prototype.bind` result's `.name` is built lazily — + // `js_function_bind` skips the "bound " + target-name string allocation + // on every call and only snapshots the raw target-name value (capture + // slot 3). Synthesize and cache the real string here, on first read, so + // every other reader of a closure's `.name` (ordinary property-get below, + // `Object.getOwnPropertyDescriptor`, a chained `.bind()`'s own read of an + // already-bound target) gets it for free through this one seam. Once + // cached, the `closure_props` lookup above intercepts before this runs + // again. + if prop == "name" && !closure_is_key_deleted(ptr, "name") { + let func_ptr = unsafe { (*(ptr as *const ClosureHeader)).func_ptr }; + if func_ptr == crate::closure::BOUND_FUNCTION_FUNC_PTR { + return unsafe { crate::closure::bound_function_lazy_name(ptr) }; + } + } // #36 / #321: own prop miss — walk the closure's static prototype chain // (`Object.setPrototypeOf(closure, protoObj)`). Reads a string-keyed field // off the proto object. Lets effect's `TagClass._op` resolve to "Tag" on diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 20666f8b15..93177371db 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -53,7 +53,7 @@ pub use registry::{ }; pub(crate) use dispatch::{ - bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, + bound_function_lazy_name, bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, reify_function_method_value, reset_throw_not_callable_counter, }; pub use dispatch::{ diff --git a/test-files/test_gap_10084_bind_lazy_name_length.ts b/test-files/test_gap_10084_bind_lazy_name_length.ts new file mode 100644 index 0000000000..a636137845 --- /dev/null +++ b/test-files/test_gap_10084_bind_lazy_name_length.ts @@ -0,0 +1,114 @@ +// Gap test for #10084: `Function.prototype.bind` eagerly materialized the +// bound function's `.name` string and `set_builtin_property_attrs` records +// for `.name`/`.length` on every call, even when neither was ever read. The +// fix defers building "bound " + name (and the dynamic-prop cache entry) to +// first actual `.name` read, and drops the now-redundant attrs calls +// entirely. This test pins the full spec surface the fix must preserve. + +function add(this: { bias: number }, a: number, b: number): number { + return this.bias + a + b; +} + +// A bind that never reads .name/.length must still work correctly when +// called — the lazy-metadata path must not affect invocation. +const bound = add.bind({ bias: 10 }, 1); +console.log("call result:", bound(2)); // 13 + +// `.name` reads "bound " + target name, and `.length` = max(0, target.length +// - boundArgs.length). +console.log("name:", bound.name); // bound add +console.log("length:", bound.length); // 1 + +// Chained bind: reading the outer bound function's name must recurse through +// the inner (also-lazy) bound function's own name synthesis. +const chained = add.bind({ bias: 0 }).bind({ bias: 0 }); +console.log("chained name:", chained.name); // bound bound add +console.log("chained length:", chained.length); // 2 + +// `Object.defineProperty` override on the target, observed through bind. +function target() {} +Object.defineProperty(target, "name", { value: "renamedTarget" }); +console.log("override name:", target.bind().name); // bound renamedTarget + +// Non-string override on the target falls back to the empty string, not the +// declared name. Matches Test262's bind/instance-name-non-string.js exactly: +// the function expression is passed directly to `defineProperty` (never +// bound to a variable, so no NamedEvaluation name inference applies) — a +// truly nameless target sidesteps a pre-existing, unrelated ambiguity +// between "no name override" and "name explicitly set to `undefined`" (both +// read back as the same sentinel) that a named target would otherwise hit. +const anon = Object.defineProperty(function () {}, "name", { + value: undefined, +}); +console.log("non-string override name:", anon.bind().name); // bound + +// name/length are non-enumerable: absent from Object.keys/for-in, and +// hasOwnProperty still reports them present. +const enumKeys: string[] = []; +for (const k in bound) enumKeys.push(k); +console.log("for-in keys:", JSON.stringify(enumKeys)); // [] +console.log("Object.keys:", JSON.stringify(Object.keys(bound))); // [] +console.log( + "hasOwnProperty name/length:", + bound.hasOwnProperty("name"), + bound.hasOwnProperty("length"), +); // true true + +// Property descriptor attributes match spec defaults even though bind never +// wrote them explicitly. +const desc = Object.getOwnPropertyDescriptor(bound, "name")!; +console.log( + "name descriptor:", + desc.value, + desc.writable, + desc.enumerable, + desc.configurable, +); // bound add false false true +const lenDesc = Object.getOwnPropertyDescriptor(bound, "length")!; +console.log( + "length descriptor:", + lenDesc.value, + lenDesc.writable, + lenDesc.enumerable, + lenDesc.configurable, +); // 1 false false true + +// A write to .name/.length throws under strict mode (non-writable) — +// this file runs as an ES module, so every write attempt is strict. +let nameWriteThrew = false; +try { + (bound as any).name = "clobbered"; +} catch { + nameWriteThrew = true; +} +let lengthWriteThrew = false; +try { + (bound as any).length = 99; +} catch { + lengthWriteThrew = true; +} +console.log( + "write threw / unchanged:", + nameWriteThrew, + lengthWriteThrew, + bound.name, + bound.length, +); // true true bound add 1 + +// Beyond-u32 (here +Infinity) target length forwards through bind's own +// dynamic-prop fallback path. +function infLen() {} +Object.defineProperty(infLen, "length", { value: Infinity }); +console.log("infinity length:", infLen.bind().length); // Infinity + +// A class target still binds correctly and reports its name lazily. +class Widget { + static tag = "w"; +} +const BoundWidget = Widget.bind(null); +console.log("class bind name:", BoundWidget.name); // bound Widget + +// console.log on a bound function whose .name was NEVER read must still +// display the synthesized name (formatting must not bypass the lazy path). +const neverRead = add.bind({ bias: 0 }, 1); +console.log("display:", neverRead); From 57e7767bbdd437645a8fa52a340dad70a9b6ad3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:18:37 +0200 Subject: [PATCH 06/19] docs: add changelog fragment for PR 10119 Claude-Session: https://claude.ai/code/session_011B1Jqq3tKredbFkx4t7yaN (cherry picked from commit d24d7a909dc8070dc35a966ff960adf90888a21f) --- changelog.d/10119-bind-lazy-name-length.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 changelog.d/10119-bind-lazy-name-length.md diff --git a/changelog.d/10119-bind-lazy-name-length.md b/changelog.d/10119-bind-lazy-name-length.md new file mode 100644 index 0000000000..eeb46c9196 --- /dev/null +++ b/changelog.d/10119-bind-lazy-name-length.md @@ -0,0 +1,14 @@ +### Performance + +- **`Function.prototype.bind` no longer eagerly builds `"bound " + name` or its + `name`/`length` property-attribute records on every call.** A bind whose + result never reads `.name`/`.length` now performs neither the runtime-string + allocation nor the two `set_builtin_property_attrs` side-table inserts — + redundant, since a closure with no dynamic-prop entry for those keys already + defaults correctly everywhere it's observed. `.name`'s string is built and + cached lazily on first actual read, through the same seam every other reader + of a closure's `.name` already goes through, so `Object. + getOwnPropertyDescriptor`, `console.log`, and a chained `.bind().bind()` all + still see the right value. `Get(Target, "name")` still runs synchronously at + bind time, so a throwing `name` getter on the target still fails `bind()` + itself. Refs #10084. From a10f488e18299e09cb80b5275b419968270a7a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:04:12 +0200 Subject: [PATCH 07/19] perf(codegen): read materialized lazy JSON arrays from the indexed cache A JSON.parse result carries GC_TYPE_LAZY_ARRAY, so the indexed inline cache's brand check (obj_type == GC_TYPE_ARRAY) rejected it and every element read fell through to arrlike.ic.miss, re-classifying the same receiver three more times: js_packed_arraylike_index_get, then js_array_get_f64, then json_tape::cached_read::lazy_get. R22-R26 made that last helper allocation-free, but the call chain in front of it was untouched, so `rows[7].id` on a parsed array still cost ~237 retired instructions against Node's handful of cycles. Serve the read in the cache instead, once a scan or the random-access flip has installed the ordinary array, on exactly the proof lazy_get already takes: live unforwarded GC_TYPE_ARRAY, no descriptor overrides, length within capacity and its plausibility bound, dense in-bounds index. Prototype invalidation is also honoured, which the ordinary tier checks and lazy_get does not, so the admitted set is no wider. lazy_get refreshes the header's cached_length mirror when it takes this path. A cache cannot write, so it requires the mirror to already agree and routes a disagreement to the miss helper, which refreshes it and lets the next read hit. A grown or shrunk array therefore never reports a stale length through a fast-path read. Holes, sparse tape-backed reads, growth-forwarding stubs and every exotic receiver keep the unchanged dispatcher. (cherry picked from commit 6f29fb0d03068a49a0a8bfc82b77b1464e543f0d) --- .../expr/index_get/inline_dyn_typed_array.rs | 125 +++++++++++++++++- crates/perry-runtime/src/json_tape.rs | 11 ++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 53c1fa6cac..00425a0a40 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -466,9 +466,132 @@ pub(super) fn lower_inline_dyn_typed_array_get( let elem_bounds_label = ctx.block_label(elem_bounds_idx); let elem_load_label = ctx.block_label(elem_load_idx); let elem_value_label = ctx.block_label(elem_value_idx); + // A `JSON.parse` result is `GC_TYPE_LAZY_ARRAY`, not `GC_TYPE_ARRAY`, so + // every one of its indexed reads used to fall straight through to + // `arrlike.ic.miss` and re-classify the receiver three more times + // (`js_packed_arraylike_index_get` -> `js_array_get_f64` -> + // `json_tape::cached_read::lazy_get`). Once a scan or a random-access flip + // has installed the ordinary array, that whole chain resolves one word; + // serve it here instead, on exactly the proof `lazy_get` already uses. + let lazy_kind_idx = ctx.new_block("arrlike.lazy.kind"); + let lazy_header_idx = ctx.new_block("arrlike.lazy.header"); + let lazy_guard_idx = ctx.new_block("arrlike.lazy.guard"); + let lazy_load_idx = ctx.new_block("arrlike.lazy.load"); + let lazy_kind_label = ctx.block_label(lazy_kind_idx); + let lazy_header_label = ctx.block_label(lazy_header_idx); + let lazy_guard_label = ctx.block_label(lazy_guard_idx); + let lazy_load_label = ctx.block_label(lazy_load_idx); + ctx.current_block = object_brand_idx; ctx.block() - .cond_br(&is_array, &object_array_guard_label, &elem_kind_label); + .cond_br(&is_array, &object_array_guard_label, &lazy_kind_label); + + // `GC_TYPE_LAZY_ARRAY` (perry-runtime `gc/types.rs`). Anything else keeps + // the existing Array-subclass probe below. + ctx.current_block = lazy_kind_idx; + let lazy_is_lazy = ctx.block().icmp_eq(I8, &gc_type, "9"); + ctx.block() + .cond_br(&lazy_is_lazy, &lazy_header_label, &elem_kind_label); + + // `LazyArrayHeader::materialized` is word 4 (offset 32; pinned by a const + // assert in perry-runtime `json_tape.rs`). Null means the array is still + // tape-backed and only the sparse per-element cache can answer, which + // needs the bitmap probe in `lazy_get` — keep that on the miss path. + ctx.current_block = lazy_header_idx; + let lazy_materialized_addr = ctx.block().add(I64, &object_raw, "32"); + let lazy_materialized_ptr = ctx.block().inttoptr(I64, &lazy_materialized_addr); + let lazy_materialized = ctx.block().load(I64, &lazy_materialized_ptr); + let lazy_has_array = ctx.block().icmp_ne(I64, &lazy_materialized, "0"); + ctx.block() + .cond_br(&lazy_has_array, &lazy_guard_label, &object_miss_label); + + // The same proof `cached_read::lazy_get` takes before its inline load: a + // live unforwarded ordinary Array, no descriptor overrides, no prototype + // invalidation, and a dense in-capacity index. A growth-forwarding stub + // keeps its own GC header and fails `obj_type`/`FORWARDED`, so it routes + // to the resolver exactly as before (#9717). + // + // `cached_length` is the length mirror codegen reads for `.length` at + // offset 0. `lazy_get` refreshes it when it takes this path; the cache + // cannot write, so it instead requires the mirror to already agree and + // sends a disagreement to the miss helper — which refreshes it, making the + // next read hit. That keeps a grown or shrunk array from reporting a stale + // length through a fast-path read. + ctx.current_block = lazy_guard_idx; + let lazy_type_addr = ctx.block().sub(I64, &lazy_materialized, "8"); + let lazy_type_ptr = ctx.block().inttoptr(I64, &lazy_type_addr); + let lazy_type = ctx.block().load(I8, &lazy_type_ptr); + let lazy_is_array = ctx.block().icmp_eq(I8, &lazy_type, "1"); + let lazy_flags_addr = ctx.block().sub(I64, &lazy_materialized, "7"); + let lazy_flags_ptr = ctx.block().inttoptr(I64, &lazy_flags_addr); + let lazy_flags = ctx.block().load(I8, &lazy_flags_ptr); + let lazy_fwd = ctx.block().and(I8, &lazy_flags, "128"); + let lazy_not_fwd = ctx.block().icmp_eq(I8, &lazy_fwd, "0"); + let lazy_reserved_addr = ctx.block().sub(I64, &lazy_materialized, "6"); + let lazy_reserved_ptr = ctx.block().inttoptr(I64, &lazy_reserved_addr); + let lazy_reserved = ctx.block().load(I16, &lazy_reserved_ptr); + let lazy_descriptor_bits = ctx.block().and(I16, &lazy_reserved, "1024"); + let lazy_no_descriptors = ctx.block().icmp_eq(I16, &lazy_descriptor_bits, "0"); + let lazy_invalidated = ctx + .block() + .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let lazy_default_prototypes = ctx.block().icmp_eq(I8, &lazy_invalidated, "0"); + let lazy_array_ptr = ctx.block().inttoptr(I64, &lazy_materialized); + let lazy_length = ctx.block().load(I32, &lazy_array_ptr); + let lazy_capacity_addr = ctx.block().add(I64, &lazy_materialized, "4"); + let lazy_capacity_ptr = ctx.block().inttoptr(I64, &lazy_capacity_addr); + let lazy_capacity = ctx.block().load(I32, &lazy_capacity_ptr); + let lazy_mirror_ptr = ctx.block().inttoptr(I64, &object_raw); + let lazy_mirror = ctx.block().load(I32, &lazy_mirror_ptr); + let lazy_mirror_fresh = ctx.block().icmp_eq(I32, &lazy_mirror, &lazy_length); + let lazy_length_i64 = ctx.block().zext(I32, &lazy_length, I64); + let lazy_capacity_i64 = ctx.block().zext(I32, &lazy_capacity, I64); + let lazy_in_bounds = ctx.block().icmp_ult(I64, &object_idx_i64, &lazy_length_i64); + let lazy_within_capacity = ctx + .block() + .icmp_ule(I64, &lazy_length_i64, &lazy_capacity_i64); + // `lazy_get`'s own plausibility bound on an installed array. Keeping it + // makes this cache admit exactly the set the runtime helper admits, so the + // two can never disagree about which reads are fast. + let lazy_length_plausible = ctx + .block() + .icmp_ule(I64, &lazy_length_i64, "100000000"); + let lazy_ok = ctx.block().and(I1, &lazy_is_array, &lazy_not_fwd); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_no_descriptors); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_default_prototypes); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_mirror_fresh); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_in_bounds); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_within_capacity); + let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_length_plausible); + ctx.block() + .cond_br(&lazy_ok, &lazy_load_label, &object_miss_label); + + // A hole must still consult the prototype chain, so it keeps the complete + // dispatcher rather than becoming `undefined` here. + ctx.current_block = lazy_load_idx; + let lazy_element_word = ctx.block().add(I64, &object_idx_i64, "1"); + let lazy_element_ptr = + ctx.block() + .gep_inbounds(I64, &lazy_array_ptr, &[(I64, &lazy_element_word)]); + let lazy_raw = ctx.block().load(DOUBLE, &lazy_element_ptr); + let lazy_raw_bits = ctx.block().bitcast_double_to_i64(&lazy_raw); + let lazy_is_hole = ctx + .block() + .icmp_eq(I64, &lazy_raw_bits, crate::nanbox::TAG_HOLE_I64); + let lazy_value_idx = ctx.new_block("arrlike.lazy.value"); + let lazy_value_label = ctx.block_label(lazy_value_idx); + ctx.block() + .cond_br(&lazy_is_hole, &object_miss_label, &lazy_value_label); + ctx.current_block = lazy_value_idx; + let lazy_value = if coerce_slow_to_number { + ctx.block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &lazy_raw)]) + } else { + lazy_raw + }; + let lazy_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + kind_incoming.push((lazy_value, lazy_end_label)); ctx.current_block = elem_kind_idx; let elem_is_object = ctx.block().icmp_eq(I8, &gc_type, "2"); ctx.block() diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index ffe70a0699..0515fb1f86 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1153,6 +1153,17 @@ const _: () = assert!( `.length` as a raw u32 load there" ); +// `materialized` is the second codegen contract on this struct. The indexed +// inline cache (`perry-codegen` `expr/index_get/inline_dyn_typed_array.rs`) +// reads this slot directly to serve `lazy[i]` without a runtime call, exactly +// as `cached_read::lazy_get` does. A reordered field would send that fast path +// at an unrelated word, so pin the offset the same way `cached_length` is. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized) == 32, + "LazyArrayHeader::materialized must stay at offset 32 — the indexed inline \ + cache loads the installed array from that word" +); + /// #7478: how long a run of consecutive ascending cold reads has to get /// before we stop materializing element-by-element and hand the whole /// array to the batch parser. From a75fc1ba5bef995c4ac9e9aa136151acd2e5636b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:12:03 +0200 Subject: [PATCH 08/19] perf(codegen): probe the lazy sparse element cache from the indexed cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The materialized tier only fires once a scan or the adaptive random-access flip has installed an ordinary array. An array small enough that the walk never trips that flip — 120 records in the benchmark's 16 KiB fixture, and any repeated or clustered read pattern — stays tape-backed for the life of the program and kept missing the cache entirely, which is why the 16 KiB field walk was the worst row in the matrix at 10.6x Node. Inline lazy_get's sparse branch for that case: bounds against the header's cached_length, non-null bitmap and element words, the bitmap bit, then the parallel element slot. The bitmap is the liveness test because JSValue::ZERO is a legal cached value, so a zero element word cannot serve as one. Out-of-bounds deliberately does not shortcut to undefined the way lazy_get does — the prototype chain stays the miss helper's job. Cold reads, holes, uncached slots and prototype invalidation all keep the unchanged dispatcher. (cherry picked from commit fd5221197a63503bf21ec27be97b0837d271182b) --- .../expr/index_get/inline_dyn_typed_array.rs | 98 ++++++++++++++++++- .../src/expr/index_get_claim_tests.rs | 12 +++ crates/perry-runtime/src/json_tape.rs | 14 +++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 00425a0a40..97096802b5 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -477,6 +477,16 @@ pub(super) fn lower_inline_dyn_typed_array_get( let lazy_header_idx = ctx.new_block("arrlike.lazy.header"); let lazy_guard_idx = ctx.new_block("arrlike.lazy.guard"); let lazy_load_idx = ctx.new_block("arrlike.lazy.load"); + let lazy_sparse_bounds_idx = ctx.new_block("arrlike.lazy.sparse.bounds"); + let lazy_sparse_probe_idx = ctx.new_block("arrlike.lazy.sparse.probe"); + let lazy_sparse_bit_idx = ctx.new_block("arrlike.lazy.sparse.bit"); + let lazy_sparse_load_idx = ctx.new_block("arrlike.lazy.sparse.load"); + let lazy_sparse_value_idx = ctx.new_block("arrlike.lazy.sparse.value"); + let lazy_sparse_bounds_label = ctx.block_label(lazy_sparse_bounds_idx); + let lazy_sparse_probe_label = ctx.block_label(lazy_sparse_probe_idx); + let lazy_sparse_bit_label = ctx.block_label(lazy_sparse_bit_idx); + let lazy_sparse_load_label = ctx.block_label(lazy_sparse_load_idx); + let lazy_sparse_value_label = ctx.block_label(lazy_sparse_value_idx); let lazy_kind_label = ctx.block_label(lazy_kind_idx); let lazy_header_label = ctx.block_label(lazy_header_idx); let lazy_guard_label = ctx.block_label(lazy_guard_idx); @@ -503,7 +513,93 @@ pub(super) fn lower_inline_dyn_typed_array_get( let lazy_materialized = ctx.block().load(I64, &lazy_materialized_ptr); let lazy_has_array = ctx.block().icmp_ne(I64, &lazy_materialized, "0"); ctx.block() - .cond_br(&lazy_has_array, &lazy_guard_label, &object_miss_label); + .cond_br(&lazy_has_array, &lazy_guard_label, &lazy_sparse_bounds_label); + + // Still tape-backed: the sparse per-element cache is the only thing that + // can answer without materializing a subtree, and it is what a repeated or + // clustered read pattern actually hits — an array small enough that the + // adaptive walk never trips the full-materialization flip stays here for + // the life of the program. `lazy_get`'s own sparse branch is three loads + // and a bit test, so inline exactly that and leave every cold read, hole + // and out-of-bounds index to the miss helper. + // + // Out-of-bounds deliberately does NOT shortcut to `undefined` here even + // though `lazy_get` does: the prototype chain is the miss helper's job. + ctx.current_block = lazy_sparse_bounds_idx; + let sparse_invalidated = ctx + .block() + .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let sparse_default_prototypes = ctx.block().icmp_eq(I8, &sparse_invalidated, "0"); + let sparse_length_ptr = ctx.block().inttoptr(I64, &object_raw); + let sparse_length = ctx.block().load(I32, &sparse_length_ptr); + let sparse_length_i64 = ctx.block().zext(I32, &sparse_length, I64); + let sparse_in_bounds = ctx + .block() + .icmp_ult(I64, &object_idx_i64, &sparse_length_i64); + let sparse_bounds_ok = ctx + .block() + .and(I1, &sparse_default_prototypes, &sparse_in_bounds); + ctx.block() + .cond_br(&sparse_bounds_ok, &lazy_sparse_probe_label, &object_miss_label); + + // `materialized_bitmap` is word 6 and `materialized_elements` word 5 + // (offsets 48 and 40; both pinned by const asserts in perry-runtime + // `json_tape.rs`). A null on either side means nothing has been cached yet. + ctx.current_block = lazy_sparse_probe_idx; + let sparse_bitmap_addr = ctx.block().add(I64, &object_raw, "48"); + let sparse_bitmap_slot = ctx.block().inttoptr(I64, &sparse_bitmap_addr); + let sparse_bitmap = ctx.block().load(I64, &sparse_bitmap_slot); + let sparse_elements_addr = ctx.block().add(I64, &object_raw, "40"); + let sparse_elements_slot = ctx.block().inttoptr(I64, &sparse_elements_addr); + let sparse_elements = ctx.block().load(I64, &sparse_elements_slot); + let sparse_has_bitmap = ctx.block().icmp_ne(I64, &sparse_bitmap, "0"); + let sparse_has_elements = ctx.block().icmp_ne(I64, &sparse_elements, "0"); + let sparse_probe_ok = ctx + .block() + .and(I1, &sparse_has_bitmap, &sparse_has_elements); + ctx.block() + .cond_br(&sparse_probe_ok, &lazy_sparse_bit_label, &object_miss_label); + + // The bitmap is the authoritative "this slot holds a materialized value" + // signal — `JSValue::ZERO` is a legal cached value, so a null/zero element + // word cannot be used as the liveness test. + ctx.current_block = lazy_sparse_bit_idx; + let sparse_word_index = ctx.block().lshr(I64, &object_idx_i64, "6"); + let sparse_word_offset = ctx.block().shl(I64, &sparse_word_index, "3"); + let sparse_word_addr = ctx.block().add(I64, &sparse_bitmap, &sparse_word_offset); + let sparse_word_ptr = ctx.block().inttoptr(I64, &sparse_word_addr); + let sparse_word = ctx.block().load(I64, &sparse_word_ptr); + let sparse_bit_index = ctx.block().and(I64, &object_idx_i64, "63"); + let sparse_shifted = ctx.block().lshr(I64, &sparse_word, &sparse_bit_index); + let sparse_bit = ctx.block().and(I64, &sparse_shifted, "1"); + let sparse_cached = ctx.block().icmp_ne(I64, &sparse_bit, "0"); + ctx.block() + .cond_br(&sparse_cached, &lazy_sparse_load_label, &object_miss_label); + + // A bitmap-set slot always holds a real materialized JSValue, so the hole + // test below can never fire; keep it anyway, since its only effect is to + // route an impossible value to the same helper that would have produced it. + ctx.current_block = lazy_sparse_load_idx; + let sparse_elem_offset = ctx.block().shl(I64, &object_idx_i64, "3"); + let sparse_elem_addr = ctx.block().add(I64, &sparse_elements, &sparse_elem_offset); + let sparse_elem_ptr = ctx.block().inttoptr(I64, &sparse_elem_addr); + let sparse_raw = ctx.block().load(DOUBLE, &sparse_elem_ptr); + let sparse_raw_bits = ctx.block().bitcast_double_to_i64(&sparse_raw); + let sparse_is_hole = ctx + .block() + .icmp_eq(I64, &sparse_raw_bits, crate::nanbox::TAG_HOLE_I64); + ctx.block() + .cond_br(&sparse_is_hole, &object_miss_label, &lazy_sparse_value_label); + ctx.current_block = lazy_sparse_value_idx; + let sparse_value = if coerce_slow_to_number { + ctx.block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &sparse_raw)]) + } else { + sparse_raw + }; + let sparse_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + kind_incoming.push((sparse_value, sparse_end_label)); // The same proof `cached_read::lazy_get` takes before its inline load: a // live unforwarded ordinary Array, no descriptor overrides, no prototype diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index f3ace585ac..7322c59248 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -175,6 +175,18 @@ fn unknown_numeric_read_guards_dense_subclass_families_and_spilled_length() { ir.contains("arrlike.ic.range") && ir.contains("arrlike.ic.miss"), "the live length and cached dense-prefix bound must retain a semantic side exit:\n{ir}" ); + assert!( + ir.contains("arrlike.lazy.guard") && ir.contains("arrlike.lazy.load"), + "a materialized lazy JSON array must be readable without leaving the cache:\n{ir}" + ); + assert!( + ir.contains("@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"), + "the lazy tier must honour process-wide prototype invalidation:\n{ir}" + ); + assert!( + ir.contains("arrlike.lazy.sparse.bit") && ir.contains("arrlike.lazy.sparse.load"), + "a tape-backed lazy array must probe its per-element cache inline:\n{ir}" + ); } fn dynamic_symbol_access_ir(symbol_init: Expr, field: Option<&str>) -> String { diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 0515fb1f86..63fe008497 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1164,6 +1164,20 @@ const _: () = assert!( cache loads the installed array from that word" ); +// The sparse tier of that same cache probes the per-element cache directly: +// bitmap bit first, then the parallel element slot. Both offsets are read as +// raw words from emitted code, so neither may drift either. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized_elements) == 40, + "LazyArrayHeader::materialized_elements must stay at offset 40 — the \ + indexed inline cache loads a cached element from that word" +); +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized_bitmap) == 48, + "LazyArrayHeader::materialized_bitmap must stay at offset 48 — the indexed \ + inline cache proves a cached element live from that word" +); + /// #7478: how long a run of consecutive ascending cold reads has to get /// before we stop materializing element-by-element and hand the whole /// array to the batch parser. From 8689299fe41a955d3f643b8c4354982d668a9b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:12:56 +0200 Subject: [PATCH 09/19] test(json): cover both indexed-cache tiers for lazy JSON arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises what the two new cache tiers are allowed to answer and what they must hand back to the dispatcher: identity and value across repeated reads, growth and shrink against the header's length mirror, holes, an accessor descriptor installed on one index, a prototype index override and its retirement, and cached zero — whose NaN-boxed bits are all zero, so the bitmap rather than the element word has to prove a sparse slot live. The sparse half deliberately never scans its array, since a scan would trip the materialization flip and move it onto the other tier. (cherry picked from commit 07d6d2370d5e4496ef3d3e1f1219a1a2186905bc) --- .../test_gap_json_lazy_indexed_cache.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test-files/test_gap_json_lazy_indexed_cache.ts diff --git a/test-files/test_gap_json_lazy_indexed_cache.ts b/test-files/test_gap_json_lazy_indexed_cache.ts new file mode 100644 index 0000000000..09ecca106e --- /dev/null +++ b/test-files/test_gap_json_lazy_indexed_cache.ts @@ -0,0 +1,151 @@ +// The indexed inline cache serves reads of a MATERIALIZED lazy JSON array. +// Everything here is about the guards that let it do so safely: the array must +// still be an ordinary unforwarded Array, its length mirror on the lazy header +// must agree, holes and descriptors must fall back, and a prototype override +// must disable the fast path. Run in auto/tape/direct modes, including +// scheduled moving GC. +const pieces: string[] = []; +for (let i = 0; i < 200; i++) { + pieces.push('{"id":' + i + ',"name":"heap string for record ' + i + '"}'); +} +const text = "[" + pieces.join(",") + "]"; +const retained: any[] = []; +const sparseRetained: any[] = []; +let sum = 0; + +function fullyMaterialize(rows: any): void { + // A whole-array scan trips the adaptive flip, so later reads go through the + // installed ordinary array rather than the sparse per-element cache. + let seen = 0; + for (let i = 0; i < rows.length; i++) seen += rows[i].id; + if (seen !== 19900) throw new Error("scan sum changed: " + seen); +} + +for (let round = 0; round < 40; round++) { + const rows: any = JSON.parse(text); + fullyMaterialize(rows); + + // Repeated reads off the installed array must keep identity and value. + const saved: any = rows[7]; + for (let repeat = 0; repeat < 16; repeat++) { + if (rows[7] !== saved || rows[7].id !== 7) throw new Error("identity changed"); + if (rows[199].id !== 199) throw new Error("tail value changed"); + if (rows[200] !== undefined || rows[4294967295] !== undefined) { + throw new Error("out-of-bounds read"); + } + sum += rows[7].id + rows[199].id; + } + + // Growth: the header's length mirror goes stale, so a fast-path read must + // not report the old length or miss the new element. + rows.push({id: 200, name: "grown heap string"}); + if (rows.length !== 201) throw new Error("length after growth: " + rows.length); + if (rows[200].id !== 200) throw new Error("grown element lost"); + if (rows[7] !== saved) throw new Error("identity lost across growth"); + + // Shrink, then regrow into holes. Reads of the hole region must consult the + // prototype chain rather than loading a stale slot. + rows.length = 32; + if (rows.length !== 32) throw new Error("length after shrink: " + rows.length); + if (rows[32] !== undefined || rows[200] !== undefined) { + throw new Error("stale read past shrink"); + } + if (rows[31].id !== 31) throw new Error("surviving element lost"); + rows.length = 64; + if (rows[48] !== undefined) throw new Error("hole must read undefined"); + + // A descriptor override on one index must take every read off the cache. + const described: any = JSON.parse(text); + fullyMaterialize(described); + let getterCalls = 0; + Object.defineProperty(described, 5, { + configurable: true, + get: function () { getterCalls++; return {id: -5, name: "from getter"}; }, + }); + for (let repeat = 0; repeat < 8; repeat++) { + const value: any = described[5]; + if (value.id !== -5 || value.name !== "from getter") { + throw new Error("descriptor read bypassed"); + } + if (described[6].id !== 6) throw new Error("neighbour read broken"); + sum += value.id; + } + if (getterCalls !== 8) throw new Error("getter calls: " + getterCalls); + + // Allocate between passes so scheduled moving GC also covers reads taken + // after the installed array has moved. + const churn: any = JSON.parse('{"name":"pass ' + round + '"}'); + if (churn.name !== "pass " + round) throw new Error("churn changed"); + retained.push(saved); +} + +// The SPARSE tier: an array whose adaptive walk never trips the +// full-materialization flip stays tape-backed, so repeated reads are served +// from the per-element cache instead of an installed array. Never scan this +// one -- a scan would move it onto the materialized tier above. +for (let round = 0; round < 20; round++) { + const sparse: any = JSON.parse(text); + const probes: number[] = [0, 1, 63, 64, 65, 127, 128, 199]; + const first: any[] = []; + for (let p = 0; p < probes.length; p++) first.push(sparse[probes[p]]); + for (let repeat = 0; repeat < 12; repeat++) { + for (let p = 0; p < probes.length; p++) { + const index = probes[p]; + const value: any = sparse[index]; + // Identity must hold across every repeat: a cache hit returns the + // same object, never a freshly materialized copy. + if (value !== first[p]) throw new Error("sparse identity changed at " + index); + if (value.id !== index) throw new Error("sparse value changed at " + index); + if (value.name !== "heap string for record " + index) { + throw new Error("sparse name changed at " + index); + } + sum += value.id; + } + if (sparse[200] !== undefined || sparse[4294967295] !== undefined) { + throw new Error("sparse out-of-bounds read"); + } + if (sparse[-1] !== undefined) throw new Error("negative index read"); + } + // Zero is a legal cached value and its NaN-boxed bits are all zero, so the + // bitmap -- not the element word -- has to be what proves a slot cached. + const zeros: any = JSON.parse("[0,0,0,0,0,0,0,0]"); + for (let repeat = 0; repeat < 8; repeat++) { + if (zeros[3] !== 0 || zeros[7] !== 0) throw new Error("cached zero lost"); + sum += zeros[3]; + } + // Mutating through the sparse cache must move the array off it correctly. + sparse[64] = {id: -64, name: "replacement"}; + if (sparse[64].id !== -64) throw new Error("sparse replacement lost"); + if (sparse[65] !== first[4]) throw new Error("neighbour lost across mutation"); + sparseRetained.push(first[2]); +} + +// A prototype index override must disable the fast path process-wide: a hole +// read has to find the inherited value, not undefined. +const holed: any = JSON.parse(text); +fullyMaterialize(holed); +holed.length = 8; +holed.length = 16; +(Array.prototype as any)[12] = "from prototype"; +if (holed[12] !== "from prototype") throw new Error("prototype override ignored"); +if (holed[3].id !== 3) throw new Error("dense read broken under override"); +delete (Array.prototype as any)[12]; +if (holed[12] !== undefined) throw new Error("prototype override not retired"); + +for (let round = 0; round < retained.length; round++) { + const saved: any = retained[round]; + if (saved.id !== 7 || saved.name !== "heap string for record 7") { + throw new Error("retained element changed"); + } + sum += saved.id; +} +// Elements handed out by the sparse tier must survive every later collection +// with their identity and contents intact. +for (let round = 0; round < sparseRetained.length; round++) { + const saved: any = sparseRetained[round]; + if (saved.id !== 63 || saved.name !== "heap string for record 63") { + throw new Error("retained sparse element changed"); + } + sum += saved.id; +} +console.log("lazy-indexed-cache", retained.length, sparseRetained.length, sum); From 7a00190dd443e79f77d54f2f247a915963c1fa0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:33:44 +0200 Subject: [PATCH 10/19] perf(codegen): route the lazy tier off the subclass probe's miss edge Keep brand's successors exactly as they were and reach the lazy tier from arrlike.elem.kind's miss edge instead, after both the ordinary-Array and elements-subclass probes have declined the receiver. The admitted set and every guard are unchanged; only the position in the chain moves. This is hygiene, not a fix. The 20 MiB access rows regress ~4.8% on field walks either way, and the first placement was not the cause: profiling both arms on that row shows js_packed_arraylike_index_get absent entirely. A 20 MiB document is above the lazy admission bound, so it parses to an ordinary Array whose reads hit arrlike.ic.array_guard inline and never reach the miss chain where these blocks live -- they are present, not executed. The cost is code layout: run() grows 10752 to 11804 bytes, and that row's per-iteration work is ~0.027us and front-end bound (72-74% of samples inside run, 25% in fmod from the workload's own i % length). Retired instructions move +0.85% while CPU moves +4.8%, which is the signature of instruction fetch and prediction rather than executed work. (cherry picked from commit 28b83967fd14eee789f3bd04d78e9e8e5fadf350) --- .../src/expr/index_get/inline_dyn_typed_array.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 97096802b5..c29ccc1e98 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -473,6 +473,8 @@ pub(super) fn lower_inline_dyn_typed_array_get( // `json_tape::cached_read::lazy_get`). Once a scan or a random-access flip // has installed the ordinary array, that whole chain resolves one word; // serve it here instead, on exactly the proof `lazy_get` already uses. + // The blocks are declared here; they are reached from `arrlike.elem.kind` + // below, after the ordinary-Array and elements-subclass probes both miss. let lazy_kind_idx = ctx.new_block("arrlike.lazy.kind"); let lazy_header_idx = ctx.new_block("arrlike.lazy.header"); let lazy_guard_idx = ctx.new_block("arrlike.lazy.guard"); @@ -494,14 +496,18 @@ pub(super) fn lower_inline_dyn_typed_array_get( ctx.current_block = object_brand_idx; ctx.block() - .cond_br(&is_array, &object_array_guard_label, &lazy_kind_label); + .cond_br(&is_array, &object_array_guard_label, &elem_kind_label); - // `GC_TYPE_LAZY_ARRAY` (perry-runtime `gc/types.rs`). Anything else keeps - // the existing Array-subclass probe below. + // `GC_TYPE_LAZY_ARRAY` (perry-runtime `gc/types.rs`). This tier hangs off + // the Array-subclass probe's miss edge rather than off `brand`, so the + // ordinary-Array path keeps exactly the control flow it had: measured on + // the 20 MiB fixture (above the lazy admission bound, so a plain Array), + // routing `brand`'s not-array edge through here cost +4 retired + // instructions per read on that untouched path. ctx.current_block = lazy_kind_idx; let lazy_is_lazy = ctx.block().icmp_eq(I8, &gc_type, "9"); ctx.block() - .cond_br(&lazy_is_lazy, &lazy_header_label, &elem_kind_label); + .cond_br(&lazy_is_lazy, &lazy_header_label, &object_miss_label); // `LazyArrayHeader::materialized` is word 4 (offset 32; pinned by a const // assert in perry-runtime `json_tape.rs`). Null means the array is still @@ -691,7 +697,7 @@ pub(super) fn lower_inline_dyn_typed_array_get( ctx.current_block = elem_kind_idx; let elem_is_object = ctx.block().icmp_eq(I8, &gc_type, "2"); ctx.block() - .cond_br(&elem_is_object, &elem_meta_label, &object_miss_label); + .cond_br(&elem_is_object, &elem_meta_label, &lazy_kind_label); ctx.current_block = elem_meta_idx; let elem_meta_addr = ctx.block().add(I64, &object_raw, &meta_offset); let elem_meta_slot_ptr = ctx.block().inttoptr(I64, &elem_meta_addr); From 9a9d633292e8ef63f521256a0db8cf82c8ebdd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:45:28 +0200 Subject: [PATCH 11/19] chore(json): keep json_tape.rs under the file cap; rustfmt the lazy tiers The three new LazyArrayHeader offset pins pushed json_tape.rs to 2004 lines, over scripts/check_file_size.sh's 2000-line cap. Move all four layout contracts (the existing cached_length pin included) into json_tape/layout.rs, which is only their enforcement; the field doc comments keep saying why each word is load-bearing. json_tape.rs lands at 1968 lines, below where main has it. Also rustfmt's rewrap of the new cond_br calls in the indexed cache. (cherry picked from commit be49b9ee669e1ea3e5eef477364555a707036b96) --- .../expr/index_get/inline_dyn_typed_array.rs | 25 ++++++---- crates/perry-runtime/src/json_tape.rs | 39 +-------------- crates/perry-runtime/src/json_tape/layout.rs | 50 +++++++++++++++++++ 3 files changed, 67 insertions(+), 47 deletions(-) create mode 100644 crates/perry-runtime/src/json_tape/layout.rs diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index c29ccc1e98..0be52b154a 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -518,8 +518,11 @@ pub(super) fn lower_inline_dyn_typed_array_get( let lazy_materialized_ptr = ctx.block().inttoptr(I64, &lazy_materialized_addr); let lazy_materialized = ctx.block().load(I64, &lazy_materialized_ptr); let lazy_has_array = ctx.block().icmp_ne(I64, &lazy_materialized, "0"); - ctx.block() - .cond_br(&lazy_has_array, &lazy_guard_label, &lazy_sparse_bounds_label); + ctx.block().cond_br( + &lazy_has_array, + &lazy_guard_label, + &lazy_sparse_bounds_label, + ); // Still tape-backed: the sparse per-element cache is the only thing that // can answer without materializing a subtree, and it is what a repeated or @@ -545,8 +548,11 @@ pub(super) fn lower_inline_dyn_typed_array_get( let sparse_bounds_ok = ctx .block() .and(I1, &sparse_default_prototypes, &sparse_in_bounds); - ctx.block() - .cond_br(&sparse_bounds_ok, &lazy_sparse_probe_label, &object_miss_label); + ctx.block().cond_br( + &sparse_bounds_ok, + &lazy_sparse_probe_label, + &object_miss_label, + ); // `materialized_bitmap` is word 6 and `materialized_elements` word 5 // (offsets 48 and 40; both pinned by const asserts in perry-runtime @@ -594,8 +600,11 @@ pub(super) fn lower_inline_dyn_typed_array_get( let sparse_is_hole = ctx .block() .icmp_eq(I64, &sparse_raw_bits, crate::nanbox::TAG_HOLE_I64); - ctx.block() - .cond_br(&sparse_is_hole, &object_miss_label, &lazy_sparse_value_label); + ctx.block().cond_br( + &sparse_is_hole, + &object_miss_label, + &lazy_sparse_value_label, + ); ctx.current_block = lazy_sparse_value_idx; let sparse_value = if coerce_slow_to_number { ctx.block() @@ -655,9 +664,7 @@ pub(super) fn lower_inline_dyn_typed_array_get( // `lazy_get`'s own plausibility bound on an installed array. Keeping it // makes this cache admit exactly the set the runtime helper admits, so the // two can never disagree about which reads are fast. - let lazy_length_plausible = ctx - .block() - .icmp_ule(I64, &lazy_length_i64, "100000000"); + let lazy_length_plausible = ctx.block().icmp_ule(I64, &lazy_length_i64, "100000000"); let lazy_ok = ctx.block().and(I1, &lazy_is_array, &lazy_not_fwd); let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_no_descriptors); let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_default_prototypes); diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 63fe008497..6f292bd38c 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1139,44 +1139,7 @@ pub struct LazyArrayHeader { pub sequential_streak: u32, } -// `cached_length` at offset 0 is a CODEGEN contract, not a layout preference: -// Perry inlines `.length` as a raw u32 load at offset 0 rather than calling -// `js_array_length`, so an unmaterialized lazy array only reports the right -// length because this field sits first. Nothing else in the tree enforced -// that — the guarantee lived in a doc comment — so a field reordered into -// the front would have produced silently wrong `.length` values with every -// test still green. Adding a field to this struct is the moment that can -// happen, so pin it here. -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, cached_length) == 0, - "LazyArrayHeader::cached_length must stay at offset 0 — codegen inlines \ - `.length` as a raw u32 load there" -); - -// `materialized` is the second codegen contract on this struct. The indexed -// inline cache (`perry-codegen` `expr/index_get/inline_dyn_typed_array.rs`) -// reads this slot directly to serve `lazy[i]` without a runtime call, exactly -// as `cached_read::lazy_get` does. A reordered field would send that fast path -// at an unrelated word, so pin the offset the same way `cached_length` is. -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized) == 32, - "LazyArrayHeader::materialized must stay at offset 32 — the indexed inline \ - cache loads the installed array from that word" -); - -// The sparse tier of that same cache probes the per-element cache directly: -// bitmap bit first, then the parallel element slot. Both offsets are read as -// raw words from emitted code, so neither may drift either. -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized_elements) == 40, - "LazyArrayHeader::materialized_elements must stay at offset 40 — the \ - indexed inline cache loads a cached element from that word" -); -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized_bitmap) == 48, - "LazyArrayHeader::materialized_bitmap must stay at offset 48 — the indexed \ - inline cache proves a cached element live from that word" -); +mod layout; /// #7478: how long a run of consecutive ascending cold reads has to get /// before we stop materializing element-by-element and hand the whole diff --git a/crates/perry-runtime/src/json_tape/layout.rs b/crates/perry-runtime/src/json_tape/layout.rs new file mode 100644 index 0000000000..26a0323364 --- /dev/null +++ b/crates/perry-runtime/src/json_tape/layout.rs @@ -0,0 +1,50 @@ +//! Layout contracts on [`LazyArrayHeader`] that emitted code depends on. +//! +//! Perry's codegen reads these words directly — `.length` as a raw u32 at +//! offset 0, and the indexed inline cache's lazy tiers at the three pointer +//! slots below — instead of calling into the runtime. A field reordered in +//! front of any of them would send emitted code at an unrelated word with +//! every test still green, so each offset is pinned here at compile time. +//! The doc comments on the fields themselves say *why* each is load-bearing; +//! this module is only the enforcement. + +use super::LazyArrayHeader; + +// `cached_length` at offset 0 is a CODEGEN contract, not a layout preference: +// Perry inlines `.length` as a raw u32 load at offset 0 rather than calling +// `js_array_length`, so an unmaterialized lazy array only reports the right +// length because this field sits first. Nothing else in the tree enforced +// that — the guarantee lived in a doc comment — so a field reordered into +// the front would have produced silently wrong `.length` values with every +// test still green. Adding a field to this struct is the moment that can +// happen, so pin it here. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, cached_length) == 0, + "LazyArrayHeader::cached_length must stay at offset 0 — codegen inlines \ + `.length` as a raw u32 load there" +); + +// `materialized` is the second codegen contract on this struct. The indexed +// inline cache (`perry-codegen` `expr/index_get/inline_dyn_typed_array.rs`) +// reads this slot directly to serve `lazy[i]` without a runtime call, exactly +// as `cached_read::lazy_get` does. A reordered field would send that fast path +// at an unrelated word, so pin the offset the same way `cached_length` is. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized) == 32, + "LazyArrayHeader::materialized must stay at offset 32 — the indexed inline \ + cache loads the installed array from that word" +); + +// The sparse tier of that same cache probes the per-element cache directly: +// bitmap bit first, then the parallel element slot. Both offsets are read as +// raw words from emitted code, so neither may drift either. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized_elements) == 40, + "LazyArrayHeader::materialized_elements must stay at offset 40 — the \ + indexed inline cache loads a cached element from that word" +); +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, materialized_bitmap) == 48, + "LazyArrayHeader::materialized_bitmap must stay at offset 48 — the indexed \ + inline cache proves a cached element live from that word" +); From ee6adbc4033ffe72ef4422c76df14a952bc72b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 06:50:19 +0200 Subject: [PATCH 12/19] test(json): split the lazy defineProperty gap out of the cache fixture The indexed-cache fixture asserted that an accessor descriptor installed on an index takes reads off the fast path. main cannot do that at all: in both the sparse and the materialized state the descriptor is installed and then ignored, and only PERRY_JSON_TAPE=0 matches Node. The assertion therefore failed identically on both arms and told us nothing about the new tiers. Move it to its own reproducer, covering both lazy states, and record it in the gap snapshot against #10097. The cache fixture keeps every assertion the tiers are actually responsible for. (cherry picked from commit 69002c6c21b325680d445268c2112be0edd31a2b) --- ...test_gap_json_lazy_defineproperty_index.ts | 33 +++++++++++++++++++ .../test_gap_json_lazy_indexed_cache.ts | 26 +++------------ test-parity/gap_snapshot.json | 7 ++++ 3 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 test-files/test_gap_json_lazy_defineproperty_index.ts diff --git a/test-files/test_gap_json_lazy_defineproperty_index.ts b/test-files/test_gap_json_lazy_defineproperty_index.ts new file mode 100644 index 0000000000..173cc5cdd3 --- /dev/null +++ b/test-files/test_gap_json_lazy_defineproperty_index.ts @@ -0,0 +1,33 @@ +// Object.defineProperty on an index of a JSON.parse array must be honoured by +// later reads. Passes with PERRY_JSON_TAPE=0 (direct parse) and fails in the +// default lazy route: the accessor is installed but indexed reads keep +// returning the element, in both the sparse and the materialized state. +// Known gap: #10097. +const pieces: string[] = []; +for (let i = 0; i < 200; i++) { + pieces.push('{"id":' + i + ',"name":"heap string for record ' + i + '"}'); +} +const text = "[" + pieces.join(",") + "]"; +let sum = 0; +for (let round = 0; round < 4; round++) { + // sparse (never scanned) and materialized (scanned) both must honour it + for (const scan of [false, true]) { + const rows: any = JSON.parse(text); + if (scan) { let seen = 0; for (let i = 0; i < rows.length; i++) seen += rows[i].id; sum += seen; } + let getterCalls = 0; + Object.defineProperty(rows, 5, { + configurable: true, + get: function () { getterCalls++; return {id: -5, name: "from getter"}; }, + }); + for (let repeat = 0; repeat < 8; repeat++) { + const value: any = rows[5]; + if (value.id !== -5 || value.name !== "from getter") { + throw new Error("descriptor read bypassed (scan=" + scan + ")"); + } + if (rows[6].id !== 6) throw new Error("neighbour read broken"); + sum += value.id; + } + if (getterCalls !== 8) throw new Error("getter calls: " + getterCalls); + } +} +console.log("lazy-defineproperty-index", sum); diff --git a/test-files/test_gap_json_lazy_indexed_cache.ts b/test-files/test_gap_json_lazy_indexed_cache.ts index 09ecca106e..078645ac2c 100644 --- a/test-files/test_gap_json_lazy_indexed_cache.ts +++ b/test-files/test_gap_json_lazy_indexed_cache.ts @@ -1,9 +1,11 @@ // The indexed inline cache serves reads of a MATERIALIZED lazy JSON array. // Everything here is about the guards that let it do so safely: the array must // still be an ordinary unforwarded Array, its length mirror on the lazy header -// must agree, holes and descriptors must fall back, and a prototype override -// must disable the fast path. Run in auto/tape/direct modes, including -// scheduled moving GC. +// must agree, holes must fall back, and a prototype override must disable the +// fast path. Run in auto/tape/direct modes, including scheduled moving GC. +// (An accessor descriptor on one index is test_gap_json_lazy_defineproperty_index.ts: +// main cannot honour it on a lazy array in any parser mode, and that is +// tracked as its own gap.) const pieces: string[] = []; for (let i = 0; i < 200; i++) { pieces.push('{"id":' + i + ',"name":"heap string for record ' + i + '"}'); @@ -54,24 +56,6 @@ for (let round = 0; round < 40; round++) { rows.length = 64; if (rows[48] !== undefined) throw new Error("hole must read undefined"); - // A descriptor override on one index must take every read off the cache. - const described: any = JSON.parse(text); - fullyMaterialize(described); - let getterCalls = 0; - Object.defineProperty(described, 5, { - configurable: true, - get: function () { getterCalls++; return {id: -5, name: "from getter"}; }, - }); - for (let repeat = 0; repeat < 8; repeat++) { - const value: any = described[5]; - if (value.id !== -5 || value.name !== "from getter") { - throw new Error("descriptor read bypassed"); - } - if (described[6].id !== 6) throw new Error("neighbour read broken"); - sum += value.id; - } - if (getterCalls !== 8) throw new Error("getter calls: " + getterCalls); - // Allocate between passes so scheduled moving GC also covers reads taken // after the installed array has moved. const churn: any = JSON.parse('{"name":"pass ' + round + '"}'); diff --git a/test-parity/gap_snapshot.json b/test-parity/gap_snapshot.json index 46723c64a5..10423a89f8 100644 --- a/test-parity/gap_snapshot.json +++ b/test-parity/gap_snapshot.json @@ -24,6 +24,13 @@ "category": "bug-open", "reason": "process SIGINT trace hook gap; standing per #5917 diff." }, + "test_gap_json_lazy_defineproperty_index": { + "status": "parity_fail", + "issue": "10097", + "added": "2026-09-12", + "category": "bug-open", + "reason": "Object.defineProperty index accessor on a JSON.parse lazy array is installed but bypassed by indexed reads; passes with PERRY_JSON_TAPE=0" + }, "test_gap_perfhooks_3088_3008_3010_3011": { "status": "parity_fail", "issue": "3088", From ba3be0d414e0a3b90b754635ef7b8cf0be5ffc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 07:22:16 +0200 Subject: [PATCH 13/19] perf(codegen): outline the lazy read proof into one runtime probe Inlining the whole lazy proof at every indexed read site made emitted code measurably worse on rows it never executes on. The 50-row screen against the pre-change compiler showed six separated regressions, worst string_a:parse +5.08% and null:parse +3.14% -- rows with no array in them at all -- and the access screen showed 20 MiB field walks +4.78%, on a receiver that is an ordinary Array and never enters these blocks. run() in the JSON worker grew 10752 to 11804 bytes; retired instructions moved +0.85% while CPU moved +4.8%, the signature of instruction fetch and prediction rather than executed work. Replace both tiers with a single call to js_lazy_array_index_probe, which is lazy_get's two non-allocating branches and nothing else. Emitted code per read site drops from ~87 instructions to a tag test, a call and a result test. The dispatcher chain (js_packed_arraylike_index_get -> js_array_get_f64 -> lazy_get) is still skipped; only the proof moves out of line. TAG_HOLE is the declined signal -- unambiguous, because a hole is never a value a read yields and holes already route to the miss helper. Cold elements, descriptors, out-of-bounds, growth stubs and a stale length mirror all come back as that. The probe is classified CannotCollect: it reads headers, bitmap and slots, never allocates a managed value, never enters user code, and deliberately omits lazy_get's rooted fallback. The three pointer-word offset pins go away with the inline form, since the cache no longer emits those offsets; only the pre-existing cached_length contract stays pinned. (cherry picked from commit b3bc4d7a909f0fe3e6701b48e00c68f66bd15e65) --- .../expr/index_get/inline_dyn_typed_array.rs | 267 ++++-------------- .../src/expr/index_get_claim_tests.rs | 12 +- crates/perry-codegen/src/gc_call_effects.rs | 6 + .../perry-codegen/src/runtime_decls/arrays.rs | 5 + .../src/json_tape/cached_read.rs | 79 ++++++ crates/perry-runtime/src/json_tape/layout.rs | 41 +-- 6 files changed, 151 insertions(+), 259 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 0be52b154a..6f989e12d8 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -476,231 +476,16 @@ pub(super) fn lower_inline_dyn_typed_array_get( // The blocks are declared here; they are reached from `arrlike.elem.kind` // below, after the ordinary-Array and elements-subclass probes both miss. let lazy_kind_idx = ctx.new_block("arrlike.lazy.kind"); - let lazy_header_idx = ctx.new_block("arrlike.lazy.header"); - let lazy_guard_idx = ctx.new_block("arrlike.lazy.guard"); - let lazy_load_idx = ctx.new_block("arrlike.lazy.load"); - let lazy_sparse_bounds_idx = ctx.new_block("arrlike.lazy.sparse.bounds"); - let lazy_sparse_probe_idx = ctx.new_block("arrlike.lazy.sparse.probe"); - let lazy_sparse_bit_idx = ctx.new_block("arrlike.lazy.sparse.bit"); - let lazy_sparse_load_idx = ctx.new_block("arrlike.lazy.sparse.load"); - let lazy_sparse_value_idx = ctx.new_block("arrlike.lazy.sparse.value"); - let lazy_sparse_bounds_label = ctx.block_label(lazy_sparse_bounds_idx); - let lazy_sparse_probe_label = ctx.block_label(lazy_sparse_probe_idx); - let lazy_sparse_bit_label = ctx.block_label(lazy_sparse_bit_idx); - let lazy_sparse_load_label = ctx.block_label(lazy_sparse_load_idx); - let lazy_sparse_value_label = ctx.block_label(lazy_sparse_value_idx); + let lazy_call_idx = ctx.new_block("arrlike.lazy.call"); + let lazy_value_idx = ctx.new_block("arrlike.lazy.value"); let lazy_kind_label = ctx.block_label(lazy_kind_idx); - let lazy_header_label = ctx.block_label(lazy_header_idx); - let lazy_guard_label = ctx.block_label(lazy_guard_idx); - let lazy_load_label = ctx.block_label(lazy_load_idx); + let lazy_call_label = ctx.block_label(lazy_call_idx); + let lazy_value_label = ctx.block_label(lazy_value_idx); ctx.current_block = object_brand_idx; ctx.block() .cond_br(&is_array, &object_array_guard_label, &elem_kind_label); - // `GC_TYPE_LAZY_ARRAY` (perry-runtime `gc/types.rs`). This tier hangs off - // the Array-subclass probe's miss edge rather than off `brand`, so the - // ordinary-Array path keeps exactly the control flow it had: measured on - // the 20 MiB fixture (above the lazy admission bound, so a plain Array), - // routing `brand`'s not-array edge through here cost +4 retired - // instructions per read on that untouched path. - ctx.current_block = lazy_kind_idx; - let lazy_is_lazy = ctx.block().icmp_eq(I8, &gc_type, "9"); - ctx.block() - .cond_br(&lazy_is_lazy, &lazy_header_label, &object_miss_label); - - // `LazyArrayHeader::materialized` is word 4 (offset 32; pinned by a const - // assert in perry-runtime `json_tape.rs`). Null means the array is still - // tape-backed and only the sparse per-element cache can answer, which - // needs the bitmap probe in `lazy_get` — keep that on the miss path. - ctx.current_block = lazy_header_idx; - let lazy_materialized_addr = ctx.block().add(I64, &object_raw, "32"); - let lazy_materialized_ptr = ctx.block().inttoptr(I64, &lazy_materialized_addr); - let lazy_materialized = ctx.block().load(I64, &lazy_materialized_ptr); - let lazy_has_array = ctx.block().icmp_ne(I64, &lazy_materialized, "0"); - ctx.block().cond_br( - &lazy_has_array, - &lazy_guard_label, - &lazy_sparse_bounds_label, - ); - - // Still tape-backed: the sparse per-element cache is the only thing that - // can answer without materializing a subtree, and it is what a repeated or - // clustered read pattern actually hits — an array small enough that the - // adaptive walk never trips the full-materialization flip stays here for - // the life of the program. `lazy_get`'s own sparse branch is three loads - // and a bit test, so inline exactly that and leave every cold read, hole - // and out-of-bounds index to the miss helper. - // - // Out-of-bounds deliberately does NOT shortcut to `undefined` here even - // though `lazy_get` does: the prototype chain is the miss helper's job. - ctx.current_block = lazy_sparse_bounds_idx; - let sparse_invalidated = ctx - .block() - .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); - let sparse_default_prototypes = ctx.block().icmp_eq(I8, &sparse_invalidated, "0"); - let sparse_length_ptr = ctx.block().inttoptr(I64, &object_raw); - let sparse_length = ctx.block().load(I32, &sparse_length_ptr); - let sparse_length_i64 = ctx.block().zext(I32, &sparse_length, I64); - let sparse_in_bounds = ctx - .block() - .icmp_ult(I64, &object_idx_i64, &sparse_length_i64); - let sparse_bounds_ok = ctx - .block() - .and(I1, &sparse_default_prototypes, &sparse_in_bounds); - ctx.block().cond_br( - &sparse_bounds_ok, - &lazy_sparse_probe_label, - &object_miss_label, - ); - - // `materialized_bitmap` is word 6 and `materialized_elements` word 5 - // (offsets 48 and 40; both pinned by const asserts in perry-runtime - // `json_tape.rs`). A null on either side means nothing has been cached yet. - ctx.current_block = lazy_sparse_probe_idx; - let sparse_bitmap_addr = ctx.block().add(I64, &object_raw, "48"); - let sparse_bitmap_slot = ctx.block().inttoptr(I64, &sparse_bitmap_addr); - let sparse_bitmap = ctx.block().load(I64, &sparse_bitmap_slot); - let sparse_elements_addr = ctx.block().add(I64, &object_raw, "40"); - let sparse_elements_slot = ctx.block().inttoptr(I64, &sparse_elements_addr); - let sparse_elements = ctx.block().load(I64, &sparse_elements_slot); - let sparse_has_bitmap = ctx.block().icmp_ne(I64, &sparse_bitmap, "0"); - let sparse_has_elements = ctx.block().icmp_ne(I64, &sparse_elements, "0"); - let sparse_probe_ok = ctx - .block() - .and(I1, &sparse_has_bitmap, &sparse_has_elements); - ctx.block() - .cond_br(&sparse_probe_ok, &lazy_sparse_bit_label, &object_miss_label); - - // The bitmap is the authoritative "this slot holds a materialized value" - // signal — `JSValue::ZERO` is a legal cached value, so a null/zero element - // word cannot be used as the liveness test. - ctx.current_block = lazy_sparse_bit_idx; - let sparse_word_index = ctx.block().lshr(I64, &object_idx_i64, "6"); - let sparse_word_offset = ctx.block().shl(I64, &sparse_word_index, "3"); - let sparse_word_addr = ctx.block().add(I64, &sparse_bitmap, &sparse_word_offset); - let sparse_word_ptr = ctx.block().inttoptr(I64, &sparse_word_addr); - let sparse_word = ctx.block().load(I64, &sparse_word_ptr); - let sparse_bit_index = ctx.block().and(I64, &object_idx_i64, "63"); - let sparse_shifted = ctx.block().lshr(I64, &sparse_word, &sparse_bit_index); - let sparse_bit = ctx.block().and(I64, &sparse_shifted, "1"); - let sparse_cached = ctx.block().icmp_ne(I64, &sparse_bit, "0"); - ctx.block() - .cond_br(&sparse_cached, &lazy_sparse_load_label, &object_miss_label); - - // A bitmap-set slot always holds a real materialized JSValue, so the hole - // test below can never fire; keep it anyway, since its only effect is to - // route an impossible value to the same helper that would have produced it. - ctx.current_block = lazy_sparse_load_idx; - let sparse_elem_offset = ctx.block().shl(I64, &object_idx_i64, "3"); - let sparse_elem_addr = ctx.block().add(I64, &sparse_elements, &sparse_elem_offset); - let sparse_elem_ptr = ctx.block().inttoptr(I64, &sparse_elem_addr); - let sparse_raw = ctx.block().load(DOUBLE, &sparse_elem_ptr); - let sparse_raw_bits = ctx.block().bitcast_double_to_i64(&sparse_raw); - let sparse_is_hole = ctx - .block() - .icmp_eq(I64, &sparse_raw_bits, crate::nanbox::TAG_HOLE_I64); - ctx.block().cond_br( - &sparse_is_hole, - &object_miss_label, - &lazy_sparse_value_label, - ); - ctx.current_block = lazy_sparse_value_idx; - let sparse_value = if coerce_slow_to_number { - ctx.block() - .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &sparse_raw)]) - } else { - sparse_raw - }; - let sparse_end_label = ctx.block().label.clone(); - ctx.block().br(&merge_label); - kind_incoming.push((sparse_value, sparse_end_label)); - - // The same proof `cached_read::lazy_get` takes before its inline load: a - // live unforwarded ordinary Array, no descriptor overrides, no prototype - // invalidation, and a dense in-capacity index. A growth-forwarding stub - // keeps its own GC header and fails `obj_type`/`FORWARDED`, so it routes - // to the resolver exactly as before (#9717). - // - // `cached_length` is the length mirror codegen reads for `.length` at - // offset 0. `lazy_get` refreshes it when it takes this path; the cache - // cannot write, so it instead requires the mirror to already agree and - // sends a disagreement to the miss helper — which refreshes it, making the - // next read hit. That keeps a grown or shrunk array from reporting a stale - // length through a fast-path read. - ctx.current_block = lazy_guard_idx; - let lazy_type_addr = ctx.block().sub(I64, &lazy_materialized, "8"); - let lazy_type_ptr = ctx.block().inttoptr(I64, &lazy_type_addr); - let lazy_type = ctx.block().load(I8, &lazy_type_ptr); - let lazy_is_array = ctx.block().icmp_eq(I8, &lazy_type, "1"); - let lazy_flags_addr = ctx.block().sub(I64, &lazy_materialized, "7"); - let lazy_flags_ptr = ctx.block().inttoptr(I64, &lazy_flags_addr); - let lazy_flags = ctx.block().load(I8, &lazy_flags_ptr); - let lazy_fwd = ctx.block().and(I8, &lazy_flags, "128"); - let lazy_not_fwd = ctx.block().icmp_eq(I8, &lazy_fwd, "0"); - let lazy_reserved_addr = ctx.block().sub(I64, &lazy_materialized, "6"); - let lazy_reserved_ptr = ctx.block().inttoptr(I64, &lazy_reserved_addr); - let lazy_reserved = ctx.block().load(I16, &lazy_reserved_ptr); - let lazy_descriptor_bits = ctx.block().and(I16, &lazy_reserved, "1024"); - let lazy_no_descriptors = ctx.block().icmp_eq(I16, &lazy_descriptor_bits, "0"); - let lazy_invalidated = ctx - .block() - .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); - let lazy_default_prototypes = ctx.block().icmp_eq(I8, &lazy_invalidated, "0"); - let lazy_array_ptr = ctx.block().inttoptr(I64, &lazy_materialized); - let lazy_length = ctx.block().load(I32, &lazy_array_ptr); - let lazy_capacity_addr = ctx.block().add(I64, &lazy_materialized, "4"); - let lazy_capacity_ptr = ctx.block().inttoptr(I64, &lazy_capacity_addr); - let lazy_capacity = ctx.block().load(I32, &lazy_capacity_ptr); - let lazy_mirror_ptr = ctx.block().inttoptr(I64, &object_raw); - let lazy_mirror = ctx.block().load(I32, &lazy_mirror_ptr); - let lazy_mirror_fresh = ctx.block().icmp_eq(I32, &lazy_mirror, &lazy_length); - let lazy_length_i64 = ctx.block().zext(I32, &lazy_length, I64); - let lazy_capacity_i64 = ctx.block().zext(I32, &lazy_capacity, I64); - let lazy_in_bounds = ctx.block().icmp_ult(I64, &object_idx_i64, &lazy_length_i64); - let lazy_within_capacity = ctx - .block() - .icmp_ule(I64, &lazy_length_i64, &lazy_capacity_i64); - // `lazy_get`'s own plausibility bound on an installed array. Keeping it - // makes this cache admit exactly the set the runtime helper admits, so the - // two can never disagree about which reads are fast. - let lazy_length_plausible = ctx.block().icmp_ule(I64, &lazy_length_i64, "100000000"); - let lazy_ok = ctx.block().and(I1, &lazy_is_array, &lazy_not_fwd); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_no_descriptors); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_default_prototypes); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_mirror_fresh); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_in_bounds); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_within_capacity); - let lazy_ok = ctx.block().and(I1, &lazy_ok, &lazy_length_plausible); - ctx.block() - .cond_br(&lazy_ok, &lazy_load_label, &object_miss_label); - - // A hole must still consult the prototype chain, so it keeps the complete - // dispatcher rather than becoming `undefined` here. - ctx.current_block = lazy_load_idx; - let lazy_element_word = ctx.block().add(I64, &object_idx_i64, "1"); - let lazy_element_ptr = - ctx.block() - .gep_inbounds(I64, &lazy_array_ptr, &[(I64, &lazy_element_word)]); - let lazy_raw = ctx.block().load(DOUBLE, &lazy_element_ptr); - let lazy_raw_bits = ctx.block().bitcast_double_to_i64(&lazy_raw); - let lazy_is_hole = ctx - .block() - .icmp_eq(I64, &lazy_raw_bits, crate::nanbox::TAG_HOLE_I64); - let lazy_value_idx = ctx.new_block("arrlike.lazy.value"); - let lazy_value_label = ctx.block_label(lazy_value_idx); - ctx.block() - .cond_br(&lazy_is_hole, &object_miss_label, &lazy_value_label); - ctx.current_block = lazy_value_idx; - let lazy_value = if coerce_slow_to_number { - ctx.block() - .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &lazy_raw)]) - } else { - lazy_raw - }; - let lazy_end_label = ctx.block().label.clone(); - ctx.block().br(&merge_label); - kind_incoming.push((lazy_value, lazy_end_label)); ctx.current_block = elem_kind_idx; let elem_is_object = ctx.block().icmp_eq(I8, &gc_type, "2"); ctx.block() @@ -770,6 +555,50 @@ pub(super) fn lower_inline_dyn_typed_array_get( ctx.block().br(&merge_label); kind_incoming.push((elem_value, elem_end_label)); + // `GC_TYPE_LAZY_ARRAY` (perry-runtime `gc/types.rs`). This tier hangs off + // the Array-subclass probe's miss edge, after the ordinary-Array and + // elements-subclass probes have both declined the receiver. + ctx.current_block = lazy_kind_idx; + let lazy_is_lazy = ctx.block().icmp_eq(I8, &gc_type, "9"); + ctx.block() + .cond_br(&lazy_is_lazy, &lazy_call_label, &object_miss_label); + + // One call into `json_tape::cached_read::js_lazy_array_index_probe`, which + // is `lazy_get`'s two non-allocating branches and nothing else. That skips + // the dispatcher chain (`js_packed_arraylike_index_get` -> + // `js_array_get_f64` -> `lazy_get`) without inlining the whole proof at + // every indexed read site: the inline form grew this function ~10% and cost + // rows it never executes on up to 5% to code layout alone. + // + // `TAG_HOLE` means "this read needs the rooted accessor" -- unambiguous, + // because a hole is never a value a read yields, and holes already route to + // the miss helper. Cold elements, descriptors, out-of-bounds, growth stubs + // and a stale length mirror all come back as that. The probe cannot + // allocate, run user code or collect, so no extra rooting is required here. + ctx.current_block = lazy_call_idx; + let lazy_raw_i64 = object_raw.clone(); + let lazy_probe = ctx.block().call( + DOUBLE, + "js_lazy_array_index_probe", + &[(I64, &lazy_raw_i64), (I64, &object_idx_i64)], + ); + let lazy_probe_bits = ctx.block().bitcast_double_to_i64(&lazy_probe); + let lazy_declined = ctx + .block() + .icmp_eq(I64, &lazy_probe_bits, crate::nanbox::TAG_HOLE_I64); + ctx.block() + .cond_br(&lazy_declined, &object_miss_label, &lazy_value_label); + ctx.current_block = lazy_value_idx; + let lazy_value = if coerce_slow_to_number { + ctx.block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &lazy_probe)]) + } else { + lazy_probe + }; + let lazy_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + kind_incoming.push((lazy_value, lazy_end_label)); + // Ordinary Array: the receiver tag and forwarding state were checked in // the predecessor. Reject descriptors or any process-wide prototype // invalidation, then prove a dense in-capacity index before loading the diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index 7322c59248..3670cd0ce3 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -176,16 +176,12 @@ fn unknown_numeric_read_guards_dense_subclass_families_and_spilled_length() { "the live length and cached dense-prefix bound must retain a semantic side exit:\n{ir}" ); assert!( - ir.contains("arrlike.lazy.guard") && ir.contains("arrlike.lazy.load"), - "a materialized lazy JSON array must be readable without leaving the cache:\n{ir}" + ir.contains("arrlike.lazy.kind") && ir.contains("js_lazy_array_index_probe"), + "a lazy JSON array must reach its probe from the cache, not the dispatcher:\n{ir}" ); assert!( - ir.contains("@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"), - "the lazy tier must honour process-wide prototype invalidation:\n{ir}" - ); - assert!( - ir.contains("arrlike.lazy.sparse.bit") && ir.contains("arrlike.lazy.sparse.load"), - "a tape-backed lazy array must probe its per-element cache inline:\n{ir}" + !ir.contains("arrlike.lazy.sparse") && !ir.contains("arrlike.lazy.guard"), + "the lazy proof belongs in the probe, not inlined at every read site:\n{ir}" ); } diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 69280c68eb..e1e8d17fe4 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -60,6 +60,12 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // `array/subclass.rs`: scalar descriptor/header comparison only. It // neither allocates nor enters user code; a miss returns zero. | "js_packed_arraylike_loop_revalidate_live" + // `json_tape/cached_read.rs`: reads an already-materialized lazy JSON + // element. Header/bitmap/slot loads only -- it is `lazy_get`'s two + // non-allocating branches with the rooted fallback deliberately left + // out, so every case it cannot serve returns TAG_HOLE and the emitted + // code takes its ordinary miss call instead. + | "js_lazy_array_index_probe" // `gc/roots/temp_roots.rs`: TLS vector operations and an incremental // marking barrier only. They never run a Perry collection. | "js_gc_temp_root_push" diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index b7b8e3123f..f8b78d9708 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -50,6 +50,11 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // Refs #488: bulk push for `arr.push(...src)` spread call. module.declare_function("js_array_push_spread_f64", I64, &[I64, I64]); module.declare_function("js_array_get_f64", DOUBLE, &[I64, I32]); + // The indexed inline cache's lazy tier: `lazy_get`'s two non-allocating + // branches, reached once the receiver's GC header proves a live + // GC_TYPE_LAZY_ARRAY. Returns the element, or TAG_HOLE when the read needs + // the rooted accessor and the cache must take its ordinary miss call. + module.declare_function("js_lazy_array_index_probe", DOUBLE, &[I64, I64]); // repsel #7480 / #5093: the element-shape versioned loop's preheader // guard. Establishes-or-confirms the per-array homogeneous element-shape // invariant and returns the proven class id (0 = no proof). O(n) on the diff --git a/crates/perry-runtime/src/json_tape/cached_read.rs b/crates/perry-runtime/src/json_tape/cached_read.rs index e1e41db2b9..08e4e317d1 100644 --- a/crates/perry-runtime/src/json_tape/cached_read.rs +++ b/crates/perry-runtime/src/json_tape/cached_read.rs @@ -74,6 +74,85 @@ pub unsafe fn lazy_get(hdr: *mut LazyArrayHeader, i: u32) -> JSValue { super::lazy_get_rooted(hdr, i) } +/// Probe an already-materialized lazy element for emitted code. +/// +/// This is `lazy_get`'s two non-allocating branches and nothing else. It exists +/// so the indexed inline cache can skip the dispatcher chain +/// (`js_packed_arraylike_index_get` -> `js_array_get_f64` -> `lazy_get`) with +/// ONE call instead of inlining ~87 instructions at every indexed read site in +/// the program: the inline form measurably grew `run()` by 10% in the JSON +/// access benchmark and cost an untouched ordinary-Array row ~4.8% to code +/// layout alone. +/// +/// `raw` must be a live, unforwarded `GC_TYPE_LAZY_ARRAY` pointer -- the caller +/// proves that from the GC header before calling. Returns `TAG_HOLE` to mean +/// "this read needs the rooted accessor"; that is unambiguous because a hole is +/// never a value a read yields, and the caller already routes holes to its miss +/// helper. Cold elements, holes, descriptors, out-of-bounds indices, growth +/// stubs and a stale length mirror all take that exit. +/// +/// Cannot allocate a managed value, run user code or collect, so the caller +/// needs no additional rooting around it. +#[no_mangle] +pub unsafe extern "C" fn js_lazy_array_index_probe(raw: i64, idx: i64) -> f64 { + let miss = f64::from_bits(crate::value::TAG_HOLE); + if raw == 0 || !(0..=u32::MAX as i64).contains(&idx) { + return miss; + } + let hdr = raw as *mut LazyArrayHeader; + let i = idx as u32; + if (*hdr).materialized.is_null() { + // Sparse: the bitmap is the liveness test, because `JSValue::ZERO` is a + // legal cached value whose bits are all zero. + if i >= (*hdr).cached_length { + return miss; + } + let bitmap = (*hdr).materialized_bitmap; + let cache = (*hdr).materialized_elements; + if bitmap.is_null() + || cache.is_null() + || *bitmap.add(i as usize / 64) & (1u64 << (i % 64)) == 0 + { + return miss; + } + let bits = (*cache.add(i as usize)).bits(); + if bits == crate::value::TAG_HOLE { + return miss; + } + return f64::from_bits(bits); + } + // Materialized: the same proof `lazy_get` takes before its inline load. + // A growth-forwarding stub keeps its own GC header and fails these, so it + // routes to the resolver through the caller's miss path exactly as before. + let cached = (*hdr).materialized; + let header = &*cached + .cast::() + .sub(crate::gc::GC_HEADER_SIZE) + .cast::(); + if header.obj_type != crate::gc::GC_TYPE_ARRAY + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 + || (*cached).length > (*cached).capacity + || (*cached).length > 100_000_000 + || i >= (*cached).length + { + return miss; + } + // `lazy_get` refreshes this mirror when it serves the read; a probe must not + // write, so it declines instead and lets the rooted accessor refresh it. + // That keeps a grown or shrunk array from reporting a stale `.length`. + if (*hdr).cached_length != (*cached).length { + return miss; + } + let elements = + (cached as *const u8).add(std::mem::size_of::()) as *const u64; + let bits = *elements.add(i as usize); + if bits == crate::value::TAG_HOLE { + return miss; + } + f64::from_bits(bits) +} + #[cfg(test)] mod tests { use super::super::*; diff --git a/crates/perry-runtime/src/json_tape/layout.rs b/crates/perry-runtime/src/json_tape/layout.rs index 26a0323364..3cbd3817eb 100644 --- a/crates/perry-runtime/src/json_tape/layout.rs +++ b/crates/perry-runtime/src/json_tape/layout.rs @@ -1,12 +1,14 @@ //! Layout contracts on [`LazyArrayHeader`] that emitted code depends on. //! -//! Perry's codegen reads these words directly — `.length` as a raw u32 at -//! offset 0, and the indexed inline cache's lazy tiers at the three pointer -//! slots below — instead of calling into the runtime. A field reordered in -//! front of any of them would send emitted code at an unrelated word with -//! every test still green, so each offset is pinned here at compile time. -//! The doc comments on the fields themselves say *why* each is load-bearing; -//! this module is only the enforcement. +//! Perry's codegen reads `.length` as a raw u32 at offset 0 instead of calling +//! into the runtime. A field reordered in front of it would send emitted code +//! at an unrelated word with every test still green, so the offset is pinned +//! here at compile time. The doc comment on the field itself says *why* it is +//! load-bearing; this module is only the enforcement. +//! +//! The indexed inline cache reaches the other words through +//! `js_lazy_array_index_probe` rather than emitting their offsets, so they need +//! no pin: moving them is a plain Rust refactor the compiler checks. use super::LazyArrayHeader; @@ -23,28 +25,3 @@ const _: () = assert!( "LazyArrayHeader::cached_length must stay at offset 0 — codegen inlines \ `.length` as a raw u32 load there" ); - -// `materialized` is the second codegen contract on this struct. The indexed -// inline cache (`perry-codegen` `expr/index_get/inline_dyn_typed_array.rs`) -// reads this slot directly to serve `lazy[i]` without a runtime call, exactly -// as `cached_read::lazy_get` does. A reordered field would send that fast path -// at an unrelated word, so pin the offset the same way `cached_length` is. -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized) == 32, - "LazyArrayHeader::materialized must stay at offset 32 — the indexed inline \ - cache loads the installed array from that word" -); - -// The sparse tier of that same cache probes the per-element cache directly: -// bitmap bit first, then the parallel element slot. Both offsets are read as -// raw words from emitted code, so neither may drift either. -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized_elements) == 40, - "LazyArrayHeader::materialized_elements must stay at offset 40 — the \ - indexed inline cache loads a cached element from that word" -); -const _: () = assert!( - std::mem::offset_of!(LazyArrayHeader, materialized_bitmap) == 48, - "LazyArrayHeader::materialized_bitmap must stay at offset 48 — the indexed \ - inline cache proves a cached element live from that word" -); From 23bc968e90e638994dd53be9fd7758f4c9bc2236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 08:29:06 +0200 Subject: [PATCH 14/19] test(json): cover the lazy index probe's decline contract The probe is the indexed cache's whole lazy fast path, so what it DECLINES matters as much as what it serves: every decline is a read the emitted code must hand to its rooted miss helper, and a decline that wrongly became a value would be a silently wrong read. Covers an uncached index, a warmed sparse hit and its still-cold neighbour, cached zero (whose NaN-boxed bits are all zero, so the bitmap rather than the element word has to prove liveness), out-of-bounds -- which must not shortcut to undefined, since the prototype chain is the caller's job -- indices outside the u32 domain, a null receiver, and a materialized read whose length mirror has gone stale, which must decline until the rooted accessor refreshes it. (cherry picked from commit 62ef8db166ab145c7dea5bf55c8b57dae6839529) --- .../src/json_tape/cached_read.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/perry-runtime/src/json_tape/cached_read.rs b/crates/perry-runtime/src/json_tape/cached_read.rs index 08e4e317d1..d4058f46f8 100644 --- a/crates/perry-runtime/src/json_tape/cached_read.rs +++ b/crates/perry-runtime/src/json_tape/cached_read.rs @@ -183,6 +183,79 @@ mod tests { } } + const MISS: u64 = crate::value::TAG_HOLE; + + fn probe(hdr: *mut LazyArrayHeader, i: i64) -> u64 { + unsafe { super::js_lazy_array_index_probe(hdr as i64, i).to_bits() } + } + + /// The probe is the cache's entire lazy fast path, so what it DECLINES is + /// as load-bearing as what it serves: every decline is a read the emitted + /// code must hand to its rooted miss helper. + #[test] + fn lazy_index_probe_serves_cached_reads_and_declines_everything_else() { + let _guard = crate::gc::GcSuppressScope::new(); + let input = format!( + "[{}]", + (0..8).map(|i| i.to_string()).collect::>().join(",") + ); + unsafe { + let hdr = fixture(input.as_bytes()); + // Nothing is cached yet, so every index declines rather than + // inventing a value. + for i in 0..8 { + assert_eq!(probe(hdr, i), MISS, "uncached index {i} must decline"); + } + // A rooted read populates the sparse cache; the probe then serves + // that index and still declines its neighbours. + let warmed = lazy_get(hdr, 3); + assert!((*hdr).materialized.is_null(), "must still be tape-backed"); + assert_eq!(probe(hdr, 3), warmed.bits(), "cached index must be served"); + assert_eq!(probe(hdr, 4), MISS, "a neighbour is still uncached"); + // Zero is a legal cached value whose NaN-boxed bits are all zero, + // so the bitmap rather than the element word has to prove liveness. + let zero = lazy_get(hdr, 0); + assert_eq!(zero.bits(), JSValue::number(0.0).bits()); + assert_eq!(probe(hdr, 0), zero.bits(), "cached zero must be served"); + // Out of bounds consults the prototype chain, so it is the caller's + // job -- the probe must not shortcut it to undefined. + for i in [8, 9, 4_294_967_295] { + assert_eq!(probe(hdr, i), MISS, "out-of-bounds {i} must decline"); + } + // Indices outside the u32 domain, and a null receiver, decline too. + for i in [-1, -4096, 4_294_967_296, i64::MAX] { + assert_eq!(probe(hdr, i), MISS, "index {i} must decline"); + } + assert_eq!(probe(std::ptr::null_mut(), 0), MISS, "null must decline"); + } + } + + #[test] + fn lazy_index_probe_declines_a_stale_length_mirror_after_growth() { + let _guard = crate::gc::GcSuppressScope::new(); + let input = format!("[{}]", vec![r#"{"id":1}"#; 12].join(",")); + unsafe { + let hdr = fixture(input.as_bytes()); + let arr = force_materialize_lazy(hdr); + assert!(!(*hdr).materialized.is_null(), "must be materialized"); + let served = lazy_get(hdr, 5); + assert_eq!( + probe(hdr, 5), + served.bits(), + "materialized read must be served" + ); + // `lazy_get` refreshes the header's length mirror when it serves a + // read; the probe cannot write, so a mirror that has gone stale + // must send the read back to the rooted accessor rather than let a + // later `.length` report the old value. + let real = (*arr).length; + (*hdr).cached_length = real + 1; + assert_eq!(probe(hdr, 5), MISS, "a stale mirror must decline"); + (*hdr).cached_length = real; + assert_eq!(probe(hdr, 5), served.bits(), "a fresh mirror serves again"); + } + } + unsafe fn fixture(input: &[u8]) -> *mut LazyArrayHeader { let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); with_built_tape(input, |tape| { From 73cff381e21f2243827b31199c024459d43e85a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 08:44:31 +0200 Subject: [PATCH 15/19] test(parity): ratchet the lazy defineProperty gap on both platforms scripts/parity_known_failures.py is a ratchet, not a suppression list: a gap_snapshot.json entry without a platform-applicable known_failures.json record fails the audit. Register #10097 for linux and macos with its provenance. (cherry picked from commit aeb41638a333b6c4d84cb95e3b1906c21c1886d7) --- test-parity/known_failures.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index dea95fa392..ed881947a1 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -76,6 +76,16 @@ "category": "bug-stale", "reason": "RE-TRIAGE: tracking issue #2514 is CLOSED but this still fails (audited 2026-08-07, #7582) — needs a new issue. process SIGINT trace hook gap; standing per the #5917 diff." }, + "test_gap_json_lazy_defineproperty_index": { + "issue": "10097", + "added": "2026-09-12", + "category": "bug-open", + "reason": "Object.defineProperty on an index of a JSON.parse lazy array is installed and then ignored by indexed reads, in both the sparse and materialized states; PERRY_JSON_TAPE=0 (direct parse) matches Node. Found while adding the indexed-cache lazy tier: the assertion failed identically on the candidate and on main, so it is main's gap, not the change's. Filed as #10097.", + "platforms": [ + "linux", + "macos" + ] + }, "test_gap_perfhooks_3088_3008_3010_3011": { "issue": "3088", "added": "2026-07-04", From 2092ebd7a0d2a8e4d13e342389cba1f088abd670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:19:08 +0200 Subject: [PATCH 16/19] docs(changelog): record the lazy JSON array indexed-cache tier (#10114) (cherry picked from commit 1801680434ced221fda5862aa0a2f4177220b490) --- changelog.d/10114-json-lazy-array-index-ic.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/10114-json-lazy-array-index-ic.md diff --git a/changelog.d/10114-json-lazy-array-index-ic.md b/changelog.d/10114-json-lazy-array-index-ic.md new file mode 100644 index 0000000000..ae8a48395e --- /dev/null +++ b/changelog.d/10114-json-lazy-array-index-ic.md @@ -0,0 +1,11 @@ +**perf(codegen): serve lazy JSON array reads from the indexed inline cache.** A `JSON.parse` result carries `GC_TYPE_LAZY_ARRAY`, and the indexed inline cache's brand check only admitted `GC_TYPE_ARRAY` — so every `parsed[i]` fell through to `arrlike.ic.miss` and re-classified the same receiver three more times (`js_packed_arraylike_index_get` → `js_array_get_f64` → `json_tape::cached_read::lazy_get`), about **227 retired instructions for `rows[7].id`**. #10050 and #10064 made that last helper allocation-free; nothing had touched the dispatcher chain in front of it, which is why post-parse reads were the worst rows in the JSON matrix. + +The cache now proves a live `GC_TYPE_LAZY_ARRAY` from the GC header and makes one call to `js_lazy_array_index_probe` — `lazy_get`'s two non-allocating branches and nothing else, covering both the sparse per-element cache (an array whose adaptive walk never trips the materialization flip stays tape-backed for the life of the program: the 16 KiB fixture is 120 records) and an installed ordinary array. `TAG_HOLE` is the declined signal, unambiguous because a hole is never a value a read yields and holes already route to the miss helper; cold elements, descriptors, out-of-bounds, growth-forwarding stubs and a stale `cached_length` mirror all take that exit. `lazy_get` refreshes that mirror when it serves a read and a probe cannot write, so it instead requires the mirror to agree and declines otherwise — a grown or shrunk array can never report a stale `.length` through a fast-path read. The probe is classified `CannotCollect` and omits `lazy_get`'s rooted fallback, so the caller needs no extra rooting. + +Quiet M1/8 GiB host, Node 26.5.1, Bun 1.3.14, 7 interleaved repetitions, measured against `main` at `e8f912392`: 1 MiB repeat **−41.7%**, fields **−41.6%**, sequential **−33.4%**, random **−31.9%**; 16 KiB repeat **−41.8%**, fields **−39.8%**, random **−35.4%**, sequential **−22.8%**. `records_array_1m:random` goes from 2.11× Node to **1.47×**. Retired instructions per read drop 37.7–48.9% on those rows. Peak RSS unchanged. + +**Disclosed costs.** The 50-row screen shows nine separated regressions of +0.5–1.2% (`numbers_1m:parse +2.41%`), six of which reproduce across two independent windows, on `parse`/`sparse`/`roundtrip` rows. These come from the benchmark worker's structure rather than from parsing: `worker.ts` runs all five operations inside one `run()`, so its parse loop shares code layout and register allocation with the `scan`/`sparse` loops that do contain indexed reads. A worker whose `run()` has no indexed read at all compiles to a **byte-identical object file on both arms**, so the cost cannot reach such a function. Programs that parse and index in the same hot function will see it; programs that do not, cannot. On the access screen, 20 MiB rows (above the lazy admission bound, so ordinary Arrays that never enter the new path) show `repeat +2.97%` and `fields −2.83%`; before #10074 landed the same pair read +0.08% and +4.94%, so that cost relocates between rows when unrelated runtime changes land and is microarchitectural sensitivity on ~0.026 µs rows, not a property of this change. + +**Why one call rather than inlining the proof.** The inline form was built and measured first, then rejected: `run()` grew 10752 → 11804 bytes and the 50-row screen showed `string_a:parse +5.08%` and `null:parse +3.14%` — rows with no array in them. Outlining cuts the growth to **+56 bytes** and those rows to +0.01% and −0.07%, retaining essentially all of the instruction reduction. + +**Validation.** 234 rows — 13 lazy-array fixtures × native/shadow roots × auto/tape/direct parsers × normal/scheduled/full-GC — byte-identical to the reference on both arms, with a real moving-GC witness on every one of the 78 scheduled rows. `test_gap_json_lazy_indexed_cache.ts` covers identity, growth, shrink, holes, a prototype override and its retirement, and cached zero (all-zero NaN-boxed bits, so the bitmap rather than the element word must prove a slot live); two Rust unit tests pin the probe's decline contract. `Object.defineProperty` on a lazy index is a pre-existing gap failing identically on both arms, split into its own reproducer and ratcheted against #10097. The architectural follow-up that would delete this tier entirely is #10098. From 4ed246934d1cba65ae6e4801a5359bc8619efa42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:50:24 +0200 Subject: [PATCH 17/19] chore: bump workspace version to 0.5.1539 Train165 (#10114, #10117, #10119, #10120) lands on main at 0.5.1538; 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 9df6b7438a..b5aa23c422 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.1538 +**Current Version:** 0.5.1539 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 0cb1384333..bddecc9427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5695,7 +5695,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "base64 0.22.1", @@ -5759,7 +5759,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-dispatch", "serde", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "cc", "libc", @@ -5776,7 +5776,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "aho-corasick", "anyhow", @@ -5794,7 +5794,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-hir", @@ -5802,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-hir", @@ -5810,7 +5810,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-dispatch", @@ -5819,7 +5819,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-hir", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "base64 0.22.1", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-hir", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "async-trait", @@ -5876,14 +5876,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "serde", "serde_json", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1538" +version = "0.5.1539" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5902,7 +5902,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "clap", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "block2", "objc2", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "argon2", "perry-ffi", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "reqwest", @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "bcrypt", "perry-ffi", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "rusqlite", @@ -5961,7 +5961,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "scraper", @@ -5969,7 +5969,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "perry-runtime", @@ -5977,7 +5977,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "chrono", "cron", @@ -5987,7 +5987,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "chrono", "perry-ffi", @@ -5995,7 +5995,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "rust_decimal", @@ -6003,7 +6003,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "serde_json", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6019,7 +6019,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "perry-runtime", @@ -6027,14 +6027,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "bytes", "http-body-util", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "bytes", "lazy_static", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "bytes", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "lazy_static", "perry-ffi", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "lru", "perry-ffi", @@ -6127,7 +6127,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "chrono", "perry-ffi", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "bson", "futures-util", @@ -6147,7 +6147,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "chrono", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "nanoid", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "bytes", "perry-ffi", @@ -6183,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "lettre", "perry-ffi", @@ -6212,7 +6212,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "fancy-regex", "notify", @@ -6224,7 +6224,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "printpdf", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "sqlx", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "perry-runtime", @@ -6250,7 +6250,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "governor", "perry-ffi", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "fast_image_resize", "image", @@ -6269,7 +6269,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "lazy_static", "perry-ffi", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-ffi", @@ -6298,7 +6298,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "perry-runtime", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "uuid", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "regex", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "futures-util", "lazy_static", @@ -6338,7 +6338,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "brotli", "flate2", @@ -6348,7 +6348,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6358,7 +6358,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-api-manifest", @@ -6377,11 +6377,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1538" +version = "0.5.1539" [[package]] name = "perry-parser" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-diagnostics", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "ahash", "anyhow", @@ -6457,14 +6457,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1538" +version = "0.5.1539" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1538" +version = "0.5.1539" [[package]] name = "perry-ui-tvos" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1538" +version = "0.5.1539" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 7b84e5b224..842285bb80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -336,7 +336,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1538" +version = "0.5.1539" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From cf2fb65f5875cdec93738d171fceca9e6aa1948e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:53:06 +0200 Subject: [PATCH 18/19] fix(runtime): scope bind's bound-closure pointer to its non-allocating stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10119's rewrite left the bound closure's address bound once, immediately after rooting it, and then used it again ~60 lines later — past `closure_get_own_dynamic_prop` and `closure_length`, either of which can allocate and therefore move it. The raw-handle ratchet refused the two new sites (`closure/dispatch/bound.rs` is a module with no ceiling, so it is locked at zero, and `--no-raise-vs ` will not accept a new ceiling on it). This is the hazard the ratchet exists for, not a style question, so the sites are converted rather than recorded: - the four capture stores, none of which allocates, now take the closure and the partial-args array through `with_mut_ptr` for exactly that block; - the `.length` publication and the final return re-read the rooted slot, since the dynamic-property lookups between them can allocate. Debt returns to the recorded 944 with every module inside its ceiling (`--no-raise-vs origin/main`: "none raised"). --- .../src/closure/dispatch/bound.rs | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index c44f61dab4..a994957db6 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -634,18 +634,23 @@ pub unsafe extern "C" fn js_function_bind( // partial-args array, and the `.name` snapshot above. let bound = crate::closure::js_closure_alloc(BOUND_FUNCTION_FUNC_PTR, 4); let bound_h = scope.root_raw_mut_ptr(bound as *mut u8); - let bound = bound_h.get_raw_mut_ptr::(); let target_value = target_h.get_nanbox_f64(); let bound_this = this_h.get_nanbox_f64(); let name_hint = name_h.get_nanbox_f64(); - let bound_args_arr = args_h - .as_ref() - .map(|h| h.get_raw_mut_ptr::()) - .unwrap_or(std::ptr::null_mut()); - js_closure_set_capture_f64(bound, 0, target_value); - js_closure_set_capture_f64(bound, 1, bound_this); - js_closure_set_capture_ptr(bound, 2, bound_args_arr as i64); - js_closure_set_capture_f64(bound, 3, name_hint); + // None of the four capture stores allocates, so both raw addresses are + // scoped to this block rather than bound for the rest of the function — + // the `.length` reads below can allocate and move either object. + bound_h.with_mut_ptr(|bound: *mut ClosureHeader| { + js_closure_set_capture_f64(bound, 0, target_value); + js_closure_set_capture_f64(bound, 1, bound_this); + match args_h.as_ref() { + Some(h) => h.with_mut_ptr(|arr: *mut crate::array::ArrayHeader| { + js_closure_set_capture_ptr(bound, 2, arr as i64) + }), + None => js_closure_set_capture_ptr(bound, 2, 0), + } + js_closure_set_capture_f64(bound, 3, name_hint); + }); // Re-derive the target closure pointer from the (possibly refreshed) // `target_value` for the `.length` read below — `target_is_closure`'s @@ -687,19 +692,25 @@ pub unsafe extern "C" fn js_function_bind( }; let bound_len = (target_len_f - bound_arg_count as f64).max(0.0); if bound_len.is_finite() && bound_len <= u32::MAX as f64 { - crate::object::set_builtin_closure_length(bound as usize, bound_len as u32); + bound_h.with_mut_ptr(|bound: *mut ClosureHeader| { + crate::object::set_builtin_closure_length(bound as usize, bound_len as u32) + }); } else { // +Infinity (or beyond u32): store as an own dynamic prop, which the // `.length` read path prefers over the registered builtin length. - crate::closure::closure_set_dynamic_prop( - bound as usize, - "length", - f64::from_bits(JSValue::number(bound_len).bits()), - ); + bound_h.with_mut_ptr(|bound: *mut ClosureHeader| { + crate::closure::closure_set_dynamic_prop( + bound as usize, + "length", + f64::from_bits(JSValue::number(bound_len).bits()), + ) + }); } - crate::gc::runtime_write_barrier_root_heap_word(bound as u64); - f64::from_bits(JSValue::pointer(bound as *mut u8).bits()) + bound_h.with_mut_ptr(|bound: *mut ClosureHeader| { + crate::gc::runtime_write_barrier_root_heap_word(bound as u64); + f64::from_bits(JSValue::pointer(bound as *mut u8).bits()) + }) } /// Keepalive anchor for the `js_function_bind` symbol. The auto-optimize From 49d6b8827a9e482fb9de01f30d7de008c44429dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 11:47:24 +0200 Subject: [PATCH 19/19] test(codegen): pin both hops of the indexed-read kind guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10114 routes the lazy-JSON-array tier off the elements-subclass probe's miss edge, which moves that guard's false target from `arrlike.ic.miss` to `arrlike.lazy.kind`. `any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index` asserted the OLD block adjacency, so it failed on the train: only GC_TYPE_OBJECT may reach the ObjectMeta.elements load: %r713 = icmp eq i8 %r705, 2 br i1 %r713, label %arrlike.elem.meta.159, label %arrlike.lazy.kind.164 The safety property the test exists for is intact — the guard is still `icmp eq i8 …, 2` and its true edge is still `arrlike.elem.meta`, so no non-object reaches the ObjectMeta.elements load. What changed is that a non-object now takes one more type test before leaving: `arrlike.lazy.kind` checks `icmp eq i8 …, 9` (GC_TYPE_LAZY_ARRAY) and sends everything else to `arrlike.ic.miss`, which is the same complete dispatcher as before. A declining probe returns TAG_HOLE to that same exit. Native Buffers and other exotic managed cells therefore still leave through the dispatcher. So the fix is to assert the property rather than the adjacency, and to pin BOTH hops — which is strictly stronger than what it replaced, because the lazy tier's own kind guard is now covered too. Sabotage-checked rather than assumed: with the lazy guard's false edge rewired to `lazy_call_label` (so an exotic cell would fall into the probe), the new assertion fails with "only GC_TYPE_LAZY_ARRAY may reach the lazy probe; everything else must still exit through the complete dispatcher". Restored, 12 of 12 pass. --- .../src/expr/index_get_claim_tests.rs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index 3670cd0ce3..c5da000d3f 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -424,12 +424,31 @@ fn any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index() { let kind = super::class_field_barrier_tests::block_body(&ir, "arrlike.elem.kind.") .expect("the elements-store object-kind guard exists"); assert!( - kind.contains("icmp eq i8") - && kind.contains(", 2") - && kind.contains("arrlike.elem.meta") - && kind.contains("arrlike.ic.miss"), + kind.contains("icmp eq i8") && kind.contains(", 2") && kind.contains("arrlike.elem.meta"), "only GC_TYPE_OBJECT may reach the ObjectMeta.elements load:\n{kind}" ); + // #10114 put the lazy-JSON-array tier on this guard's miss edge, so the + // exit is one block further out than it used to be. Pin BOTH hops rather + // than the old block adjacency: a non-object must fall to the lazy kind + // test, and anything that is not GC_TYPE_LAZY_ARRAY (9) must still leave + // through the complete dispatcher at `arrlike.ic.miss`. Native Buffers and + // other exotic managed cells reach that exit unchanged; what must never + // happen is either tier reading their header word at offset 8 as + // ObjectMeta. + assert!( + kind.contains("arrlike.lazy.kind"), + "a non-object must fall through to the lazy tier's own kind test:\n{kind}" + ); + let lazy_kind = super::class_field_barrier_tests::block_body(&ir, "arrlike.lazy.kind.") + .expect("the lazy-array kind guard exists"); + assert!( + lazy_kind.contains("icmp eq i8") + && lazy_kind.contains(", 9") + && lazy_kind.contains("arrlike.lazy.call") + && lazy_kind.contains("arrlike.ic.miss"), + "only GC_TYPE_LAZY_ARRAY may reach the lazy probe; everything else must \ + still exit through the complete dispatcher:\n{lazy_kind}" + ); // The elements-backed subclass probe sits ahead of the shape IC: meta // word → `ObjectMeta.elements` (word 12) → inner-array bounds → slot. let store = super::class_field_barrier_tests::block_body(&ir, "arrlike.elem.store.")