From f5b546983983901a0b3e9d3fe97a59c543269a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:44:35 +0200 Subject: [PATCH 1/9] perf(runtime): stop re-deriving the receiver on every array push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hot `for (…) { a.push(v); a.pop(); }` loop costs 1,350 instructions per push+pop pair. Profiled with symbols, more than half of that is not the append — it is the append re-asking questions about a receiver it has already resolved, once per helper in the chain. Four removals, no inlining and no caching; the runtime gets smaller. 1. `typed_feedback::numeric_array_push_guard` probed the property descriptor of `"length"` on EVERY push — a string-keyed lookup that was the single heaviest frame in the loop at 16.5% of samples, more than the append it guarded. It was also unreachable-true: a non-writable `length` is only reachable through a descriptor write, every one of which marks the receiver `OBJ_FLAG_ARRAY_DESCRIPTORS` (documented on `array::named_props::mark_array_descriptors` as the shared "index-accessor / non-writable-length / sparse-index" gate), and the flag test three lines above has already returned `false` for any receiver carrying it. `array_length_is_non_writable_with_flags` encodes the same implication the other way round, short-circuiting on the flag before it will look anything up. 2. `js_array_numeric_push_f64_unboxed` asked `array_is_sealed_or_no_extend`, `array_is_frozen` and `guard_writable_length` in sequence. Each goes through the non-resolved `array::header::array_object_flags`, which re-runs `clean_arr_ptr` — allocator-ownership plus forwarding classification — before every single bit test, on the pointer `clean_arr_ptr_mut` resolved on the line above. `array_object_flags` was 24.0% of the loop, every sample of it from this one function. One read of the resolved header now answers all three. 3. The append chain resolved twice more: `array_numeric_raw_f64_push_inbounds` and then `ensure_array_numeric_raw_f64` inside it. 4. `array_iteration_is_exotic` and the layout chain (`js_array_is_numeric_f64_layout` → `array_numeric_layout`) each resolved again. The exotic check keeps its Buffer / TypedArray registry probes — the flag word cannot answer that, since those headers are not `GC_TYPE_ARRAY` and read as flags `0`. 1,350.6 -> 432.0 instructions per push+pop pair, -68.0%. Measured by differencing two probes that differ only in push count, inside each binary, so driver dispatch and code layout cancel before the arms are compared; the bare loop control reads 0.05 and -0.04 in the two arms. `js_array_pop_f64` already carries this exact fix — its comment describes the same three redundant classifications per pop. `push` never got it. Also fixes a parity bug the integrity fixture found: `Object.preventExtensions(a); a.push(1)` silently kept the old length where node throws. The dense append answers `SEALED | NO_EXTEND` with a bare `return arr`, which is right for `js_array_push_f64` — the INTERNAL CreateDataProperty-style append that runtime code uses to build fresh result arrays, and which must not throw — and wrong for user `push`. The observable entry now throws. `Object.seal` masked this: sealing also marks the element descriptors, so it routed down the exotic path and threw for another reason. The frozen-push message now matches node's wording too, since what fails is CreateDataProperty for the new index rather than a write to a read-only one. Known remaining divergence, not addressed here: frozen `pop` reports "Cannot mutate a frozen array" where node says "Cannot delete property 'N' of [object Array]". It throws from an earlier branch than the push path, and getting it right needs care about the empty-array ordering. --- crates/perry-runtime/src/array/header.rs | 62 ++++++++++++++++++- crates/perry-runtime/src/array/indexing.rs | 29 +++++++++ crates/perry-runtime/src/array/mod.rs | 35 ++++++----- crates/perry-runtime/src/array/push_pop.rs | 57 +++++++++++++++-- .../native_call_method/common_methods.rs | 5 +- .../native_call_method/handle_methods.rs | 4 +- crates/perry-runtime/src/typed_feedback.rs | 24 ++++--- test-files/test_gap_array_push_integrity.ts | 57 +++++++++++++++++ 8 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 test-files/test_gap_array_push_integrity.ts diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 41672ffddb..f978b1e5ea 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1340,6 +1340,18 @@ pub(crate) unsafe fn array_numeric_layout(arr: *const ArrayHeader) -> Option Option { array_has_raw_f64_layout_flag(arr).then_some(NumericArrayLayout::RawF64) } @@ -1375,6 +1387,17 @@ pub(crate) unsafe fn ensure_array_numeric_raw_f64(arr: *mut ArrayHeader) -> bool if arr.is_null() { return false; } + unsafe { ensure_array_numeric_raw_f64_resolved(arr) } +} + +/// [`ensure_array_numeric_raw_f64`] for a receiver the caller has already put +/// through [`clean_arr_ptr_mut`]. +/// +/// # Safety +/// `arr` is that non-null resolved head, with no intervening allocation or +/// safepoint — the same contract [`array_object_flags_resolved`] carries. +#[inline] +pub(crate) unsafe fn ensure_array_numeric_raw_f64_resolved(arr: *mut ArrayHeader) -> bool { let length = (*arr).length as usize; let capacity = (*arr).capacity as usize; if length > capacity || length > 16_000_000 { @@ -1442,7 +1465,28 @@ pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds( value: f64, ) -> bool { let arr = clean_arr_ptr_mut(arr); - if arr.is_null() || !ensure_array_numeric_raw_f64(arr) { + if arr.is_null() { + return false; + } + unsafe { array_numeric_raw_f64_push_inbounds_resolved(arr, value) } +} + +/// [`array_numeric_raw_f64_push_inbounds`] for an already-resolved receiver. +/// +/// The append chain re-entered `clean_arr_ptr` — allocator-ownership plus +/// forwarding classification — once per helper: the unboxed push entry +/// resolved, then this did, then `ensure_array_numeric_raw_f64` did again, all +/// on the one pointer the entry had already proved live. Threading the resolved +/// head through removes the repeats instead of caching their answer. +/// +/// # Safety +/// `arr` is a non-null [`clean_arr_ptr_mut`] result with no intervening +/// allocation or safepoint. +pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds_resolved( + arr: *mut ArrayHeader, + value: f64, +) -> bool { + if !ensure_array_numeric_raw_f64_resolved(arr) { return false; } let length = (*arr).length; @@ -1617,8 +1661,22 @@ pub extern "C" fn js_array_is_numeric_f64_layout(arr: *const ArrayHeader) -> i32 if arr.is_null() { return 0; } + unsafe { js_array_is_numeric_f64_layout_resolved(arr) } +} + +/// [`js_array_is_numeric_f64_layout`] for a caller holding a resolved head. +/// +/// The typed-feedback push guard reaches this with a pointer it has already +/// normalized, header-checked and proved non-forwarded, so the entry's own +/// `clean_arr_ptr` — and the second one `array_numeric_layout` used to perform +/// inside it — were both re-deriving that proof once per push. +/// +/// # Safety +/// `arr` is a non-null resolved head with no intervening allocation or +/// safepoint. +pub(crate) unsafe fn js_array_is_numeric_f64_layout_resolved(arr: *const ArrayHeader) -> i32 { unsafe { - if array_numeric_layout(arr) == Some(NumericArrayLayout::RawF64) { + if array_numeric_layout_resolved(arr) == Some(NumericArrayLayout::RawF64) { return 1; } // #6011 follow-up: a holes-flagged array (`new Array(n)` mid-fill) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 1199056c5c..69d15f0ace 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -98,6 +98,35 @@ pub(crate) fn array_iteration_is_exotic(arr: *const ArrayHeader) -> bool { unsafe { array_iteration_is_exotic_resolved(arr, flags) } } +/// [`array_iteration_is_exotic`] for a caller holding a resolved head and its +/// flag word, but which has NOT excluded Buffer / TypedArray receivers. +/// +/// This is the shape the hot append wants: `js_array_numeric_push_f64_unboxed` +/// resolved the receiver and read the header once, so the only thing it still +/// needs from `array_iteration_is_exotic` is the registry probe plus the policy +/// tests — not a second `clean_arr_ptr`, which re-runs allocator-ownership and +/// forwarding classification on a pointer already proved live one call up. +/// +/// The registry probes stay: a Buffer or typed array must still be routed to +/// the spec path, and the flag word cannot answer that (their headers are not +/// `GC_TYPE_ARRAY`, so `array_object_flags_from_tag` reads them as `0`, which +/// on its own would let a typed array reach the raw-f64 append). +/// +/// # Safety +/// +/// `arr` and `flags` must satisfy [`array_object_flags_resolved`]'s contract. +pub(crate) unsafe fn array_iteration_is_exotic_cleaned( + arr: *const ArrayHeader, + flags: u16, +) -> bool { + if crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { + return true; + } + unsafe { array_iteration_is_exotic_resolved(arr, flags) } +} + /// [`array_iteration_is_exotic`] for a caller that already resolved the live /// plain-array head, excluded Buffer/TypedArray receivers, and owns the header /// word: the policy tests without a second receiver resolution and registry diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index efdfb88608..e4c5926058 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -142,9 +142,9 @@ pub(crate) use self::generic_object::{ object_splice, }; pub(crate) use self::header::{ - array_has_arguments_object_flag, mark_array_as_arguments_object, - rebuild_array_numeric_raw_f64_allow_holes, rebuild_array_numeric_raw_f64_dense_window, - rebuild_array_numeric_raw_f64_dense_window_i32, + array_has_arguments_object_flag, js_array_is_numeric_f64_layout_resolved, + mark_array_as_arguments_object, rebuild_array_numeric_raw_f64_allow_holes, + rebuild_array_numeric_raw_f64_dense_window, rebuild_array_numeric_raw_f64_dense_window_i32, }; pub use self::header::{ js_array_clear_numeric_layout, js_array_declare_all_pointer_elements, @@ -160,8 +160,8 @@ pub use self::immutable::{ }; pub(crate) use self::indexing::{ array_custom_prototype, array_has_own_index, array_iteration_is_exotic, - array_iteration_is_exotic_resolved, array_prototype_has_index_flag, array_spec_get, - array_spec_has_index, array_spec_set, + array_iteration_is_exotic_cleaned, array_iteration_is_exotic_resolved, + array_prototype_has_index_flag, array_spec_get, array_spec_has_index, array_spec_set, }; pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, @@ -221,6 +221,7 @@ pub(crate) use self::prototype_addr::{ test_memoized_prototype_addr, test_prototype_addr_cache_wiring, test_prototype_addr_cell_count, test_rewrite_prototype_addr_slot, }; +pub(crate) use self::push_pop::throw_non_extensible_array_push; pub(crate) use self::sort::object_prototype_has_index_prop; pub(crate) use self::sort::object_prototype_index_get as sort_object_prototype_index_get; pub(crate) use self::sort::object_prototype_index_get_with_receiver as sort_object_prototype_index_get_with_receiver; @@ -281,18 +282,18 @@ pub(crate) use self::alloc::{js_array_from_arraylike, js_array_from_string_codep pub(crate) use self::flat_clone::{dense_spread_copy, dense_spread_source, flattenable_array_ptr}; pub(crate) use self::header::{ array_byte_size, array_is_frozen, array_is_sealed_or_no_extend, array_numeric_raw_f64_get, - array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, - array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, - array_receiver_addr, array_receiver_gc_tag, buffer_receiver_as_uint8_typed_array, - canonicalize_array_numeric_store_value_from_flags, clean_arr_ptr, clean_arr_ptr_mut, - clear_array_numeric_layout, clear_array_numeric_layout_ptr, finish_array_dense_move_layout, - gc_element_slot_range, mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, - normalize_array_receiver, note_array_slot, note_array_slot_layout_only, - note_array_slot_resolved_flags, rebuild_array_layout, rebuild_array_layout_exact, - reclassify_array_numeric_layout_from_slots, refresh_array_numeric_layout, - replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, - store_array_slot_resolved, typed_array_receiver, value_bits_to_number, NumericArrayLayout, - MIN_ARRAY_CAPACITY, + array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_push_inbounds_resolved, + array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, + array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, + buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value_from_flags, + clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, + finish_array_dense_move_layout, gc_element_slot_range, mark_array_layout_unknown, + mark_array_raw_f64_holes_fresh, normalize_array_receiver, note_array_slot, + note_array_slot_layout_only, note_array_slot_resolved_flags, rebuild_array_layout, + rebuild_array_layout_exact, reclassify_array_numeric_layout_from_slots, + refresh_array_numeric_layout, replay_array_growth_write_barriers, set_array_numeric_layout, + store_array_slot, store_array_slot_resolved, typed_array_receiver, value_bits_to_number, + NumericArrayLayout, MIN_ARRAY_CAPACITY, }; pub(crate) use self::named_props::{ array_has_named_properties_resolved, array_has_sparse_index_properties_resolved, diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index de88a8949a..54cab10a38 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -33,6 +33,17 @@ fn array_length_is_non_writable_with_flags(arr: *const ArrayHeader, flags: u16) .unwrap_or(false) } +/// §23.1.3.21 push performs `Set(O, len, value, true)`; on a non-extensible +/// receiver `CreateDataProperty` for the new index fails, and `Throw=true` +/// makes that a TypeError. Node words it exactly this way for +/// `preventExtensions`, `seal` AND `freeze`. +#[cold] +pub(crate) fn throw_non_extensible_array_push(index: u32) -> ! { + crate::collection_iter::throw_type_error(&format!( + "Cannot add property {index}, object is not extensible" + )); +} + #[cold] fn throw_non_writable_length() -> ! { crate::collection_iter::throw_type_error( @@ -892,6 +903,25 @@ static KEEP_JS_ARRAY_PUSH_U31_WITH_LENGTH: extern "C" fn( #[no_mangle] pub extern "C" fn js_array_push_f64_spec(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader { if let Some(plain) = direct_plain_push_receiver(arr) { + // A non-extensible receiver must THROW here, not decline silently. + // `js_array_push_f64_resolved` answers `SEALED | NO_EXTEND` with a bare + // `return arr`, which is right for its other caller — `js_array_push_f64` + // is the INTERNAL CreateDataProperty-style append that runtime code uses + // to build fresh result arrays, and those must not throw. It is wrong for + // user `push`: `Object.preventExtensions(a); a.push(1)` silently kept the + // old length where Node raises TypeError. `Object.seal` happened to throw + // only because sealing also marks the receiver's element descriptors, + // which sends it down the exotic route instead of this one. + // + // FROZEN is left to the resolved append below, which throws its own + // frozen message; only the extensibility bits are answered here. + // SAFETY: `direct_plain_push_receiver` just proved the resolved head. + let flags = unsafe { array_object_flags_resolved(plain) }; + if flags & crate::gc::OBJ_FLAG_FROZEN == 0 + && flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0 + { + throw_non_extensible_array_push(unsafe { (*plain).length }); + } crate::string::js_string_addref_if_heap_string(value); return unsafe { js_array_push_f64_resolved(plain, value) }; } @@ -982,15 +1012,34 @@ pub extern "C" fn js_array_numeric_push_f64_unboxed( if arr.is_null() { return js_array_alloc(0); } - if array_is_sealed_or_no_extend(arr) || array_is_frozen(arr) { + // ONE read of the already-resolved header answers all three integrity + // questions. Each of `array_is_sealed_or_no_extend`, `array_is_frozen` and + // `guard_writable_length` went through the non-resolved + // `array::header::array_object_flags`, which re-runs `clean_arr_ptr` — the + // allocator-ownership and forwarding classification — before every single + // bit test. On a pointer `clean_arr_ptr_mut` resolved on the line above, + // that is three further resolutions to re-derive a fact already proved. + // + // `array_object_flags_from_tag` keeps the exact semantics of the helpers it + // replaces: a receiver whose header is not `GC_TYPE_ARRAY` (a Buffer, a + // typed array) reads as flags `0`, just as `array_object_flags` returned 0 + // for it, so the exotic check below still owns those receivers. + // + // Measured on `for (…) { a.push(v); a.pop(); }`: `array_object_flags` was + // 24.0% of all samples in the loop, every one of them from this function. + let flags = crate::array::array_object_flags_from_tag(crate::array::array_receiver_gc_tag(arr)); + if flags + & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND | crate::gc::OBJ_FLAG_FROZEN) + != 0 + { return arr; } - guard_writable_length(arr); + guard_writable_length_with_flags(arr, flags); unsafe { - if crate::array::array_iteration_is_exotic(arr) { + if crate::array::array_iteration_is_exotic_cleaned(arr, flags) { return js_array_push_f64_spec(arr, value); } - if array_numeric_raw_f64_push_inbounds(arr, value) { + if crate::array::array_numeric_raw_f64_push_inbounds_resolved(arr, value) { return arr; } } diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 12a9e001eb..88bd5bdb7c 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -897,7 +897,10 @@ pub(super) unsafe fn dispatch_common( jsval.as_pointer::() as *mut crate::array::ArrayHeader; // Spec §23.1.3.21: length is Set even with 0 args, so guards fire regardless if crate::array::array_is_frozen(arr_ptr) { - crate::collection_iter::throw_type_error("Cannot mutate a frozen array"); + // What fails is CreateDataProperty for the NEW index, not a + // write to an existing read-only one, so node words this the + // same for freeze / seal / preventExtensions. + crate::array::throw_non_extensible_array_push(unsafe { (*arr_ptr).length }); } crate::array::guard_writable_length(arr_ptr); let mut arr = arr_ptr; diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index d4f306c499..2d47c3c7a0 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -424,9 +424,7 @@ pub(super) unsafe fn dispatch_handle( // args, so frozen / non-writable-length must throw. let arr = raw_ptr as *mut crate::array::ArrayHeader; if crate::array::array_is_frozen(arr) { - crate::collection_iter::throw_type_error( - "Cannot mutate a frozen array", - ); + crate::array::throw_non_extensible_array_push(unsafe { (*arr).length }); } crate::array::guard_writable_length(arr); let mut a = arr; diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 38bc1e4d9f..ffb9b526d6 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1801,17 +1801,27 @@ fn numeric_array_push_guard(arr: *const ArrayHeader, value: f64) -> bool { { return false; } - if crate::object::get_property_attrs(raw_addr, "length") - .map(|attrs| !attrs.writable()) - .unwrap_or(false) - { - return false; - } + // A non-writable `length` is reachable only through a descriptor + // write, and every one of those marks the receiver + // `OBJ_FLAG_ARRAY_DESCRIPTORS` (`array::named_props::mark_array_descriptors` + // — the bit is documented there as the shared "index-accessor / + // non-writable-length / sparse-index" gate). The flag test above has + // already returned `false` for any receiver carrying it, so by this + // point the array provably has no descriptors and its `length` is + // provably writable. `array::push_pop::array_length_is_non_writable_with_flags` + // encodes the same implication the other way round, short-circuiting on + // the flag before it will look a descriptor up at all. + // + // The lookup that used to sit here was therefore unreachable-true, and + // it was not cheap: a string-keyed descriptor probe on EVERY push. In a + // `for (…) { a.push(v); a.pop(); }` loop it was the single heaviest + // frame in the profile at 16.5% of all samples, more than the append it + // was guarding. len <= 16_000_000 && cap <= 16_000_000 && len < cap && is_numeric_value_bits(value.to_bits()) - && crate::array::js_array_is_numeric_f64_layout(arr) != 0 + && crate::array::js_array_is_numeric_f64_layout_resolved(arr) != 0 } } diff --git a/test-files/test_gap_array_push_integrity.ts b/test-files/test_gap_array_push_integrity.ts new file mode 100644 index 0000000000..bfad8e4526 --- /dev/null +++ b/test-files/test_gap_array_push_integrity.ts @@ -0,0 +1,57 @@ +// `Array.prototype.push` on a receiver whose integrity has been locked. +// +// §23.1.3.21 performs `Set(O, len, value, true)` and then +// `Set(O, "length", len+1, true)`. Both carry Throw=true, which is the +// mutator's OWN throw and does not depend on the caller's strictness — so +// every case below throws in this file whether node treats it as a module or +// as CommonJS. +// +// `preventExtensions` is the case that regressed: the dense append answered a +// non-extensible receiver with a bare `return arr`, which is correct for the +// INTERNAL CreateDataProperty-style append used to build fresh result arrays +// and wrong for user `push`. `seal` masked it, because sealing also marks the +// element descriptors and therefore routes the receiver down the exotic path, +// which threw for a different reason. +function push1(label: string, a: any): void { + try { + a.push(99); + console.log(label, "no-throw len=" + a.length); + } catch (e: any) { + console.log(label, e.constructor.name + ": " + e.message); + } +} + +const ext: number[] = [1, 2]; +Object.preventExtensions(ext); +push1("preventExtensions", ext); + +const sealed: number[] = [1, 2]; +Object.seal(sealed); +push1("seal", sealed); + +const frozen: number[] = [1, 2]; +Object.freeze(frozen); +push1("freeze", frozen); + +// Zero-argument push only performs the `length` Set, which succeeds on a +// non-extensible receiver: no throw, length unchanged. +const zero: number[] = [1, 2]; +Object.preventExtensions(zero); +zero.push(); +console.log("zero-arg push len", zero.length); + +// A non-writable `length` must still throw, and must keep throwing after the +// typed-feedback fast tier has been warmed by a hot loop — the re-check the +// guard performs per push is what this pins. +const warm: number[] = []; +for (let i = 0; i < 200; i++) { + warm.push(i); + warm.pop(); +} +Object.defineProperty(warm, "length", { writable: false }); +push1("warm-then-non-writable-length", warm); + +// A plain array is unaffected by all of the above. +const plain: number[] = [1, 2]; +plain.push(3); +console.log("plain push len", plain.length, "last", plain[plain.length - 1]); From 2fdba51ef435e2005eb0a723fea5d1005795f09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:45:29 +0200 Subject: [PATCH 2/9] docs: changelog fragment for #10414 --- .../10414-array-push-receiver-requeries.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 changelog.d/10414-array-push-receiver-requeries.md diff --git a/changelog.d/10414-array-push-receiver-requeries.md b/changelog.d/10414-array-push-receiver-requeries.md new file mode 100644 index 0000000000..7c518f00b8 --- /dev/null +++ b/changelog.d/10414-array-push-receiver-requeries.md @@ -0,0 +1,30 @@ +Stop re-deriving the receiver on every `Array.prototype.push`. A hot +`for (…) { a.push(v); a.pop(); }` loop cost 1,350 instructions per push+pop +pair, and a symbol-resolved profile showed more than half of that was not the +append but the append re-asking questions about a receiver it had already +resolved, once per helper in the chain. 1,350.6 -> 432.0, **-68.0%**, with +nothing inlined and nothing cached. + +Four removals. `typed_feedback::numeric_array_push_guard` probed the property +descriptor of `"length"` on every push — the heaviest frame in the loop at +16.5% of samples — although the flag test three lines above had already +rejected every receiver that could make it answer true (a non-writable +`length` always marks `OBJ_FLAG_ARRAY_DESCRIPTORS`). +`js_array_numeric_push_f64_unboxed` re-ran `clean_arr_ptr` three more times by +asking `array_is_sealed_or_no_extend`, `array_is_frozen` and +`guard_writable_length` in sequence, each of which goes through the +non-resolved `array_object_flags`; that function alone was 24.0% of the loop. +The append chain resolved twice more, and the exotic and numeric-layout checks +once each. `js_array_pop_f64` already carried this exact fix — `push` never got +it. + +Also fixes a parity bug the new fixture found: +`Object.preventExtensions(a); a.push(1)` silently kept the old length where +node throws. The dense append answers `SEALED | NO_EXTEND` with a bare +`return arr`, which is correct for the internal CreateDataProperty-style append +that builds fresh result arrays and wrong for user `push`; the observable entry +now throws, with node's wording. `Object.seal` had masked it by routing down +the exotic path for an unrelated reason. + +Known divergence left: frozen `pop` still reports "Cannot mutate a frozen +array" rather than node's "Cannot delete property 'N' of [object Array]". From e71541c3967754329a690078d950cd494cebaccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:57:40 +0200 Subject: [PATCH 3/9] perf(runtime): drop the provably-no-op layout note from the raw-f64 push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `layout_note_slot` was 15.0% of a push/pop loop — 96 of its 109 samples from the single call in `array_numeric_raw_f64_push_inbounds`. Its MASK work is a provable no-op there, because the caller has already proved the value is a plain number (`value_bits_to_number` returned `Some` two lines above). That argument is not new: it is written out in full on the object twin of `array_store_needs_layout_note`, which is how the codegen-side element store already elides the same call. It holds in every layout state the receiver can be in — `GC_LAYOUT_UNKNOWN` returns at the note's own state check; an intact typed descriptor lets a non-pointer fall through the pointer-mask arm untouched; `GC_LAYOUT_POINTER_FREE` hits the `!pointer && POINTER_FREE` early return; and under `GC_LAYOUT_SIDE_MASK` the note could only ever CLEAR this slot's bit, so skipping it leaves at worst a stale set bit over a numeric word, which costs one extra visit and nothing else because `gc::trace::mark_field_into_worklist` re-validates every slot word and rejects f64 bit patterns as out-of-range addresses. The #7480 element-shape invariant is NOT part of that argument and is kept — through `note_element_store_resolved_flags`, so it reads the header this function already holds instead of classifying the parent a second time. 346.0 instructions per push+pop pair, from 432.0. Together with the receiver work in this branch's first commit: 1,350.7 -> 346.0, -74.4%. The stale-mask case is the one that has to survive, so it gets a fixture built to produce it: fill array slots with POINTERS, pop them all, then refill the same slots with plain numbers, and interleave numbers and pointers in one array while a retained subset keeps a live graph. Under seeded GC scheduling with from-space protection, evacuation verification and PERRY_GC_FROMSPACE_SCAN_ABORT=1, three seeds each ran ~270,000 copying minors and ~33,700 from-space scans with dangling=0 and missing_rewrites=0, output byte-identical to node every time. --- crates/perry-runtime/src/array/header.rs | 22 ++++++++++++++++- crates/perry-runtime/src/array/mod.rs | 2 +- ...ap_array_push_numeric_over_pointer_slot.ts | 24 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_array_push_numeric_over_pointer_slot.ts diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index f978b1e5ea..c7d7042ad5 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1502,7 +1502,27 @@ pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds_resolved( let elements_ptr = array_elements_ptr(arr) as *mut f64; // GC_STORE_AUDIT(POINTER_FREE): raw-f64 push stores numeric payloads only. std::ptr::write(elements_ptr.add(length as usize), number); - crate::gc::layout_note_slot(arr as usize, length as usize, number.to_bits()); + // `layout_note_slot`'s MASK work is a provable no-op for a value that is a + // plain number, and `value_bits_to_number` just proved that above. The + // argument is the one already written out for the codegen-side elision on + // `array_store_needs_layout_note`'s object twin, and it holds in every + // layout state the receiver can be in: `GC_LAYOUT_UNKNOWN` returns at the + // note's own state check; an intact typed descriptor lets a non-pointer + // fall through the pointer-mask arm untouched; `GC_LAYOUT_POINTER_FREE` + // hits the note's `!pointer && POINTER_FREE` early return; and under + // `GC_LAYOUT_SIDE_MASK` the note could only ever CLEAR this slot's bit, so + // skipping it leaves at worst a stale set bit over a non-pointer word — + // which costs one extra visit and nothing else, because + // `gc::trace::mark_field_into_worklist` re-validates every slot word and + // rejects f64 bit patterns as out-of-range addresses. + // + // That leaves the #7480 element-shape invariant, which is NOT part of that + // argument and is kept — through the resolved-flags entry, so it reads the + // header this function already holds instead of classifying the parent a + // second time. Measured: `layout_note_slot` was 15.0% of a push/pop loop, + // 96 of its 109 samples from this one call. + let flags = array_object_flags_resolved(arr); + crate::array::note_element_store_resolved_flags(arr, length as usize, number.to_bits(), flags); (*arr).length = length + 1; true } diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index e4c5926058..7867c30922 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -102,7 +102,7 @@ pub use self::concat_reverse::{ }; pub(crate) use self::element_shape::{ forget_element_shape, invalidate_all_element_shapes, note_element_store, - prune_dead_element_shape_owners, transfer_element_shape, + note_element_store_resolved_flags, prune_dead_element_shape_owners, transfer_element_shape, }; pub use self::element_shape::{ js_array_element_shape_check, js_array_element_shape_class, js_array_element_shape_epoch, diff --git a/test-files/test_gap_array_push_numeric_over_pointer_slot.ts b/test-files/test_gap_array_push_numeric_over_pointer_slot.ts new file mode 100644 index 0000000000..2fe0c4094a --- /dev/null +++ b/test-files/test_gap_array_push_numeric_over_pointer_slot.ts @@ -0,0 +1,24 @@ +// The case the elision has to survive: a slot that HELD A POINTER, was popped, +// and is then re-filled with a plain number. Skipping the layout note leaves +// the slot's pointer-mask bit set over a numeric word, so the collector must +// re-validate slot words rather than trust the mask. +class Node2 { v: number; tag: string; constructor(v: number) { this.v = v; this.tag = "n" + v; } } +const retained: any[] = []; +let checksum = 0; +for (let round = 0; round < 400; round++) { + const a: any[] = []; + // fill with pointers, then drain + for (let i = 0; i < 24; i++) a.push(new Node2(round * 100 + i)); + while (a.length > 0) { const n = a.pop(); checksum += n.v % 7; } + // refill the SAME slots with plain numbers + for (let i = 0; i < 24; i++) a.push(i * 1.5); + while (a.length > 4) a.pop(); + // interleave: numbers and pointers into one array + for (let i = 0; i < 12; i++) { a.push(i); a.push(new Node2(i)); } + for (const e of a) { if (typeof e === "number") checksum += e; else checksum += e.v % 5; } + if (round % 40 === 0) retained.push(a); +} +console.log("checksum", checksum, "retained", retained.length); +let live = 0; +for (const a of retained) for (const e of a) if (typeof e !== "number" && e && e.tag) live++; +console.log("live tags", live); From 1c741bdf59d2d6fee3260f2b7ade94b06ea06f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:57:59 +0200 Subject: [PATCH 4/9] docs: changelog fragment for the layout-note elision --- changelog.d/10414-array-push-layout-note.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 changelog.d/10414-array-push-layout-note.md diff --git a/changelog.d/10414-array-push-layout-note.md b/changelog.d/10414-array-push-layout-note.md new file mode 100644 index 0000000000..fe5e490af4 --- /dev/null +++ b/changelog.d/10414-array-push-layout-note.md @@ -0,0 +1,14 @@ +Drop the provably-no-op layout note from the raw-f64 array push. +`layout_note_slot` was 15.0% of a push/pop loop, 96 of its 109 samples from the +single call in `array_numeric_raw_f64_push_inbounds`, where the caller has +already proved the value is a plain number. The mask work is then a no-op in +every layout state the receiver can be in — the same argument already written +out for the codegen-side elision on `array_store_needs_layout_note`'s object +twin. The #7480 element-shape invariant is kept, through the resolved-flags +entry so it reads the header the caller already holds. + +Validated on the shape that would expose a mistake: array slots filled with +POINTERS, popped, then refilled with plain numbers, with a retained live graph. +Three seeds under from-space protection, evacuation verification and +PERRY_GC_FROMSPACE_SCAN_ABORT=1 each ran ~270,000 copying minors and ~33,700 +from-space scans with dangling=0 and missing_rewrites=0, byte-identical to node. From b0f7fce92ebc9873277af5ddae4ad1640f38e976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 11:50:06 +0200 Subject: [PATCH 5/9] fix(runtime): delete the array push wrapper that lost its last caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_numeric_raw_f64_push_inbounds` existed only to resolve the receiver and delegate. Its one caller now holds a resolved head and calls the _resolved core directly, so the wrapper is dead — `-D warnings` caught it as an unused import plus a never-used function, which is the right verdict. Deleted rather than re-exported or silenced: leaving a resolving wrapper in place is exactly the shape this branch is removing. --- crates/perry-runtime/src/array/header.rs | 10 ---------- crates/perry-runtime/src/array/mod.rs | 6 +++--- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index c7d7042ad5..4b0187379e 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1460,16 +1460,6 @@ pub(crate) unsafe fn array_numeric_raw_f64_set_inbounds( } #[inline] -pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds( - arr: *mut ArrayHeader, - value: f64, -) -> bool { - let arr = clean_arr_ptr_mut(arr); - if arr.is_null() { - return false; - } - unsafe { array_numeric_raw_f64_push_inbounds_resolved(arr, value) } -} /// [`array_numeric_raw_f64_push_inbounds`] for an already-resolved receiver. /// diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 7867c30922..f085ceffce 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -282,9 +282,9 @@ pub(crate) use self::alloc::{js_array_from_arraylike, js_array_from_string_codep pub(crate) use self::flat_clone::{dense_spread_copy, dense_spread_source, flattenable_array_ptr}; pub(crate) use self::header::{ array_byte_size, array_is_frozen, array_is_sealed_or_no_extend, array_numeric_raw_f64_get, - array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_push_inbounds_resolved, - array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, - array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, + array_numeric_raw_f64_push_inbounds_resolved, array_numeric_raw_f64_set_inbounds, + array_object_flags, array_object_flags_from_tag, array_object_flags_resolved, + array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value_from_flags, clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, finish_array_dense_move_layout, gc_element_slot_range, mark_array_layout_unknown, From 1d1387ffe8fee333e9da5b0b161e62307b6b1296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 10:39:00 +0000 Subject: [PATCH 6/9] perf(gc): decode a visited word once in the copying minor (#10362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base: e6dcb6274d0ce91a38c7b43850cc79d8ce870857 (main). A raw (untagged) word was classified TWICE on the copying minor's slot path: `CopyingPointerSet::decode_bits` classified it only to validate it, and `mark_addr` classified it again. Every traced shaped object visits its shape record's `keys` word, a raw address, so that was a second page-table probe and `plausible_gc_header` read per traced object. The remembering arm then re-decoded the very slot the visit had just decoded. `visit_value_bits_child` now decodes, classifies and marks once, and returns the child's address as the word reads after the visit. The validating classification is the one the mark uses (the memo is still consulted after it, in the same order as before), and the remembering arm reuses that child; only a raw word that MOVED is validated again, which is all the re-decode could still reject. Two codegen facts are load-bearing, measured, and pinned by comment: * the decode is `#[inline(always)]` — out of line, its frame and the by-memory return of its result cost as much as the classification it saves (that first cut measured flat to +1.05% on the six fixtures); * `barrier_parent_needs_remembering` is asked BEFORE the visit. It reads only the parent and the slot's own address, never the child, so the order cannot change its answer — but asked after, the optimizer duplicated the call into both decode arms and stopped inlining it, which cost a third of the win on gc3 and more on w20000. instructions:u, min of 5, same host, base vs this: gc3 11,756,388,818 -> 11,541,805,498 -1.83% w5000 1,886,467,237 -> 1,853,133,603 -1.77% w20000 4,727,775,800 -> 4,655,712,038 -1.52% oldyoung 1,454,636,978 -> 1,433,367,558 -1.46% w1000 1,045,691,197 -> 1,036,890,921 -0.84% alloc 320,204,861 -> 320,203,910 -0.00% Exact counts under callgrind agree (gc3 -1.79%) and attribute it: `classify_arena` calls fall from 6.09M to 4.20M on gc3, and on the pointer-slot control the per-slot term falls from 379.2 to 349.6 instructions at K=16. Peak RSS and max pause are flat within their own run-to-run spread on all six fixtures. Witness: `gc::tests::copy_slot_decode`, two behavioural tests each with a sabotaged twin — a raw word's child must be evacuated and the word rewritten (sabotage: drop the validated raw word, and the word goes stale), and an old parent's edge must be re-remembered from the decoded child (sabotage: forget it, and `restore_surviving_dirty_coverage`'s cross-check refuses the cycle). --- .../10362-copying-minor-single-decode.md | 35 ++++ crates/perry-runtime/src/gc/copying.rs | 29 ++-- .../src/gc/copying_parent_facts.rs | 150 ++++++++++++++++-- .../src/gc/tests/copy_slot_decode.rs | 141 ++++++++++++++++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + 5 files changed, 326 insertions(+), 30 deletions(-) create mode 100644 changelog.d/10362-copying-minor-single-decode.md create mode 100644 crates/perry-runtime/src/gc/tests/copy_slot_decode.rs diff --git a/changelog.d/10362-copying-minor-single-decode.md b/changelog.d/10362-copying-minor-single-decode.md new file mode 100644 index 0000000000..ee5939e024 --- /dev/null +++ b/changelog.d/10362-copying-minor-single-decode.md @@ -0,0 +1,35 @@ +Decode each word the copying minor visits once. A raw (untagged) word was +classified twice: `CopyingPointerSet::decode_bits` classified it only to +validate it, and `mark_addr` classified it again. Every traced shaped object +visits its shape record's `keys` word, a raw address, so that was a second +page-table probe and header read per traced object. The slot visit's +remembering arm then re-decoded the slot it had just decoded. The validating +classification is now the one the mark uses (the memo is still consulted after +it, as before), and the remembering arm reuses the child the visit decoded; +only a raw word that moved is validated again, which is all the re-decode could +still reject. + +Two codegen facts are load-bearing and pinned by comment. The decode is +`#[inline(always)]`: out of line, its call frame and the by-memory return of +its result cost as much as the classification it saves (the first cut measured +flat to +1.05%). And `barrier_parent_needs_remembering` is asked before the +visit rather than after. It reads only the parent and the slot's address, so +the order does not change the answer, but asked after, the optimizer +duplicated the call into both decode arms and stopped inlining it, which gave +back a third of the win on gc3 (-1.20% instead of -1.80%) and more than a third +on w20000 (-0.84% instead of -1.44%). + +Measured on six GC fixtures, instructions:u min-of-5: gc3 -1.83%, w5000 -1.77%, +w20000 -1.52%, oldyoung -1.46%, w1000 -0.84%, alloc flat (-951 instructions). +Exact instruction counts under callgrind agree: gc3 -1.79%, with +`classify_arena` calls down from 6.09M to 4.20M. On the pointer-slot control +(60k records whose K fields all point at one shared object, against the same +records holding doubles) the per-slot term falls from 379.2 to 349.6 +instructions at K=16, counted exactly under callgrind: a memo hit no longer pays a call to +`mark_addr`, and the re-decode's classification is gone. + +The page-generation cache was read before any of this was attempted. It runs +the direct-mapped table arm with a 93.4-97.3% hit rate, and at most 0.02% of +lookups are capacity misses. Nearly every miss is an address in no registered +block: the shape record's `keys` slot, which lives outside the heap. So the +cache's size was not the problem, and nothing here changes it. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 52442f4f6f..7a510d1d64 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -313,19 +313,6 @@ impl CopyingNurseryCollector { } } - pub(super) fn visit_value_bits(&mut self, bits: u64) -> Option { - let (addr, is_nanbox, tag) = self.ptrs.decode_bits(bits)?; - let new_addr = self.mark_addr(addr)?; - if new_addr == addr { - return None; - } - Some(if is_nanbox { - tag | (new_addr as u64 & POINTER_MASK) - } else { - new_addr as u64 - }) - } - pub(super) fn visit_raw_addr(&mut self, addr: usize) -> Option { let new_addr = self.mark_addr(addr)?; (new_addr != addr).then_some(new_addr) @@ -430,6 +417,20 @@ impl CopyingNurseryCollector { return Some(self.memo_result); } let ptr = self.ptrs.classify(addr)?; + Some(self.mark_classified(addr, ptr)) + } + + /// [`mark_addr`](Self::mark_addr) for an address the caller has already + /// classified: the memo, then the mark, without classifying again. + #[inline] + pub(super) fn mark_classified_addr(&mut self, addr: usize, ptr: CopyingPointer) -> usize { + if addr == self.memo_addr { + return self.memo_result; + } + self.mark_classified(addr, ptr) + } + + fn mark_classified(&mut self, addr: usize, ptr: CopyingPointer) -> usize { let result = match ptr.kind { CopyingPointerKind::Eden | CopyingPointerKind::FromSurvivor => unsafe { self.move_young(ptr) @@ -457,7 +458,7 @@ impl CopyingNurseryCollector { }; self.memo_addr = addr; self.memo_result = result; - Some(result) + result } /// #7742: the object's block is being promoted whole, in place. It does not diff --git a/crates/perry-runtime/src/gc/copying_parent_facts.rs b/crates/perry-runtime/src/gc/copying_parent_facts.rs index 2cecd82327..19b6d06833 100644 --- a/crates/perry-runtime/src/gc/copying_parent_facts.rs +++ b/crates/perry-runtime/src/gc/copying_parent_facts.rs @@ -1,5 +1,6 @@ -//! The per-parent weak-holder fact the copying minor's slot visit reads, and -//! the slot visit itself. Split out of `gc/copying.rs` for the 2000-line lint. +//! The per-parent weak-holder fact the copying minor's slot visit reads, the +//! slot visit itself, and its single decode of the visited word. Split out of +//! `gc/copying.rs` for the 2000-line lint. use super::*; @@ -58,7 +59,107 @@ pub(crate) mod copy_hoist_sabotage { } } +/// Test-only sabotage for the single decode per slot visit +/// (`visit_value_bits_child`). Witness: `gc::tests::copy_slot_decode`. +#[cfg(test)] +pub(crate) mod copy_decode_sabotage { + use std::cell::Cell; + + /// The validated raw word is dropped instead of marked. + pub(crate) const RAW_MARK: u8 = 1; + /// The remembering arm loses the child the visit decoded. + pub(crate) const CHILD: u8 = 2; + + thread_local! { + static FORGET: Cell = const { Cell::new(0) }; + } + + pub(crate) fn forgetting(what: u8) -> bool { + FORGET.with(|f| f.get() & what != 0) + } + + pub(crate) struct Guard(u8); + + impl Guard { + pub(crate) fn arm(what: u8) -> Self { + Self(FORGET.with(|f| f.replace(f.get() | what))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + FORGET.with(|f| f.set(self.0)); + } + } +} + impl CopyingNurseryCollector { + /// The root visitors' form of [`Self::visit_value_bits_child`]. Always + /// inlined too: out of line it added a call frame per root word. + #[inline(always)] + pub(super) fn visit_value_bits(&mut self, bits: u64) -> Option { + self.visit_value_bits_child(bits)?.1 + } + + /// Decode, classify and mark one value word ONCE: the child's address as + /// the word reads after this visit, the word's new bits if the child + /// moved, and whether the word was raw. `None` is exactly + /// `CopyingPointerSet::decode_bits`'s `None`: not a heap reference. + /// + /// A raw word used to be classified twice — by `decode_bits`, only to + /// validate it, and again by `mark_addr`. Every traced shaped object visits + /// its shape record's `keys` word, a raw address, so that was a second + /// page-table probe and header read per traced object. The validating + /// classification is now the one the mark uses; the memo is still + /// consulted after it, as before. + /// + /// Always inlined: out of line, the extra frame and the by-memory return + /// of the triple cost as much as the classification it saves. + #[inline(always)] + pub(super) fn visit_value_bits_child( + &mut self, + bits: u64, + ) -> Option<(usize, Option, bool)> { + let tag = bits & TAG_MASK; + if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { + let addr = (bits & POINTER_MASK) as usize; + if addr == 0 { + return None; + } + // An unclassifiable NaN-boxed word is still a reference to the + // remembering arm, which never classified NaN-boxed words. + return Some(match self.mark_addr(addr) { + Some(new_addr) if new_addr != addr => { + let new_bits = tag | (new_addr as u64 & POINTER_MASK); + ((new_bits & POINTER_MASK) as usize, Some(new_bits), false) + } + _ => (addr, None, false), + }); + } + if tag >= 0x7FF8_0000_0000_0000 || !CopyingPointerSet::raw_pointer_candidate(bits) { + return None; + } + let addr = bits as usize; + let ptr = self.ptrs.classify(addr)?; + #[cfg(test)] + if copy_decode_sabotage::forgetting(copy_decode_sabotage::RAW_MARK) { + return None; + } + let new_addr = self.mark_classified_addr(addr, ptr); + Some(( + new_addr, + (new_addr != addr).then_some(new_addr as u64), + true, + )) + } + + /// The remembering arm's one remaining re-decode: a raw word that moved. + /// Out of line so the rare case does not grow the slot visit it sits in. + #[inline(never)] + fn revalidate_moved_raw(&self, bits: u64) -> Option { + self.ptrs.decode_bits(bits).map(|(addr, _, _)| addr) + } + pub(super) unsafe fn visit_slot_with_parent( &mut self, slot: *mut u64, @@ -100,22 +201,39 @@ impl CopyingNurseryCollector { self.weak_slots.push(slot); return; } - let bits = *slot; - if let Some(new_bits) = self.visit_value_bits(bits) { + // Asked BEFORE the visit: it reads only the parent and the slot's own + // address, never the child. Asked after, the optimizer duplicated the + // call into both decode arms and then stopped inlining it. + let remembering = !parent_header.is_null() + && !self.skip_remembering + && barrier_parent_needs_remembering( + (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize, + external, + ); + let visited = self.visit_value_bits_child(*slot); + if let Some((_, Some(new_bits), _)) = visited { *slot = new_bits; } - if !parent_header.is_null() && !self.skip_remembering { - let parent_user = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; - if barrier_parent_needs_remembering(parent_user, external) { - if let Some((child_addr, _, _)) = self.ptrs.decode_bits(*slot) { - // Keep old→malloc pages dirty alongside old→nursery: - // the malloc child is spared by this cycle's mark - // (mark_addr handles CopyingPointerKind::Malloc) but - // the NEXT minor's malloc sweep needs the edge again. - if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { - self.sticky.remember_slot(parent_header, slot, external); - } - } + if !remembering { + return; + } + // The visit above already decoded this word; re-decoding `*slot` + // repeated it. Only a raw word that MOVED is validated again, which is + // all the re-decode could still reject. + let child = match visited { + Some((_, Some(new_bits), true)) => self.revalidate_moved_raw(new_bits), + other => other.map(|(addr, _, _)| addr).filter(|&addr| addr != 0), + }; + #[cfg(test)] + let child = + child.filter(|_| !copy_decode_sabotage::forgetting(copy_decode_sabotage::CHILD)); + if let Some(child_addr) = child { + // Keep old→malloc pages dirty alongside old→nursery: the malloc + // child is spared by this cycle's mark (mark_addr handles + // CopyingPointerKind::Malloc) but the NEXT minor's malloc sweep + // needs the edge again. + if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { + self.sticky.remember_slot(parent_header, slot, external); } } } diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs new file mode 100644 index 0000000000..68f6d1008c --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs @@ -0,0 +1,141 @@ +//! The copying minor decodes each visited word ONCE (`visit_value_bits_child`): +//! a raw word's validating classification is the one the mark uses, and the +//! remembering arm reuses the child the visit decoded instead of re-decoding +//! the slot. Both halves are pinned by collections and what they leave in the +//! heap, and each has a sabotaged twin that must fail. + +use super::super::*; +use super::support::*; +use crate::gc::copying_parent_facts::copy_decode_sabotage::{Guard, CHILD, RAW_MARK}; + +fn string_bytes(addr: usize) -> Vec { + unsafe { + let s = addr as *const crate::StringHeader; + let data = (s as *const u8).add(std::mem::size_of::()); + std::slice::from_raw_parts(data, (*s).byte_len as usize).to_vec() + } +} + +/// A young string reachable ONLY through a RAW (untagged) word in a rooted +/// young object. Returns whether the minor evacuated it through that word. +fn raw_child_evacuated(sabotaged: bool) -> bool { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _roots = ShadowAndGlobalRootResetGuard; + let (parent, fields) = unsafe { alloc_nursery_test_object(1) }; + let child = young_leaf(); + let expected = string_bytes(child); + unsafe { *fields = child as u64 }; + js_shadow_slot_set(0, ptr_bits(parent as usize)); + assert!( + crate::arena::pointer_in_nursery(child), + "premise: the child must be young, or there is nothing to evacuate" + ); + { + let _sabotage = sabotaged.then(|| Guard::arm(RAW_MARK)); + let _ = gc_collect_minor(); + } + let parent_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!( + parent_after, parent as usize, + "premise: the rooted parent moved" + ); + let word = unsafe { + *((parent_after as *const u8).add(std::mem::size_of::()) + as *const u64) + }; + // Checked before any read through `word`: a stale word names from-space. + word != child as u64 && string_bytes(word as usize) == expected + }) + .join() + .expect("raw-word decode test thread must not panic") +} + +#[test] +fn a_raw_word_is_marked_through_its_validating_classification() { + assert!( + raw_child_evacuated(false), + "the young child behind a raw word must be evacuated and the word rewritten" + ); +} + +#[test] +fn sabotaged_raw_mark_leaves_the_raw_word_stale() { + assert!( + !raw_child_evacuated(true), + "with the validated raw word dropped instead of marked, the child is not evacuated" + ); +} + +/// An OLD parent whose NaN-boxed slot holds a young child, handed to the minor +/// through the write barrier, then two minors: the second finds the edge only +/// if the first re-remembered it from the child its visit decoded. `Err` is +/// the collection thread's panic message. +fn old_edge_across_two_minors(sabotaged: bool) -> Result { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = crate::gc::tenuring::set_survivals_for_test( + crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX, + ); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _roots = ShadowAndGlobalRootResetGuard; + let (parent, fields) = unsafe { alloc_old_test_object(1) }; + let child = young_leaf(); + let expected = string_bytes(child); + unsafe { *fields = ptr_bits(child) }; + js_write_barrier_slot(ptr_bits(parent as usize), fields as u64, ptr_bits(child)); + assert!( + crate::arena::pointer_in_old_gen(parent as usize) + && crate::arena::pointer_in_nursery(child), + "premise: an old parent and a young child" + ); + let read = || unsafe { (*fields & POINTER_MASK) as usize }; + { + let _sabotage = sabotaged.then(|| Guard::arm(CHILD)); + let _ = gc_collect_minor(); + } + let first = read(); + assert!( + first != child && crate::arena::pointer_in_nursery(first), + "premise: the first minor copied the child within the nursery" + ); + let _ = gc_collect_minor(); + let second = read(); + second != first && string_bytes(second) == expected + }) + .join() + .map_err(|payload| { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_default() + }) +} + +#[test] +fn an_old_parents_edge_is_remembered_from_the_child_the_visit_decoded() { + assert_eq!( + old_edge_across_two_minors(false), + Ok(true), + "the second minor must find and move the child through the remembered edge" + ); +} + +/// In a release build `restore_surviving_dirty_coverage` would re-add the page +/// the arm failed to remember, which is why a forgotten remembered-set entry +/// is invisible to a survival check alone. In the debug build `cargo test` +/// runs, the same walk cross-checks the dirty scan's per-slot re-remembering +/// and refuses the disagreement — that refusal is this twin's observable. +#[test] +fn sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check() { + let outcome = old_edge_across_two_minors(true); + assert!( + matches!(&outcome, Err(message) if message.contains("restore_surviving_dirty_coverage")), + "with the decoded child forgotten, the coverage walk must report the \ + unremembered page; got {outcome:?}" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 1ffdcb9893..3307fdea2b 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -17,6 +17,7 @@ mod census_block_windows; mod census_whole_block; mod concat_site; mod contract; +mod copy_slot_decode; mod copy_slot_hoists; mod copying; mod copying_side_tables; From f96cf800de6b3a6f22e58e1a93ea9c97d1fba4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 12:45:01 +0200 Subject: [PATCH 7/9] changelog: key the fragment to #10491 --- ...inor-single-decode.md => 10491-copying-minor-single-decode.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10362-copying-minor-single-decode.md => 10491-copying-minor-single-decode.md} (100%) diff --git a/changelog.d/10362-copying-minor-single-decode.md b/changelog.d/10491-copying-minor-single-decode.md similarity index 100% rename from changelog.d/10362-copying-minor-single-decode.md rename to changelog.d/10491-copying-minor-single-decode.md From d4b75a9306164d5022296c44bb3a631fbf35b7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 14:00:33 +0200 Subject: [PATCH 8/9] tooling(string): read the payload through string_data in the decode test #10491's new `copy_slot_decode.rs` helper open-codes the StringHeader payload offset as `.add(size_of::())`, which raises the string payload-access ratchet for perry-runtime from 350 to 351. The baseline is debt, not an allowance for new code. `crate::string::string_data()` is the sanctioned accessor and is what `OwnedStringBytes::copy_from_header` uses internally, so the read is byte-for-byte identical. Follows `gc/tests/concat_site.rs:29`. --- crates/perry-runtime/src/gc/tests/copy_slot_decode.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs index 68f6d1008c..bed7c41126 100644 --- a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs +++ b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs @@ -11,8 +11,7 @@ use crate::gc::copying_parent_facts::copy_decode_sabotage::{Guard, CHILD, RAW_MA fn string_bytes(addr: usize) -> Vec { unsafe { let s = addr as *const crate::StringHeader; - let data = (s as *const u8).add(std::mem::size_of::()); - std::slice::from_raw_parts(data, (*s).byte_len as usize).to_vec() + std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize).to_vec() } } From 80f7a4b615b84f83930b8eb29bf84d802b8a0f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 14:00:49 +0200 Subject: [PATCH 9/9] chore: release merge train 213 as v0.5.1591 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e693c8873..a3a2dccc99 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.1590 +**Current Version:** 0.5.1591 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7b8a02ac45..33f676ed1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-parser" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-ui-tvos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 3161d1ec2d..b14b82b1b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1590" +version = "0.5.1591" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"