From ba00c9a8a9ea9beaa82d4f9306d2f512a3d42f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 17:41:28 +0200 Subject: [PATCH 1/6] perf(gc): decide a class-field store's GC bookkeeping with one inline test, not three calls (#7511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write barriers are 16.1% of `churn_alloc`'s symbolicated profile on a program whose stores are all doubles, and #5334 lever D — which elides the barrier for a value that is a non-pointer BY CONSTRUCTION — never fires there. WHY lever D cannot fire: since the `[#bloat]` `force_ctor_call` default (`lower_call/new.rs:989`), a class with its own constructor is NOT inlined at the `new` site — its body is compiled once as the shared `_constructor(this, p0, …)` symbol. HIR rewrites every closed-shape object literal into a `New` of a synthesized anon-shape class with exactly that shape (`lower/context.rs::mint_anon_shape_class`), so `{v: base+j, w: j}` also lands there. The expression reaching the field store is therefore `Expr::LocalGet()` — an LLVM function argument of a function shared by every `new` site in the module, including ones passing pointers. No by-construction proof about that value can exist, and the declared `v: number` is not a layout fact (CLAUDE.md, "No runtime type *validation*"). What CAN be decided there is the same question the three bookkeeping callees each ask first, one at a time, across three cross-crate calls: `js_write_barrier_slot` (`barrier_child_prologue` returns immediately when `decode_heap_addr(child) == 0`), `js_string_addref_if_heap_string` (tag-checked no-op off `STRING_TAG`), and `js_gc_note_slot_layout` (for a non-pointer value it can only ever CLEAR mask state, never set it — the identical argument `class_field_store_needs_layout_note` already ships, including its `requires_raw_f64 == false` precondition). Ask it ONCE, inline, and branch over all three: may_carry_heap_pointer(bits) := (bits >> 48) ∈ { 0x7FFA, 0x7FFD, 0x7FFF } // BIGINT/POINTER/STRING || ((bits >> 48) == 0 && bits >= 0x1000) // bare heap address The slot store itself stays unconditional and outside the branch. Callers that already proved the value statically pass all three flags false, so lever D's existing elision emits no test and no blocks at all. Correctness rests on this being a SUPERSET of what the runtime resolves to a heap pointer, so it is pinned from both sides: `perry-runtime`'s new `gc::tests::inline_pointer_bearing_contract` enumerates all 65,536 tags against `decode_heap_addr` AND `layout_pointer_bearing_bits`, and carries a stranding witness — an OLD parent, a YOUNG child, the exact guarded store sequence — where a sabotaged always-false guard leaves the old->young edge unrecorded and the verifier rejects it, while the shipped guard keeps it and the remembered-set scan marks the child. Measured on `gc-handoff/bench/churn_alloc.ts` via `PERRY_GC_TRACE` counters: barrier calls 79,780,888 -> 39,890,444, and `non_pointer_child_skips` 39,890,444 -> 0. Exactly the wasted calls, and every remaining call now does real work. --- crates/perry-codegen/src/block.rs | 12 + crates/perry-codegen/src/expr/mod.rs | 10 +- crates/perry-codegen/src/expr/property_set.rs | 41 ++- .../perry-codegen/src/expr/write_barrier.rs | 153 +++++++- crates/perry-codegen/src/nanbox.rs | 48 +++ .../tests/class_field_store_pointer_test.rs | 348 ++++++++++++++++++ .../tests/inline_pointer_bearing_contract.rs | 268 ++++++++++++++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + 8 files changed, 862 insertions(+), 19 deletions(-) create mode 100644 crates/perry-codegen/tests/class_field_store_pointer_test.rs create mode 100644 crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 0d2dc10785..7ef183118f 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -524,6 +524,18 @@ impl LlBlock { r } + pub fn icmp_uge(&mut self, ty: LlvmType, a: &str, b: &str) -> String { + let r = self.reg(); + self.push_inst(crate::inst::LlInst::ICmp { + dst: r.clone(), + pred: "uge", + ty, + a: a.to_string(), + b: b.to_string(), + }); + r + } + pub fn icmp_sge(&mut self, ty: LlvmType, a: &str, b: &str) -> String { let r = self.reg(); self.push_inst(crate::inst::LlInst::ICmp { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index af53065703..42bd4e681a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -119,11 +119,11 @@ pub(crate) use v8_interop::{ }; pub(crate) use write_barrier::{ emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_on_block, - emit_jsvalue_slot_store_scalar_aware_on_block, emit_jsvalue_slot_store_with_flags_on_block, - emit_jsvalue_slot_store_with_value_bits_on_block, emit_root_heap_word_store_on_block, - emit_root_nanbox_store_on_block, emit_write_barrier, emit_write_barrier_slot_on_block, - lower_array_super_init, lower_event_emitter_subclass_init, lower_node_stream_super_init, - lower_stream_super_init, + emit_jsvalue_slot_store_pointer_tested, emit_jsvalue_slot_store_scalar_aware_on_block, + emit_jsvalue_slot_store_with_flags_on_block, emit_jsvalue_slot_store_with_value_bits_on_block, + emit_root_heap_word_store_on_block, emit_root_nanbox_store_on_block, emit_write_barrier, + emit_write_barrier_slot_on_block, lower_array_super_init, lower_event_emitter_subclass_init, + lower_node_stream_super_init, lower_stream_super_init, }; // Issue #1098 phase 3: the `FnCtx` definition stays in this trunk, but its diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 3b729ee15f..4b8d4d37b8 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -20,7 +20,7 @@ use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; use super::{ class_field_store_needs_layout_note, class_field_store_needs_string_addref, - emit_jsvalue_slot_store_with_flags_on_block, emit_typed_feedback_register_site, + emit_jsvalue_slot_store_pointer_tested, emit_typed_feedback_register_site, expr_produces_non_pointer_bits_by_construction, lower_expr, lower_expr_native, raw_f64_layout_fact, try_lower_pod_field_set, unbox_to_i64, FnCtx, TypedFeedbackContract, TypedFeedbackKind, @@ -868,10 +868,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { class_field_store_needs_layout_note(ctx, value); let string_addref_needed = class_field_store_needs_string_addref(ctx, value); - let blk = ctx.block(); - let field_addr = blk.ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_with_flags_on_block( - blk, + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + // #7511: whatever these three flags could not + // be proved away statically is decided by ONE + // live test of the stored bits — see + // `emit_jsvalue_slot_store_pointer_tested`. + emit_jsvalue_slot_store_pointer_tested( + ctx, &field_ptr, &val_double, &obj_handle, @@ -1056,16 +1059,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple) .to_string(); - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let field_ptr = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) + }; let raw_stored_value = if requires_raw_f64 { // Guarded raw-f64 slots are pointer-free by typed // shape descriptor; non-number writes miss the // guard and use the boxed setter fallback. // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class // slots contain numbers only. + let blk = ctx.block(); let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, &val_double); blk.store(DOUBLE, &numeric_value, &field_ptr); @@ -1083,9 +1089,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `requires_raw_f64` is false here, which is the // precondition `class_field_store_needs_layout_note` // documents. - let field_addr = blk.ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_with_flags_on_block( - blk, + // + // #7511: this is the arm the shared + // `_constructor` symbol lands on, where the + // value is an opaque function parameter and lever D + // can never fire. Whatever survives it is decided by + // ONE live test of the stored bits instead of three + // cross-crate calls that each re-ask the same + // question — see + // `emit_jsvalue_slot_store_pointer_tested`. + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_pointer_tested( + ctx, &field_ptr, &val_double, &obj_handle, @@ -1098,7 +1113,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); None }; - blk.br(&merge_label); + ctx.block().br(&merge_label); raw_stored_value }; if let Some(numeric_value) = raw_stored_value { diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index e4d0fcdcb1..5941fb05f6 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -9,7 +9,7 @@ use super::{lower_expr, FnCtx}; use crate::block::LlBlock; use crate::nanbox::double_literal; use crate::native_value::LoweredValue; -use crate::types::{DOUBLE, I32, I64}; +use crate::types::{DOUBLE, I1, I32, I64}; /// Gen-GC Phase C2 helper: emit a write barrier after heap-store sites /// by default. Only explicit `PERRY_WRITE_BARRIERS=0`/`off`/`false` @@ -260,6 +260,157 @@ pub(crate) fn emit_jsvalue_slot_store_scalar_aware_on_block( ) } +/// #7511 — emit the `i1` predicate "these NaN-boxed bits MAY carry a heap +/// pointer", as a superset of every heap address the runtime can decode. +/// +/// This is the codegen mirror of `perry-runtime::gc::barrier::decode_heap_addr` +/// and `gc::layout::layout_pointer_bearing_bits`, narrowed to the part that can +/// be decided from the top 16 bits alone. Both runtime functions resolve a heap +/// address in exactly two situations: +/// +/// - the NaN-box tag is `POINTER_TAG` / `STRING_TAG` / `BIGINT_TAG` +/// (`0x7FFD` / `0x7FFF` / `0x7FFA`), or +/// - the value is a **bare** heap address: all-zero high 16 bits +/// (`decode_heap_addr`'s `(bits >> 48) != 0` reject, +/// `layout_pointer_bearing_bits`' `bits <= POINTER_MASK` range) and at or +/// above a low floor — `0x10000` in `decode_heap_addr`, `0x1000` in +/// `layout_pointer_bearing_bits`. The **lower** of the two floors is used +/// here, so neither runtime predicate can accept a value this one rejects. +/// +/// Everything else — every plain IEEE double, `SHORT_STRING_TAG` (inline data, +/// not a pointer), `JS_HANDLE`, the `0x7FFC` primitives, `INT32_TAG`, +/// `STATIC_DISPATCH_TAG` — is rejected by both, so this predicate is `false` +/// for them. The floor is what keeps `0.0` — whose bit pattern is all zeros, +/// and which is one of the most-stored values in any program — on the fast +/// path instead of colliding with the bare-address arm. +/// +/// **The direction of the approximation is load-bearing.** The refinements the +/// runtime applies *after* these tests (a null pointer payload, a misaligned +/// address, an arena page-map miss) are all further REJECTIONS. Dropping them +/// can only make this predicate say "maybe" where the runtime would say "no" — +/// an extra call that does nothing. It can never say "no" where the runtime +/// would say "yes", which is the direction that strands a child. +/// `perry-runtime`'s `gc::tests::inline_pointer_bearing_contract` enumerates +/// the entire 16-bit tag space against both runtime predicates to pin exactly +/// that, and `nanbox::inline_pointer_bearing_top16_set_covers_every_heap_tag` +/// pins the comparand set against the tag constants. +pub(crate) fn emit_may_carry_heap_pointer_check(blk: &mut LlBlock, value_bits: &str) -> String { + use crate::nanbox::{BIGINT_TAG_TOP16_I64, POINTER_TAG_TOP16_I64, STRING_TAG_TOP16_I64}; + // `layout_pointer_bearing_bits`' floor (`0x1000`), the lower of the two. + const BARE_HEAP_ADDR_FLOOR_I64: &str = "4096"; + let top16 = blk.lshr(I64, value_bits, "48"); + let top16_zero = blk.icmp_eq(I64, &top16, "0"); + let above_floor = blk.icmp_uge(I64, value_bits, BARE_HEAP_ADDR_FLOOR_I64); + let is_raw_addr = blk.and(I1, &top16_zero, &above_floor); + let is_pointer_tag = blk.icmp_eq(I64, &top16, POINTER_TAG_TOP16_I64); + let is_string_tag = blk.icmp_eq(I64, &top16, STRING_TAG_TOP16_I64); + let is_bigint_tag = blk.icmp_eq(I64, &top16, BIGINT_TAG_TOP16_I64); + let tagged = blk.or(I1, &is_pointer_tag, &is_string_tag); + let tagged = blk.or(I1, &tagged, &is_bigint_tag); + blk.or(I1, &tagged, &is_raw_addr) +} + +/// #7511 — a class-field JSValue slot store whose three GC-bookkeeping calls +/// are placed behind ONE inline, live test of the stored value. +/// +/// ## Why a live test rather than a wider static proof +/// +/// The bookkeeping this guards costs 16.1% of `churn_alloc`'s profile on a +/// program whose stores are all doubles, and #5334 lever D — which elides it +/// for a value that is a non-pointer BY CONSTRUCTION — never fires there. The +/// reason is structural, not a missing arm: since the `[#bloat]` +/// `force_ctor_call` default (`lower_call/new.rs`), a class with its own +/// constructor is NOT inlined at the `new` site. Its body is compiled once as +/// the shared `_constructor(this, p0, …)` symbol, and HIR rewrites every +/// closed-shape object literal into a `New` of a synthesized anon-shape class +/// with exactly that shape (`lower/context.rs::mint_anon_shape_class`). So the +/// expression reaching the field store is `Expr::LocalGet()` — an +/// LLVM *function argument* of a function shared by every `new` site in the +/// module, including ones passing pointers. No by-construction proof about that +/// value can exist, and a declared `v: number` is not a layout fact (CLAUDE.md, +/// "No runtime type *validation*"): the field legitimately receives a string +/// through an `any`. +/// +/// What CAN be decided there is the same question the three callees each ask +/// first, at runtime, one at a time, across three cross-crate calls. Asking it +/// ONCE inline and branching over all three is exactly #7501's shape (a live +/// test at the store standing in for a static claim that cannot be made). +/// +/// ## Why each call is dead when the test says "no pointer" +/// +/// - `js_write_barrier_slot` → `write_barrier_slot_inner` opens with +/// `barrier_child_prologue`, which returns immediately when +/// `decode_heap_addr(child) == 0`. Nothing else in the barrier runs — not the +/// incremental-mark shading (there is no heap object to shade), not the +/// remembered set (a non-pointer publishes no old→young edge). +/// - `js_string_addref_if_heap_string` is tag-checked and a no-op for every +/// non-`STRING_TAG` value, SSO short strings included. +/// - `js_gc_note_slot_layout` for a non-pointer value can only ever CLEAR mask +/// state, never set it, so skipping it is never the difference between a slot +/// being scanned and a live child being stranded. That is the identical +/// argument `class_field_store_needs_layout_note` already ships for the +/// static case, including its precondition — the caller only reaches here +/// with `requires_raw_f64 == false`, so the note's raw-f64-mask arm (the one +/// that MUST downgrade) is unreachable. Turning a static claim into a live +/// test does not weaken it. +/// +/// The store itself stays unconditional and outside the branch — only the +/// bookkeeping moves. +/// +/// Callers that already proved the value statically pass all three flags +/// `false`; then no test and no blocks are emitted at all, and lever D's +/// existing elision is unchanged. +#[allow(clippy::too_many_arguments)] +pub(crate) fn emit_jsvalue_slot_store_pointer_tested( + ctx: &mut FnCtx<'_>, + slot_ptr: &str, + value_double: &str, + layout_parent_bits: &str, + slot_index: &str, + string_addref_needed: bool, + layout_note_needed: bool, + barrier_parent_bits: &str, + slot_addr: &str, + write_barrier_needed: bool, +) -> Option { + { + let blk = ctx.block(); + // GC_STORE_AUDIT(BARRIERED): the slot write itself is unconditional; + // the barrier below is guarded only by a live test that the stored + // bits carry no heap pointer, which is the barrier's own first test. + blk.store(DOUBLE, value_double, slot_ptr); + } + if !string_addref_needed && !layout_note_needed && !write_barrier_needed { + return None; + } + let value_bits = ctx.block().bitcast_double_to_i64(value_double); + let bookkeeping_idx = ctx.new_block("class_field_set.gc_bookkeeping"); + let done_idx = ctx.new_block("class_field_set.gc_bookkeeping.done"); + let bookkeeping_label = ctx.block_label(bookkeeping_idx); + let done_label = ctx.block_label(done_idx); + { + let blk = ctx.block(); + let may_carry_pointer = emit_may_carry_heap_pointer_check(blk, &value_bits); + blk.cond_br(&may_carry_pointer, &bookkeeping_label, &done_label); + } + ctx.current_block = bookkeeping_idx; + { + let blk = ctx.block(); + if string_addref_needed { + blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]); + } + if layout_note_needed { + emit_layout_note_slot_on_block(blk, layout_parent_bits, slot_index, &value_bits); + } + if write_barrier_needed { + emit_write_barrier_slot_on_block(blk, barrier_parent_bits, slot_addr, &value_bits); + } + blk.br(&done_label); + } + ctx.current_block = done_idx; + Some(value_bits) +} + #[allow(clippy::too_many_arguments)] fn emit_jsvalue_slot_store_on_block_inner( blk: &mut LlBlock, diff --git a/crates/perry-codegen/src/nanbox.rs b/crates/perry-codegen/src/nanbox.rs index e97f04d14f..e6b6d7bf4a 100644 --- a/crates/perry-codegen/src/nanbox.rs +++ b/crates/perry-codegen/src/nanbox.rs @@ -57,6 +57,13 @@ pub const BIGINT_TAG_I64: &str = "9221683186994511872"; /// Asserted against the u64 tags in `tag_strings_match_u64_values`. pub const STRING_TAG_TOP16_I64: &str = "32767"; pub const SHORT_STRING_TAG_TOP16_I64: &str = "32761"; +/// The other two `lshr 48` comparands that name a HEAP-POINTER-bearing tag. +/// Together with [`STRING_TAG_TOP16_I64`] these are exactly the three tags +/// `perry-runtime::gc::barrier::decode_heap_addr` resolves to a heap address — +/// the set the #7511 inline pointer-bearing test is built from. Asserted +/// against the u64 tags in `tag_strings_match_u64_values`. +pub const POINTER_TAG_TOP16_I64: &str = "32765"; +pub const BIGINT_TAG_TOP16_I64: &str = "32762"; /// Format a `u64` as a signed LLVM i64 literal (LLVM IR integer literals are signed). pub fn i64_literal(v: u64) -> String { @@ -117,6 +124,47 @@ mod tests { i64_literal(SHORT_STRING_TAG >> 48), SHORT_STRING_TAG_TOP16_I64 ); + assert_eq!(i64_literal(POINTER_TAG >> 48), POINTER_TAG_TOP16_I64); + assert_eq!(i64_literal(BIGINT_TAG >> 48), BIGINT_TAG_TOP16_I64); + } + + /// #7511 — the inline pointer-bearing test emitted at class-field stores + /// must be a **superset** of every tag the runtime resolves to a heap + /// address. This enumerates the whole 16-bit tag space and asserts the + /// codegen predicate never says "no pointer" where the runtime's + /// `decode_heap_addr` / `layout_pointer_bearing_bits` would say "pointer". + /// + /// Written against the tag values rather than against the emitted IR on + /// purpose: the IR is a rendering of this set, and it is the SET that has + /// to be right. If a future tag joins the heap-pointer family + /// (`perry-runtime/src/value.rs`), this test still passes while the + /// generated code silently drops its barrier — so the mirror assertion + /// lives in the runtime too (`gc::barrier::tests`). + #[test] + fn inline_pointer_bearing_top16_set_covers_every_heap_tag() { + let comparands: Vec = vec![ + POINTER_TAG_TOP16_I64.parse().unwrap(), + STRING_TAG_TOP16_I64.parse().unwrap(), + BIGINT_TAG_TOP16_I64.parse().unwrap(), + ]; + for tag in [POINTER_TAG, STRING_TAG, BIGINT_TAG] { + assert!( + comparands.contains(&(tag >> 48)), + "heap tag {tag:#x} is missing from the inline pointer-bearing comparand set" + ); + } + // Non-heap tags must NOT be in the set, or the test would be vacuous. + for tag in [ + TAG_UNDEFINED & TAG_MASK, + INT32_TAG, + SHORT_STRING_TAG, + STATIC_DISPATCH_TAG, + ] { + assert!( + !comparands.contains(&(tag >> 48)), + "non-heap tag {tag:#x} must not force a barrier call" + ); + } } #[test] diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs new file mode 100644 index 0000000000..c4b2a2b5c3 --- /dev/null +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -0,0 +1,348 @@ +//! #7511 — the class-field store's GC bookkeeping sits behind ONE inline, +//! live test of the stored value's bits. +//! +//! The subject is `expr::write_barrier::emit_jsvalue_slot_store_pointer_tested`. +//! What these tests have to pin is not "the calls got faster" but the two +//! structural properties that make the guard sound: +//! +//! 1. the SLOT STORE is unconditional and stays outside the guarded block, and +//! 2. all three bookkeeping calls — write barrier, layout note, string addref — +//! are inside it, so none of them can be skipped by a *different* condition +//! than the one that proves them dead. +//! +//! Both are asserted on emitted IR rather than by calling the emitter directly, +//! because the failure mode this ticket is one wrong branch away from is a +//! stranded child in generated code. + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +fn empty_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + nextjs_path_init_modules: Vec::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn assert_default_barrier_env_not_disabled() { + assert!( + !matches!( + std::env::var("PERRY_WRITE_BARRIERS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ), + "these tests describe DEFAULT barrier emission; PERRY_WRITE_BARRIERS must be unset or on" + ); +} + +fn field(name: &str, ty: Type) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +/// `constructor(v) { this.v = v }` — the shape HIR synthesizes for every +/// closed-shape object literal (`lower/context.rs::mint_anon_shape_class`) and +/// the shape a hand-written data class already has. +fn param_prologue_ctor(field_name: &str, param_id: u32, param_ty: Type) -> Function { + Function { + id: 90, + name: "constructor".to_string(), + type_params: Vec::new(), + params: vec![param(param_id, field_name, param_ty)], + return_type: Type::Void, + body: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: field_name.to_string(), + value: Box::new(Expr::LocalGet(param_id)), + })], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class(id: u32, name: &str, fields: Vec, constructor: Option) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + } +} + +fn module_with_new(class: Class, args: Vec) -> Module { + let class_name = class.name.clone(); + Module { + name: "class_field_store_pointer_test.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: vec![class], + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Named(class_name.clone()), + body: vec![Stmt::Return(Some(Expr::New { + class_name, + args, + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn compile_ir(module: &Module) -> String { + String::from_utf8(compile_module(module, empty_opts()).unwrap()).expect("IR should be UTF-8") +} + +/// The body of the guarded block: every line after the +/// `class_field_set.gc_bookkeeping.:` LABEL up to (and excluding) its +/// terminator. `None` when no guard was emitted. +/// +/// Matched on the label DEFINITION, never on the first textual occurrence of +/// the name — that is the `br i1 …, label %class_field_set.gc_bookkeeping.N, +/// label %class_field_set.gc_bookkeeping.done.M` a line above, and starting +/// there yields an empty slice that passes nothing and fails everything. +fn gc_bookkeeping_block(ir: &str) -> Option { + let mut lines = ir.lines().skip_while(|line| { + let label = line.trim_end_matches(':'); + !(line.ends_with(':') + && label.starts_with("class_field_set.gc_bookkeeping.") + && !label.starts_with("class_field_set.gc_bookkeeping.done")) + }); + lines.next()?; + Some( + lines + .take_while(|line| !line.trim_start().starts_with("br ")) + .collect::>() + .join("\n"), + ) +} + +/// An `any`-typed field is the case the whole ticket is about: it takes the +/// boxed store path, so the three bookkeeping calls exist, and the value is a +/// constructor PARAMETER, so no by-construction proof can retire them. +#[test] +fn opaque_param_store_guards_all_three_bookkeeping_calls() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 1, + "Boxed", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + + // The guard exists, and it is the top-16-bit test — not something that + // happens to look like one. `32765`/`32767`/`32762` are POINTER/STRING/ + // BIGINT `>> 48`; `4096` is the bare-address floor. + assert!( + ir.contains("lshr i64") && ir.contains("class_field_set.gc_bookkeeping"), + "expected an inline pointer-bearing guard around the field-store bookkeeping:\n{ir}" + ); + for comparand in ["32765", "32767", "32762", "4096"] { + assert!( + ir.contains(comparand), + "the inline guard is missing the {comparand} comparand:\n{ir}" + ); + } + + let guarded = gc_bookkeeping_block(&ir).expect("a gc_bookkeeping block"); + for call in [ + "@js_write_barrier_slot", + "@js_gc_note_slot_layout", + "@js_string_addref_if_heap_string", + ] { + assert!( + guarded.contains(call), + "{call} must live inside the guarded block, not beside it:\n{guarded}" + ); + } + // …and the slot store must NOT, or a value would be dropped whenever the + // guard says "no pointer". + assert!( + !guarded.contains("store double"), + "the slot store must stay unconditional, outside the guard:\n{guarded}" + ); +} + +/// **The boundary.** The guard replaces a *runtime* early-out, so it must never +/// remove a call outright: a module that can store a pointer still has all +/// three calls present in the emitted IR, reachable on the guard's taken edge. +/// This is what distinguishes the change from an elision. +#[test] +fn every_bookkeeping_call_is_still_emitted_not_removed() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 2, + "Boxed2", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + for call in [ + "call void @js_write_barrier_slot", + "call void @js_gc_note_slot_layout", + "call void @js_string_addref_if_heap_string", + ] { + assert!(ir.contains(call), "{call} must still be emitted:\n{ir}"); + } + assert!( + ir.contains("call void @js_gc_write_barriers_emitted(i32 1)"), + "the module must still declare to the runtime that generated barriers exist" + ); +} + +/// A `number`-declared field takes the raw-f64 store path, which is proven +/// pointer-free by the typed shape descriptor and never had bookkeeping to +/// guard. Asserting the guard is ABSENT there keeps the test above from being +/// satisfied by "codegen emits this block everywhere". +#[test] +fn raw_f64_field_store_emits_no_guard() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 3, + "Numeric", + vec![field("v", Type::Number)], + Some(param_prologue_ctor("v", 7, Type::Number)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + assert!( + !ir.contains("class_field_set.gc_bookkeeping"), + "a raw-f64 class-field store has no bookkeeping to guard:\n{ir}" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs new file mode 100644 index 0000000000..af99ef1200 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs @@ -0,0 +1,268 @@ +//! #7511 — the contract the codegen-side inline pointer-bearing test rests on. +//! +//! `perry-codegen`'s `expr::write_barrier::emit_may_carry_heap_pointer_check` +//! puts the three per-class-field-store GC bookkeeping calls +//! (`js_write_barrier_slot`, `js_gc_note_slot_layout`, +//! `js_string_addref_if_heap_string`) behind ONE inline test of the stored +//! bits' TOP 16 BITS: +//! +//! ```text +//! may_carry_heap_pointer(bits) := +//! (bits >> 48) ∈ { 0x7FFA, 0x7FFD, 0x7FFF } // NaN-boxed heap tags +//! || ((bits >> 48) == 0 && bits >= 0x1000) // bare heap address +//! ``` +//! +//! That is sound only if it is a **superset** of what the runtime itself would +//! resolve to a heap pointer. The codegen crate cannot call +//! `decode_heap_addr` / `layout_pointer_bearing_bits` (they are private to this +//! crate, and codegen runs in a different process from the program it emits), +//! so the two halves are pinned from opposite sides: +//! +//! - `perry-codegen::nanbox::inline_pointer_bearing_top16_set_covers_every_heap_tag` +//! pins the comparand set against the NaN-box tag constants. +//! - **this file** pins the runtime predicates against the same set, by +//! enumerating the entire 16-bit tag space. +//! +//! A future tag that starts resolving to a heap address — the only way this +//! elision can turn into a stranded child — makes THIS test fail, on the side +//! that knows about it. + +use super::super::barrier::decode_heap_addr; +use super::super::layout::layout_pointer_bearing_bits; +use super::super::*; +use super::support::*; + +/// The exact tag comparands emitted by `emit_may_carry_heap_pointer_check`. +/// Kept as literals so a drift in codegen has to be mirrored here by hand +/// rather than silently inherited. +const CODEGEN_HEAP_TAG_TOP16: [u64; 3] = [0x7FFA, 0x7FFD, 0x7FFF]; +/// The bare-address floor codegen emits — `layout_pointer_bearing_bits`' own +/// `0x1000`, which is the LOWER of the two runtime floors (`decode_heap_addr` +/// uses `0x10000`). Using the lower one is what makes the codegen test a +/// superset of both. +const CODEGEN_BARE_ADDR_FLOOR: u64 = 0x1000; + +fn codegen_may_carry_heap_pointer(bits: u64) -> bool { + let top16 = bits >> 48; + CODEGEN_HEAP_TAG_TOP16.contains(&top16) || (top16 == 0 && bits >= CODEGEN_BARE_ADDR_FLOOR) +} + +/// Payload patterns exercised under every tag: a plausible heap address, an +/// 8-aligned address inside the handle band, a misaligned one, zero, and an +/// all-ones payload. +const PAYLOADS: [u64; 6] = [ + 0x0000_0000_0000_0000, + 0x0000_0000_0000_0008, + 0x0000_0001_0000_0000, + 0x0000_0001_0000_0007, + 0x0000_1234_5678_9AB0, + 0x0000_FFFF_FFFF_FFFF, +]; + +/// **The load-bearing direction.** For every one of the 65,536 possible tags +/// and a spread of payloads, a value the runtime would resolve to a heap +/// pointer must also pass the codegen test. A violation here is a barrier that +/// codegen skips and the collector needed — a stranded child. +#[test] +fn codegen_top16_test_is_a_superset_of_decode_heap_addr() { + for tag in 0u64..=0xFFFF { + for payload in PAYLOADS { + let bits = (tag << 48) | payload; + if decode_heap_addr(bits) != 0 { + assert!( + codegen_may_carry_heap_pointer(bits), + "decode_heap_addr resolved {bits:#018x} (tag {tag:#06x}) to a heap address, \ + but the codegen inline test would have skipped the write barrier" + ); + } + } + } +} + +/// Same obligation for the layout note: `js_gc_note_slot_layout` is only +/// skippable when the value is not pointer-bearing in the layout machinery's +/// own sense. +#[test] +fn codegen_top16_test_is_a_superset_of_layout_pointer_bearing_bits() { + for tag in 0u64..=0xFFFF { + for payload in PAYLOADS { + let bits = (tag << 48) | payload; + if layout_pointer_bearing_bits(bits) { + assert!( + codegen_may_carry_heap_pointer(bits), + "layout_pointer_bearing_bits accepted {bits:#018x} (tag {tag:#06x}), but the \ + codegen inline test would have skipped the layout note" + ); + } + } + } +} + +/// **Sabotage check — proves the two tests above can fail.** +/// +/// Drop `POINTER_TAG` from the comparand set (the single most likely way to +/// get this wrong) and assert that a real NaN-boxed object pointer is then +/// classified as "no bookkeeping needed" while the runtime still resolves it +/// to a heap address. Without this, a green run could mean "the enumeration +/// found nothing" *or* "the enumeration never looked at a pointer". +#[test] +fn dropping_pointer_tag_from_the_set_would_strand_a_child() { + const SABOTAGED: [u64; 2] = [0x7FFA, 0x7FFF]; + let object_bits = crate::value::POINTER_TAG | 0x0000_1234_5678_9AB0; + assert_ne!( + decode_heap_addr(object_bits), + 0, + "the witness must be a value the runtime really does resolve to a heap address" + ); + assert!( + codegen_may_carry_heap_pointer(object_bits), + "the shipped comparand set must keep the barrier for an object pointer" + ); + assert!( + !SABOTAGED.contains(&(object_bits >> 48)), + "a comparand set missing POINTER_TAG must classify an object pointer as barrier-free — \ + if this assertion fails the superset tests above cannot detect a dropped tag either" + ); +} + +/// The complement, so the elision is not vacuous: the value classes that +/// dominate a numeric store loop must be classified barrier-free, and the +/// runtime must agree that skipping their bookkeeping changes nothing. +#[test] +fn plain_numbers_and_primitives_need_no_bookkeeping() { + let barrier_free = [ + 1234.5f64.to_bits(), + (-1234.5f64).to_bits(), + 0f64.to_bits(), + f64::NAN.to_bits(), + f64::INFINITY.to_bits(), + crate::value::TAG_UNDEFINED, + crate::value::TAG_NULL, + crate::value::TAG_TRUE, + crate::value::TAG_FALSE, + crate::value::INT32_TAG | 42, + ]; + for bits in barrier_free { + assert!( + !codegen_may_carry_heap_pointer(bits), + "{bits:#018x} should not force the bookkeeping call" + ); + assert_eq!( + decode_heap_addr(bits), + 0, + "{bits:#018x} must carry no heap address" + ); + assert!( + !layout_pointer_bearing_bits(bits), + "{bits:#018x} must not be layout-pointer-bearing" + ); + } +} + +/// Perform a field store the way the guarded codegen sequence does: the slot +/// write is unconditional, and the barrier runs only when `guard` accepts the +/// stored bits. Returns whether the barrier was called. +unsafe fn guarded_field_store( + old_obj: *mut crate::object::ObjectHeader, + fields: *mut u64, + child_bits: u64, + guard: fn(u64) -> bool, +) -> bool { + *fields = child_bits; + if guard(child_bits) { + js_write_barrier_slot(ptr_bits(old_obj as usize), fields as u64, child_bits); + return true; + } + false +} + +/// **The stranding witness.** An OLD parent, a YOUNG child, and the exact store +/// sequence codegen now emits — assert three things in one place: +/// +/// 1. with the SHIPPED guard, a heap-pointer child takes the bookkeeping branch, +/// the old→young edge verifier is satisfied, and a remembered-set scan marks +/// the child (it survives the minor); +/// 2. with a SABOTAGED guard (one that always answers "no pointer" — the shape +/// a wrong comparand set or an inverted branch produces), the same store +/// leaves the edge unrecorded and the verifier REJECTS it: that child is +/// stranded and the next minor frees it under a live reference; +/// 3. a NUMERIC child through the shipped guard skips the barrier and the +/// verifier is still satisfied — which is exactly the case the elision +/// exists for, and proves (2)'s failure is about the pointer, not about +/// skipping a barrier per se. +#[test] +fn sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps() { + let _guard = GcTestIsolationGuard::new(); + + // (1) shipped guard, pointer child — barrier runs, edge covered, marked. + reset_remembered_set(); + clear_marks(); + let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let (old_obj, fields) = unsafe { alloc_old_test_object(1) }; + let old_header = unsafe { header_from_user_ptr(old_obj as *const u8) }; + unsafe { (*old_header).gc_flags |= GC_FLAG_MARKED }; + let child_bits = ptr_bits(young); + assert!( + unsafe { guarded_field_store(old_obj, fields, child_bits, codegen_may_carry_heap_pointer) }, + "the shipped guard must take the bookkeeping branch for a heap-pointer child" + ); + let stats = verify_old_to_young_edges_covered(); + assert_eq!(stats.checked_old_to_young_edges, 1); + assert_eq!(stats.missing_edges, 0); + let valid_ptrs = build_valid_pointer_set(); + let scan = mark_remembered_set_roots(&valid_ptrs); + assert_eq!(scan.newly_marked, 1, "the child must survive the minor"); + unsafe { (*old_header).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); + + // (2) SABOTAGE: a guard that never accepts. Same store, same child. + fn never_needs_bookkeeping(_bits: u64) -> bool { + false + } + reset_remembered_set(); + clear_marks(); + let young2 = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let (old_obj2, fields2) = unsafe { alloc_old_test_object(1) }; + let old_header2 = unsafe { header_from_user_ptr(old_obj2 as *const u8) }; + unsafe { (*old_header2).gc_flags |= GC_FLAG_MARKED }; + assert!( + !unsafe { guarded_field_store(old_obj2, fields2, ptr_bits(young2), never_needs_bookkeeping) }, + "the sabotaged guard must skip the barrier — otherwise this arm proves nothing" + ); + let rejected = std::panic::catch_unwind(verify_old_to_young_edges_covered); + assert!( + rejected.is_err(), + "a skipped barrier on a heap-pointer child must leave the old->young edge \ + unrecorded — this is the stranded child the shipped guard must never produce" + ); + unsafe { (*old_header2).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); + + // (3) shipped guard, NUMERIC child — barrier skipped, nothing stranded. + reset_remembered_set(); + clear_marks(); + let (old_obj3, fields3) = unsafe { alloc_old_test_object(1) }; + let old_header3 = unsafe { header_from_user_ptr(old_obj3 as *const u8) }; + unsafe { (*old_header3).gc_flags |= GC_FLAG_MARKED }; + assert!( + !unsafe { + guarded_field_store( + old_obj3, + fields3, + 1234.5f64.to_bits(), + codegen_may_carry_heap_pointer, + ) + }, + "a plain double must skip the bookkeeping branch" + ); + let numeric_stats = verify_old_to_young_edges_covered(); + assert_eq!( + numeric_stats.missing_edges, 0, + "a numeric store publishes no old->young edge, so skipping its barrier strands nothing" + ); + unsafe { (*old_header3).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index dbc1502221..925260be2f 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -19,6 +19,7 @@ mod global_bootstrap; mod helper_stores; mod host_safepoints; mod incremental_sweep_reclaim; +mod inline_pointer_bearing_contract; mod layout_trace; mod oldgen; mod os_tag; From db2667a2e94b2c3d58c4346931b411b441ec6611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 17:44:22 +0200 Subject: [PATCH 2/6] docs(changelog): #7511 class-field store bookkeeping guard (PR #7536) --- changelog.d/7536-barrier-elision.md | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 changelog.d/7536-barrier-elision.md diff --git a/changelog.d/7536-barrier-elision.md b/changelog.d/7536-barrier-elision.md new file mode 100644 index 0000000000..d3a34b5afc --- /dev/null +++ b/changelog.d/7536-barrier-elision.md @@ -0,0 +1,69 @@ +### Codegen: class-field store GC bookkeeping decided by one inline test instead of three calls (#7511) + +Write barriers were 16.1% of `churn_alloc`'s symbolicated profile on a program +whose stores are all doubles, and #5334 lever D — which elides the barrier for a +value that is a non-pointer *by construction* — never fired there. + +**Why lever D cannot fire, which is not what the ticket assumed.** The ticket's +premise ("the types say `v: number`") is wrong in that form: Perry does not +validate declared types at runtime, so a field declared `number` legitimately +receives a string through an `any`, and the annotation is never a layout fact. +The real reason is structural. Since the `[#bloat]` `force_ctor_call` default +(`lower_call/new.rs`), a class with its own constructor is **not inlined** at the +`new` site — its body is compiled once as the shared +`_constructor(this, p0, …)` symbol and called. HIR rewrites every +closed-shape object literal into a `New` of a synthesized anon-shape class with +exactly that constructor shape (`lower/context.rs::mint_anon_shape_class`), so +`{v: base + j, w: j}` lands there too. The expression reaching the field store is +therefore `Expr::LocalGet()` — an LLVM function argument of a +function shared by every `new` site in the module, including ones that pass +pointers. No by-construction proof about that value can exist at that site. + +What can be decided there is the same question the three bookkeeping callees each +ask first, one at a time, across three cross-crate calls: `js_write_barrier_slot` +(`barrier_child_prologue` returns immediately when `decode_heap_addr(child) == 0`), +`js_string_addref_if_heap_string` (tag-checked no-op off `STRING_TAG`), and +`js_gc_note_slot_layout` (for a non-pointer value the note can only ever *clear* +mask state, never set it — the identical argument +`class_field_store_needs_layout_note` already ships, including its +`requires_raw_f64 == false` precondition). Ask it once, inline, and branch over +all three: + +```text +may_carry_heap_pointer(bits) := + (bits >> 48) ∈ { 0x7FFA, 0x7FFD, 0x7FFF } // BIGINT / POINTER / STRING + || ((bits >> 48) == 0 && bits >= 0x1000) // bare heap address +``` + +The slot store stays unconditional and outside the branch. Callers that already +proved the value statically pass all three flags `false`, so lever D's existing +elision emits no test and no blocks at all — this composes with it rather than +replacing it. The bare-address floor is the *lower* of the two runtime floors +(`layout_pointer_bearing_bits`' `0x1000` rather than `decode_heap_addr`'s +`0x10000`), which is also what keeps `0.0` — whose bit pattern is all zeros — +off the slow path. + +Soundness is the superset property, pinned from both sides because codegen cannot +call the runtime predicates. `perry-runtime`'s new +`gc::tests::inline_pointer_bearing_contract` enumerates all 65,536 tags against +`decode_heap_addr` *and* `layout_pointer_bearing_bits`, and carries a stranding +witness: an old parent, a young child, and the exact guarded store sequence, where +a sabotaged always-false guard leaves the old→young edge unrecorded and +`verify_old_to_young_edges_covered` rejects it, while the shipped guard keeps it +and the remembered-set scan marks the child. Deleting `0x7FFD` from the comparand +set turns three of the five tests red. Codegen-side structure (store outside the +guard, all three calls inside it, every call still emitted, no guard on the +raw-f64 path) is pinned in `tests/class_field_store_pointer_test.rs`. + +Measured on `gc-handoff/bench/churn_alloc.ts` via `PERRY_GC_TRACE` counters: +barrier calls 79,780,888 → 39,890,444 with `non_pointer_child_skips` +39,890,444 → 0 — exactly the wasted calls, with every remaining call doing real +work and the GC cycle count unchanged. The whole bench set runs `rc=0` with +identical stdout under `PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1` +with copying minors confirmed live. No wall-clock claim: no quiet host was +available. + +The `put.pic.hit` store path (`expr/proxy_reflect.rs`), which is what a +user-written `this.f = v` on a union-typed field lowers to, emits the same triple +and is untouched — its layout note is the scalar-aware variant and its existing +static skip rests on a separate precondition, so it is left for a follow-up. From ea34ec7c24d7f63990c8781121a15b45e7c01ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 17:49:11 +0200 Subject: [PATCH 3/6] test(codegen): pin block termination on the guarded store, drop an unreached shape-proven claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-receiver module does not reach `property_set.rs`'s `ptr_shape_receiver_fact` arm (verified on the emitted IR — it still lowers through the typed-feedback guarded arm), so the test no longer claims to. What it does pin is the property that arm would break: every emitted block is terminated. `emit_jsvalue_slot_store_pointer_tested` leaves `ctx.current_block` on a fresh merge block, and the shape-proven arm returns without a terminator — a dangling merge block is invisible to a call-count assertion. --- .../tests/class_field_store_pointer_test.rs | 94 +++++++++++++++++++ .../tests/inline_pointer_bearing_contract.rs | 4 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index c4b2a2b5c3..cf6ed09d5b 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -324,6 +324,100 @@ fn every_bookkeeping_call_is_still_emitted_not_removed() { ); } +/// A module whose `probe` binds `let o = new C(v)` and then writes a field on +/// the LOCAL receiver rather than on `this`, so the store is lowered from a +/// different `property_set.rs` entry than the constructor prologue's. +fn module_with_local_receiver_store(class: Class, stored: Expr) -> Module { + let class_name = class.name.clone(); + let mut module = module_with_new(class, vec![Expr::Number(1.0)]); + module.functions[0].body = vec![ + Stmt::Let { + id: 5, + name: "o".to_string(), + ty: Type::Named(class_name.clone()), + mutable: false, + init: Some(Expr::New { + class_name, + args: vec![Expr::Number(1.0)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(5)), + property: "v".to_string(), + value: Box::new(stored), + }), + Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(5)), + property: "v".to_string(), + byte_offset: 0, + })), + ]; + module.functions[0].return_type = Type::Any; + module +} + +/// **Every emitted block is still terminated.** +/// +/// `emit_jsvalue_slot_store_pointer_tested` leaves `ctx.current_block` on a +/// freshly created merge block, and `property_set.rs` has arms that terminate +/// explicitly (the typed-feedback guarded arm's `br` to `class_field_set.merge`) +/// and arms that fall through to whatever the caller emits next (the +/// `ptr_shape_receiver_fact` arm returns without a terminator, exactly as it did +/// before this change). A dangling merge block is invisible to a call-count +/// assertion and is how the second shape would break, so scan the whole module: +/// no label may follow another label with no terminator in between. +#[test] +fn guarded_store_leaves_every_block_terminated() { + assert_default_barrier_env_not_disabled(); + let module = module_with_local_receiver_store( + class( + 4, + "LocalBoxed", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + // An opaque local read: no by-construction proof, so the flags survive + // to the guard instead of being retired by lever D. + Expr::LocalGet(5), + ); + let ir = compile_ir(&module); + let guarded = gc_bookkeeping_block(&ir) + .unwrap_or_else(|| panic!("expected a guarded bookkeeping block in:\n{ir}")); + for call in [ + "@js_write_barrier_slot", + "@js_gc_note_slot_layout", + "@js_string_addref_if_heap_string", + ] { + assert!( + guarded.contains(call), + "{call} must live inside the guarded block:\n{guarded}" + ); + } + + let mut open_block: Option<&str> = None; + for line in ir.lines() { + let trimmed = line.trim(); + if line.ends_with(':') && !line.starts_with(' ') && !line.starts_with('%') { + assert!( + open_block.is_none(), + "block {:?} was left unterminated before {line:?}", + open_block.unwrap() + ); + open_block = Some(line); + } else if trimmed.starts_with("br ") + || trimmed.starts_with("ret ") + || trimmed.starts_with("unreachable") + || trimmed.starts_with("switch ") + || trimmed == "}" + { + open_block = None; + } + } +} + /// A `number`-declared field takes the raw-f64 store path, which is proven /// pointer-free by the typed shape descriptor and never had bookkeeping to /// guard. Asserting the guard is ABSENT there keeps the test above from being diff --git a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs index af99ef1200..31f1567245 100644 --- a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs +++ b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs @@ -227,7 +227,9 @@ fn sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps() { let old_header2 = unsafe { header_from_user_ptr(old_obj2 as *const u8) }; unsafe { (*old_header2).gc_flags |= GC_FLAG_MARKED }; assert!( - !unsafe { guarded_field_store(old_obj2, fields2, ptr_bits(young2), never_needs_bookkeeping) }, + !unsafe { + guarded_field_store(old_obj2, fields2, ptr_bits(young2), never_needs_bookkeeping) + }, "the sabotaged guard must skip the barrier — otherwise this arm proves nothing" ); let rejected = std::panic::catch_unwind(verify_old_to_young_edges_covered); From 8673190b6e6baf1622580df81eec4cd1bde69aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 19:08:04 +0200 Subject: [PATCH 4/6] =?UTF-8?q?review(7536):=20address=20CodeRabbit=20?= =?UTF-8?q?=E2=80=94=20root=20ordering,=20floor-band=20payload,=20dead=20I?= =?UTF-8?q?R=20under=20barriers-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The stranding witness allocated the YOUNG child before the OLD parent, so the parent's allocation could collect under a bare Rust `usize` that production mode neither roots nor pins. Allocate old-gen first in all three phases; no allocation now follows the young child. - PAYLOADS gained 0x2000, the band where the two runtime floors disagree (`layout_pointer_bearing_bits` 0x1000 vs `decode_heap_addr` 0x10000). Without it the enumeration passed for either floor and proved nothing about picking the lower one; verified by hand that raising CODEGEN_BARE_ADDR_FLOOR to 0x10000 now turns the layout superset test red. - `dropping_pointer_tag_from_the_set_would_strand_a_child`'s third assertion compared literals and could not fail. Renamed to `an_object_pointer_keeps_its_bookkeeping` and reduced to the two real assertions; the mutational coverage lives in the runtime stranding witness. - `emit_jsvalue_slot_store_pointer_tested` now folds `write_barriers_enabled()` into its emission gate, so `PERRY_WRITE_BARRIERS=0` no longer gets a guard diamond whose taken arm holds only a `br`. That knob exists to A/B barrier cost; dead IR in one arm is how such a comparison lies. - The block-termination scan no longer accepts `}` as a terminator (the last block of a function is exactly where a fall-through arm dangles) and asserts on the residue after the loop. --- .../perry-codegen/src/expr/write_barrier.rs | 11 +++- .../tests/class_field_store_pointer_test.rs | 9 ++- .../tests/inline_pointer_bearing_contract.rs | 55 ++++++++++++------- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index 5941fb05f6..6be29f54c8 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -380,7 +380,14 @@ pub(crate) fn emit_jsvalue_slot_store_pointer_tested( // bits carry no heap pointer, which is the barrier's own first test. blk.store(DOUBLE, value_double, slot_ptr); } - if !string_addref_needed && !layout_note_needed && !write_barrier_needed { + // `emit_write_barrier_slot_on_block` emits nothing when barrier emission is + // compile-time disabled, so counting `write_barrier_needed` alone would put + // a predicate, a `cond_br` and two blocks around an arm holding only a + // `br` under `PERRY_WRITE_BARRIERS=0`. That knob exists to A/B the barrier's + // cost; leaving dead IR in one arm of the A/B is exactly the kind of thing + // that makes such a comparison lie. + let write_barrier_emitted = write_barrier_needed && crate::codegen::write_barriers_enabled(); + if !string_addref_needed && !layout_note_needed && !write_barrier_emitted { return None; } let value_bits = ctx.block().bitcast_double_to_i64(value_double); @@ -402,7 +409,7 @@ pub(crate) fn emit_jsvalue_slot_store_pointer_tested( if layout_note_needed { emit_layout_note_slot_on_block(blk, layout_parent_bits, slot_index, &value_bits); } - if write_barrier_needed { + if write_barrier_emitted { emit_write_barrier_slot_on_block(blk, barrier_parent_bits, slot_addr, &value_bits); } blk.br(&done_label); diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index cf6ed09d5b..4681f0a148 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -411,11 +411,18 @@ fn guarded_store_leaves_every_block_terminated() { || trimmed.starts_with("ret ") || trimmed.starts_with("unreachable") || trimmed.starts_with("switch ") - || trimmed == "}" { open_block = None; } } + // A closing `}` is deliberately NOT treated as a terminator: the last block + // of a function is exactly where a fall-through arm leaves a dangling + // merge block, and accepting `}` would let that pass. + assert!( + open_block.is_none(), + "block {:?} was left unterminated at the end of the module", + open_block.unwrap() + ); } /// A `number`-declared field takes the raw-f64 store path, which is proven diff --git a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs index 31f1567245..2b2042ef0c 100644 --- a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs +++ b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs @@ -47,12 +47,20 @@ fn codegen_may_carry_heap_pointer(bits: u64) -> bool { CODEGEN_HEAP_TAG_TOP16.contains(&top16) || (top16 == 0 && bits >= CODEGEN_BARE_ADDR_FLOOR) } -/// Payload patterns exercised under every tag: a plausible heap address, an -/// 8-aligned address inside the handle band, a misaligned one, zero, and an -/// all-ones payload. -const PAYLOADS: [u64; 6] = [ +/// Payload patterns exercised under every tag: zero, an 8-aligned address below +/// every floor, one **between the two runtime floors** (`0x1000 ≤ p < 0x10000`), +/// an 8-aligned address above both, a misaligned one, a plausible heap address, +/// and an all-ones payload. +/// +/// The `0x2000` entry is the one that earns `CODEGEN_BARE_ADDR_FLOOR`'s value: +/// it is the band where `layout_pointer_bearing_bits` (floor `0x1000`) and +/// `decode_heap_addr` (floor `0x10000`) disagree, so without it the enumeration +/// would pass for either choice of floor and prove nothing about picking the +/// lower one. +const PAYLOADS: [u64; 7] = [ 0x0000_0000_0000_0000, 0x0000_0000_0000_0008, + 0x0000_0000_0000_2000, 0x0000_0001_0000_0000, 0x0000_0001_0000_0007, 0x0000_1234_5678_9AB0, @@ -98,16 +106,20 @@ fn codegen_top16_test_is_a_superset_of_layout_pointer_bearing_bits() { } } -/// **Sabotage check — proves the two tests above can fail.** +/// **Non-vacuity check for the two enumerations above.** +/// +/// A green enumeration could mean "nothing violated the superset property" or +/// "the enumeration never looked at a real pointer". This pins the second +/// reading shut: an ordinary NaN-boxed object pointer is a value the runtime +/// really does resolve to a heap address, and the shipped comparand set really +/// does keep its bookkeeping. /// -/// Drop `POINTER_TAG` from the comparand set (the single most likely way to -/// get this wrong) and assert that a real NaN-boxed object pointer is then -/// classified as "no bookkeeping needed" while the runtime still resolves it -/// to a heap address. Without this, a green run could mean "the enumeration -/// found nothing" *or* "the enumeration never looked at a pointer". +/// The MUTATIONAL half — what actually happens when the set is wrong — is +/// `sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps` below, which +/// runs a sabotaged guard through the real barrier and remembered-set +/// machinery rather than comparing literals. #[test] -fn dropping_pointer_tag_from_the_set_would_strand_a_child() { - const SABOTAGED: [u64; 2] = [0x7FFA, 0x7FFF]; +fn an_object_pointer_keeps_its_bookkeeping() { let object_bits = crate::value::POINTER_TAG | 0x0000_1234_5678_9AB0; assert_ne!( decode_heap_addr(object_bits), @@ -115,13 +127,12 @@ fn dropping_pointer_tag_from_the_set_would_strand_a_child() { "the witness must be a value the runtime really does resolve to a heap address" ); assert!( - codegen_may_carry_heap_pointer(object_bits), - "the shipped comparand set must keep the barrier for an object pointer" + layout_pointer_bearing_bits(object_bits), + "the witness must also be pointer-bearing to the layout machinery" ); assert!( - !SABOTAGED.contains(&(object_bits >> 48)), - "a comparand set missing POINTER_TAG must classify an object pointer as barrier-free — \ - if this assertion fails the superset tests above cannot detect a dropped tag either" + codegen_may_carry_heap_pointer(object_bits), + "the shipped comparand set must keep the barrier for an object pointer" ); } @@ -195,10 +206,16 @@ fn sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps() { let _guard = GcTestIsolationGuard::new(); // (1) shipped guard, pointer child — barrier runs, edge covered, marked. + // + // The OLD parent is allocated FIRST in every phase below. `young` is a bare + // Rust `usize`, which production mode neither roots nor pins (the + // conservative stack scan resolves to `SkipDisabled`), so an allocation + // after it could collect and leave it naming freed or relocated memory. + // Old-gen first means no allocation ever follows the young child. reset_remembered_set(); clear_marks(); - let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; let (old_obj, fields) = unsafe { alloc_old_test_object(1) }; + let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; let old_header = unsafe { header_from_user_ptr(old_obj as *const u8) }; unsafe { (*old_header).gc_flags |= GC_FLAG_MARKED }; let child_bits = ptr_bits(young); @@ -222,8 +239,8 @@ fn sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps() { } reset_remembered_set(); clear_marks(); - let young2 = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; let (old_obj2, fields2) = unsafe { alloc_old_test_object(1) }; + let young2 = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; let old_header2 = unsafe { header_from_user_ptr(old_obj2 as *const u8) }; unsafe { (*old_header2).gc_flags |= GC_FLAG_MARKED }; assert!( From 61cf8f7a0ed180d1fbc334a0842a120d8907e70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 19:19:58 +0200 Subject: [PATCH 5/6] docs(changelog): record the quiet-host A/B for #7511 (churn_alloc 1.18x) --- changelog.d/7536-barrier-elision.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog.d/7536-barrier-elision.md b/changelog.d/7536-barrier-elision.md index d3a34b5afc..3cb3bfae5a 100644 --- a/changelog.d/7536-barrier-elision.md +++ b/changelog.d/7536-barrier-elision.md @@ -58,10 +58,14 @@ raw-f64 path) is pinned in `tests/class_field_store_pointer_test.rs`. Measured on `gc-handoff/bench/churn_alloc.ts` via `PERRY_GC_TRACE` counters: barrier calls 79,780,888 → 39,890,444 with `non_pointer_child_skips` 39,890,444 → 0 — exactly the wasted calls, with every remaining call doing real -work and the GC cycle count unchanged. The whole bench set runs `rc=0` with -identical stdout under `PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1` -with copying minors confirmed live. No wall-clock claim: no quiet host was -available. +work and the GC cycle count unchanged at 105. On the pinned quiet host (M1, load +1.46, best-of-7 `user+sys`, both arms linked against the same runtime archive): +`churn_alloc` 1.930 s → 1.630 s (1.18×) and `churn` 2.210 s → 1.960 s (1.13×), +with `push_cls`, `tree`, `churn_read`, `deeplist` and `push_num` unchanged — the +prediction, since none of those uses the `class_field_set` store path. The whole +bench set produces byte-identical stdout with `rc=0` and re-runs clean under +`PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1` with copying minors +confirmed live (105 copying-minor verifications on `churn_alloc`). The `put.pic.hit` store path (`expr/proxy_reflect.rs`), which is what a user-written `this.f = v` on a union-typed field lowers to, emits the same triple From c041be4e0e4083484c0062e299de10839bf4e732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 19:30:23 +0200 Subject: [PATCH 6/6] chore: bump version to 0.5.1306 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b5a1dac6c..f9d1f72f64 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.1305 +**Current Version:** 0.5.1306 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 9e3cd8548c..cc710ced3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-ui-tvos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e0b6a27f02..45ea81b897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1305" +version = "0.5.1306" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"