From 330b3cb5a3c41221cb8db3baa98b9ad2ed88829e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:38:46 +0200 Subject: [PATCH 1/6] perf(codegen): raise root-spill default to the measured fan-out cliff (#8620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEFAULT_ROOT_SPILL_RELOCATIONS` was 4,000,000, low enough to spill moderate-fan-out functions whose native RS4GC statepoints would have optimized fine. On an ~8M-relocation entry function, spilling to the shadow frame was *slower* than the fan-out it replaced (#8620), so the default paid shadow-frame overhead for nothing. Measured the RS4GC fan-out cliff with synthetic entry functions compiled at -Os with spilling OFF (`PERRY_ROOT_SPILL_RELOCATIONS=0`), timing the `@main` codegen unit: 8.0M -> ~325 s (finished) 16.0M -> ~235 s (finished) 32.0M -> ~511 s / 8.5 min (finished) 40.0M -> did not finish in 20 min 48.0M -> did not finish in 20 min Fan-out finishes in bounded time up to 32M and does not past 40M, so the default is raised to 32,000,000 — the largest estimate whose fan-out still finished. Below it fan-out is the cheaper lowering; above it fan-out risks not finishing and the shadow frame wins. The change is compile-time only (spilled `main` is run-once init) and is backstopped by the post-RS4GC instruction-budget assertion (#8586), which fails loudly rather than hanging if a function this estimate misses still fans out. Pins the new default in a unit test. Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- crates/perry-codegen/src/codegen/helpers.rs | 68 +++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 88ccc1f869..839815aab0 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -367,14 +367,33 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 { /// bundle's 68 MB entry body measured 795 root slots × ~106k safepoints ≈ 8.4e7 /// and grew 439k → 6.5M instructions under RS4GC; without RS4GC the same unit /// optimized at `-Os` in ~5s). Real functions sit orders of magnitude below -/// this: hundreds of call sites times tens of slots is ~1e4–1e5. The default -/// is set well under the measured pathological point and well over ordinary -/// code, and the post-RS4GC instruction-budget assertion (#8583, inprocess.rs) -/// backstops any function the estimate misses. +/// this: hundreds of call sites times tens of slots is ~1e4–1e5. +/// +/// The default (#8620) is measured, not guessed. Synthetic entry functions with +/// a controlled `slots × safepoints` estimate were compiled at `-Os` with +/// spilling OFF (pure RS4GC fan-out) and the `@main` codegen unit timed: +/// +/// | estimate | fan-out finish | +/// |---------:|---------------:| +/// | 8.0M | ~325 s | +/// | 16.0M | ~235 s | +/// | 32.0M | ~511 s (8.5m) | +/// | 40.0M | did not finish in 20 min | +/// | 48.0M | did not finish in 20 min | +/// +/// The fan-out cliff sits between 32M and 40M, so the default is the largest +/// estimate whose fan-out still finished in bounded time. Below it fan-out is +/// the cheaper lowering — spilling a moderate function costs more than the +/// fan-out it avoids (an ~8M function spilled in 303 s vs 180 s fanned out, +/// #8620) — and above it fan-out risks not finishing and the shadow frame wins. +/// The former 4M default fired on ~8M functions that fan out fine in minutes. +/// The post-RS4GC instruction-budget assertion (#8586, inprocess.rs) backstops +/// any function this estimate misses: it fails loudly rather than hanging, so +/// raising the threshold is safe. /// /// `PERRY_ROOT_SPILL_RELOCATIONS=` overrides it; `0` disables spilling /// (every function stays on native statepoints, the pre-#8583 behavior). -const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 4_000_000; +const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 32_000_000; fn root_spill_relocation_threshold() -> usize { std::env::var("PERRY_ROOT_SPILL_RELOCATIONS") @@ -390,6 +409,45 @@ pub(crate) fn root_relocation_estimate(slot_count: usize, safepoint_sites: usize slot_count.saturating_mul(safepoint_sites) } +#[cfg(test)] +mod root_spill_default_tests { + use super::{root_relocation_estimate, DEFAULT_ROOT_SPILL_RELOCATIONS}; + + /// #8620: the default is pinned to the measured RS4GC fan-out cliff — the + /// largest estimate whose fan-out finished in bounded time (32M finished in + /// ~8.5 min; 40M/48M did not finish in 20 min). Change it only with fresh + /// measurement. + #[test] + fn default_sits_at_the_measured_fan_out_cliff() { + assert_eq!(DEFAULT_ROOT_SPILL_RELOCATIONS, 32_000_000); + } + + /// The moderate case the old 4M default wrongly spilled (#8620): ~8M + /// relocations (4000 root slots × ~2001 safepoints) fans out in minutes, so + /// under the new default it stays on native statepoints. + #[test] + fn moderate_fan_out_stays_on_statepoints() { + let est = root_relocation_estimate(4000, 2001); + assert_eq!(est, 8_004_000); + assert!( + est <= DEFAULT_ROOT_SPILL_RELOCATIONS, + "moderate estimate {est} must not exceed the default (would spill)", + ); + } + + /// The genuinely-catastrophic case (Claude Code `cli.js` `@main`, + /// ~795 slots × ~106k safepoints ≈ 8.4e7, never finishes at `-Os`) must + /// still spill under the new default. + #[test] + fn catastrophic_fan_out_still_spills() { + let est = root_relocation_estimate(795, 106_000); + assert!( + est > DEFAULT_ROOT_SPILL_RELOCATIONS, + "catastrophic estimate {est} must exceed the default (should spill)", + ); + } +} + /// Decide whether `func` should spill its roots to the shadow frame, and if so /// mark it (BEFORE its `enable_*_shadow_frame` call) and report it. Only /// meaningful under native stack-map roots — the shadow frame is already the From 5f2842ddf2fd2b3d1f914d0e9404f43207cd898c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:39:34 +0200 Subject: [PATCH 2/6] changelog: root-spill default threshold raise (#8623) Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- changelog.d/8623-root-spill-threshold.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/8623-root-spill-threshold.md diff --git a/changelog.d/8623-root-spill-threshold.md b/changelog.d/8623-root-spill-threshold.md new file mode 100644 index 0000000000..cd0cc43be7 --- /dev/null +++ b/changelog.d/8623-root-spill-threshold.md @@ -0,0 +1,3 @@ +### Changed + +- Native GC-root spilling default raised from 4,000,000 to 32,000,000 estimated statepoint relocations (#8620, #8589). The 4M default fired on moderate-fan-out functions whose native `rewrite-statepoints-for-gc` statepoints optimize fine — on an ~8M-relocation entry function, spilling to the shadow frame was measured *slower* than the fan-out it replaced (303 s spilled vs 180 s fanned out), paying shadow-frame overhead for nothing. Measured synthetic entry functions (`@main` codegen unit, `-Os`, spilling off) fan out in bounded time up to 32M (~8.5 min) and do not finish past 40M (> 20 min), so the default is set to the largest estimate whose fan-out still finished. This is compile-time only — spilling a run-once init entry does not affect the emitted binary's runtime — and is backstopped by the post-RS4GC instruction-budget assertion (`PERRY_LL_RS4GC_MAX_INSTRS`, #8586), which fails loudly rather than hanging if a function this estimate misses still fans out. `PERRY_ROOT_SPILL_RELOCATIONS=` still overrides it; `0` disables spilling. From 24b3ae54415e62802b6e7d59c1eabb618217a4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:52:57 +0200 Subject: [PATCH 3/6] perf(codegen): un-root typed-array-param numeric accumulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #8619 for the typed-array PARAMETER case. A function that folds a spec-ABI-proven typed-array parameter into an accumulator (`let x = arr[i] + 1.0; s = s + x`) kept its numeric locals `x` and `s` as NaN-boxed GC roots with a per-write `js_write_barrier_root_nanbox`, and lowered `s`'s update to the opaque `js_dynamic_string_or_number_add` instead of an inline `fadd` — even though every value is a genuine Number. Root cause: the `number_by_construction` fixpoint's `numeric_view_value_or_undefined` (collectors/ptr_shape_numeric.rs) recognised a typed-array element read as "Number-or-undefined, never a pointer" only for a LOCAL view with a compiler-visible `TypedArrayNew` init — not for a spec-proven `TaPtr` parameter. So the fresh, read-derived `x` failed the numeric proof, which cascaded to the loop-carried accumulator `s = s + x`. Fix: the fixpoint now also treats a read off a `spec_ta_lens` binding as Number-or-undefined. `spec_ta_lens` is keyed exactly by `SpecParamRep::TaPtr` parameters, and `collectors::spec_abi_sites` admits a `TaPtr` only for `spec_ta_kind_is_numeric` kinds (the BigInt typed arrays are never `TaPtr`), so `arr[numeric_index]` off one is provably a Number in-bounds and `undefined` out of range, which `+` launders into a genuine Number (NaN at worst). The `rec(index)` guard is retained: a non-numeric key reads a property, which can be a pointer. Soundness rests on the entry contract, not the erased annotation, so a reassigned or unproven receiver is untouched. Measured on a 200000x4096 Float64Array reduction passed by parameter: the accumulator's per-iteration dynamic add + root barrier become a single `fadd` in a raw double slot — ~5x faster (5.1-7.3s -> ~1.0s), byte-identical output to the rooted build under every moving-GC configuration and to Node. Tests: unit (perry-codegen) `spec_ta_param_view_admits_read_derived_number_locals` and `ta_read_without_spec_proof_stays_dynamic` prove the fix is load-bearing; integration (perry) `gc_ta_view_accumulator_unroot_8619` is a rooted-vs-fix differential across the moving-GC matrix, covering Float64Array/Int32Array kinds and OOB/negative indices. Not covered: the issue's module-global reproducer — on main that read is still a runtime call (module-global read inlining, #8617, is unmerged), so its rooting is a secondary cost; extending the same proof to `module_global_proven_types` is the follow-up once the read inlines. Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- ...19-ta-view-param-number-by-construction.md | 38 ++++ .../src/collectors/number_by_construction.rs | 88 ++++++++ .../perry-codegen/src/collectors/ptr_shape.rs | 4 + .../ptr_shape_group_numeric_tests.rs | 4 + .../src/collectors/ptr_shape_numeric.rs | 36 +++ .../gc_ta_view_accumulator_unroot_8619.rs | 205 ++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 changelog.d/8619-ta-view-param-number-by-construction.md create mode 100644 crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs diff --git a/changelog.d/8619-ta-view-param-number-by-construction.md b/changelog.d/8619-ta-view-param-number-by-construction.md new file mode 100644 index 0000000000..346f3fc38b --- /dev/null +++ b/changelog.d/8619-ta-view-param-number-by-construction.md @@ -0,0 +1,38 @@ +A hot function that folds a typed-array **parameter** into an accumulator — +`function reduce(arr: Float64Array) { let s = 0.0; for (…) { let x = arr[i] + 1.0; +s = s + x; } }` — no longer keeps its numeric locals as NaN-boxed GC roots. + +The spec-ABI already proves such a parameter (`TaPtr`) permanently holds one +specific numeric-kind, non-view typed array, and specializes the body so the +element read inlines. But the `number_by_construction` fixpoint +(`collectors/ptr_shape_numeric.rs`) only recognised a typed-array element read as +"a Number or `undefined`, never a pointer" for a **local** view with a +compiler-visible `TypedArrayNew` initializer — not for a proven `TaPtr` +parameter. So the fresh, read-derived `x` failed the numeric proof, which +cascaded to the loop-carried accumulator `s = s + x`. Both then kept a shadow +root slot with a per-write `js_write_barrier_root_nanbox`, and `s`'s update +lowered to the opaque `js_dynamic_string_or_number_add` call instead of an inline +`fadd`. + +The fixpoint now also treats a read off a `spec_ta_lens` binding as +Number-or-`undefined`. `spec_ta_lens` is keyed exactly by `SpecParamRep::TaPtr` +parameters, and `collectors::spec_abi_sites` admits a `TaPtr` only for +`spec_ta_kind_is_numeric` kinds (the BigInt typed arrays — whose elements are +BigInt pointers — are never `TaPtr`), so `arr[numeric_index]` off one is provably +a Number in-bounds and `undefined` out of range, which `+`/`-` launders into a +genuine Number (`NaN` at worst). The `rec(index)` guard is retained — a +non-numeric key would read a property, which can be a pointer. Soundness rests on +the entry contract, not on the erased `Float64Array` annotation, so a reassigned +or unproven receiver is untouched. + +Effect on a 200000×4096 `Float64Array` reduction passed by parameter: the +accumulator's per-iteration `js_dynamic_string_or_number_add` and root barrier +become a single `fadd` in a raw `double` slot — ~5× faster (measured 5.1–7.3s → +~1.0s), with byte-identical output to the rooted build under every moving-GC +configuration and to Node. + +Does not yet cover a typed array read through a **module-global** binding (the +issue #8619 reproducer): on `main` that read is still a runtime call (module- +global read inlining, #8617, is unmerged), so its accumulator rooting is a +secondary cost there; extending the same proof to `module_global_proven_types` +is the natural follow-up once the read inlines. diff --git a/crates/perry-codegen/src/collectors/number_by_construction.rs b/crates/perry-codegen/src/collectors/number_by_construction.rs index 480bc46a90..d12160fda9 100644 --- a/crates/perry-codegen/src/collectors/number_by_construction.rs +++ b/crates/perry-codegen/src/collectors/number_by_construction.rs @@ -92,12 +92,23 @@ pub(crate) fn collect_number_by_construction_locals( if !enabled() { return HashSet::new(); } + // #8619: spec-ABI `TaPtr` parameters are proven to permanently hold one + // specific NUMERIC-kind, non-view typed array — `spec_ta_lens` is keyed + // exactly by those params (its only source is `SpecParamRep::TaPtr`, which + // `collectors::spec_abi_sites` admits only for `spec_ta_kind_is_numeric` + // kinds; the BigInt kinds are never TaPtr). A read `arr[numeric_index]` off + // one is therefore a Number (in-bounds) or `undefined` (OOB), never a + // pointer/string, so the fixpoint may treat it like a compiler-visible + // local typed-view constructor on one side of `+` (where `undefined` + // becomes the Number NaN rather than selecting string concatenation). + let numeric_ta_views: HashSet = spec_ta_lens.keys().copied().collect(); let mut numeric = super::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis( stmts, boxed_vars, module_globals, not_bigint_locals, &HashMap::new(), + &numeric_ta_views, ); numeric.extend(collect_number_at_read_after_undefined( stmts, @@ -545,4 +556,81 @@ mod tests { assert!(!run(&stmts).contains(&N)); } + + // #8619: a spec-ABI `TaPtr` parameter is proven to permanently hold one + // specific NUMERIC-kind, non-view typed array, so `arr[numeric_index]` is a + // Number (in-bounds) or `undefined` (OOB) — never a pointer. The + // number-by-construction fixpoint must therefore admit a fresh + // read-derived local `let x = arr[i] + 1.0` (whose value is a genuine + // Number, `NaN` at worst) and cascade to the loop-carried accumulator + // `s = s + x`, so both drop their GC-root slot and their arithmetic stays + // an inline `fadd` instead of `js_dynamic_string_or_number_add`. + fn ta_view_stmts(arr: u32, s_id: u32, x_id: u32) -> Vec { + vec![ + Stmt::Let { + id: s_id, + name: "s".to_string(), + ty: HirType::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + Stmt::Let { + id: x_id, + name: "x".to_string(), + ty: HirType::Number, + mutable: false, + init: Some(add( + Expr::IndexGet { + object: Box::new(Expr::LocalGet(arr)), + index: Box::new(Expr::Integer(0)), + }, + Expr::Number(1.0), + )), + }, + Stmt::Expr(Expr::LocalSet( + s_id, + Box::new(add(Expr::LocalGet(s_id), Expr::LocalGet(x_id))), + )), + ] + } + + fn run_fixpoint(stmts: &[Stmt], ta_views: &HashSet) -> HashSet { + crate::collectors::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis( + stmts, + &HashSet::new(), + &HashMap::new(), + &HashSet::new(), + &HashMap::new(), + ta_views, + ) + } + + #[test] + fn spec_ta_param_view_admits_read_derived_number_locals() { + let (arr, s_id, x_id) = (10u32, 20u32, 21u32); + let stmts = ta_view_stmts(arr, s_id, x_id); + + let with = run_fixpoint(&stmts, &HashSet::from([arr])); + assert!( + with.contains(&x_id), + "fresh `arr[i] + 1.0` local must be Number by construction" + ); + assert!( + with.contains(&s_id), + "accumulator must cascade to Number by construction" + ); + } + + #[test] + fn ta_read_without_spec_proof_stays_dynamic() { + // Same body, but the receiver is NOT a spec-proven typed array: the read + // could be a string/property access on an arbitrary receiver, so neither + // local may be un-rooted. + let (arr, s_id, x_id) = (10u32, 20u32, 21u32); + let stmts = ta_view_stmts(arr, s_id, x_id); + + let without = run_fixpoint(&stmts, &HashSet::new()); + assert!(!without.contains(&x_id)); + assert!(!without.contains(&s_id)); + } } diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index caf576bc23..223ece44ad 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -479,6 +479,10 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( module_globals, not_bigint_locals, &const_local_inits, + // #8619: this is the `Ptr` provenance pass (feeds `is_numeric_expr`), + // not the local rooting proof; it has no specialized `TaPtr` context, so + // no view binding is spec-proven here. + &HashSet::new(), ); // A spec entry has validated these parameters before entering this body. // Unlike a TypeScript annotation, that is runtime evidence, so derived diff --git a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs index d5aa913e45..26f5f0512f 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs @@ -785,6 +785,7 @@ fn numeric_locals_of(stmts: &[Stmt]) -> HashSet { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ) } @@ -942,6 +943,7 @@ fn non_numeric_writes_and_bindings_are_excluded() { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ) .contains(&7), "a boxed local's write set is not this region's to enumerate" @@ -976,6 +978,7 @@ fn update_value_resolves_via_not_bigint() { &HashMap::new(), ¬_bigint, &HashMap::new(), + &HashSet::new(), ); assert!(with_fact.contains(&21)); let without_fact = numeric::collect_numeric_by_construction_locals( @@ -984,6 +987,7 @@ fn update_value_resolves_via_not_bigint() { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ); assert!( !without_fact.contains(&22), diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index f4e5ee07bc..798a8c9465 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -74,6 +74,10 @@ pub(super) fn prove_numeric_fields( const_local_inits: &HashMap>, numeric_locals: &HashSet, ) -> HashSet { + // #8619: the class-field numeric proof has no specialized-entry `TaPtr` + // context, so no view binding is spec-proven here. Passing empty keeps this + // proof bit-identical to before the local `TaPtr` extension. + let no_ta_views: HashSet = HashSet::new(); let mut numeric: HashSet = HashSet::new(); for class in chain { for field in &class.fields { @@ -119,6 +123,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }) @@ -145,6 +150,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }) @@ -219,6 +225,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }; @@ -243,6 +250,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ), }; @@ -429,6 +437,9 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( module_globals: &HashMap, not_bigint_locals: &HashSet, const_local_inits: &HashMap>, + // #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI + // `TaPtr` params). Empty for the `Ptr` type-analysis caller. + numeric_ta_views: &HashSet, ) -> HashSet { // ONE write walker for both fixpoints (`collect_not_bigint_locals` and // this one) — see its doc for why sharing is load-bearing. `None` = a @@ -471,6 +482,7 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( not_bigint_locals, &stable_local_inits, &numeric, + numeric_ta_views, 0, ), }) @@ -505,6 +517,15 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals: &HashSet, const_local_inits: &HashMap>, numeric_locals: &HashSet, + // #8619: view bindings PROVEN to permanently hold a numeric-kind typed + // array — a spec-ABI `TaPtr` parameter (the entry contract binds the raw + // header of a proven numeric non-view typed array). A read + // `view_id[numeric_index]` is then a Number (in-bounds) or `undefined` + // (OOB) by construction, never a pointer/string, which the Add rule below + // launders into a genuine Number. Empty on every path that is not a + // specialized-entry local proof (the class-field provers, the `Ptr` + // pass). + numeric_ta_views: &HashSet, depth: usize, ) -> bool { if depth > 16 { @@ -520,6 +541,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ) }; @@ -537,6 +559,18 @@ pub(super) fn expr_numeric_by_construction( let Expr::LocalGet(view_id) = object.as_ref() else { return false; }; + // #8619: a spec-proven numeric typed-array binding (`TaPtr` parameter) + // has no compiler-visible `TypedArrayNew` init in this body, but its + // entry contract is a STRONGER proof than an inline constructor: the + // call-site pre-pass proved the argument is one specific numeric-kind, + // non-view typed array, never reassigned. So `view_id[numeric_index]` + // is a Number-or-`undefined` exactly as the local-constructor case + // below — never a pointer. The `rec(index)` guard is retained: a + // non-numeric key (symbol/string) would read a property, which can be a + // pointer. + if numeric_ta_views.contains(view_id) { + return rec(index); + } let Some(Some(init)) = const_local_inits.get(view_id) else { return false; }; @@ -676,6 +710,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ) }) == Some(true) @@ -699,6 +734,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ); } diff --git a/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs new file mode 100644 index 0000000000..6cc723e7d9 --- /dev/null +++ b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs @@ -0,0 +1,205 @@ +//! #8619 — a proven-Number f64 accumulator derived from a typed-array read is +//! un-rooted (raw double, not a nanbox GC root) without corrupting the heap. +//! +//! A function that reads a spec-ABI-proven typed-array parameter and folds the +//! elements into an accumulator (`let x = arr[i] + 1.0; s = s + x`) used to keep +//! BOTH `x` and `s` in nanbox GC-root slots with a per-write root barrier, even +//! though every value is a genuine Number (a typed-array element is a Number +//! in-bounds and `undefined` — never a pointer — out of range, which `+` +//! launders into a Number). #8619 teaches the number-by-construction fixpoint +//! that a `TaPtr` view read is Number-or-`undefined`, so the accumulator drops +//! its root slot and its arithmetic becomes an inline `fadd`. +//! +//! This is a differential test with NO node oracle. The same program is compiled +//! twice from identical source: +//! +//! * `PERRY_NUMBER_BY_CONSTRUCTION=0` — the fact is empty, so the accumulator +//! stays a NaN-boxed GC root updated through `js_dynamic_string_or_number_add` +//! (the pre-#8619 lowering); +//! * unset (default) — the accumulator is proven Number by construction and +//! kept in a raw `double` slot with an inline `fadd`. +//! +//! Both binaries run under every moving-collector configuration and MUST produce +//! byte-identical output. If the un-rooting were unsound — if the accumulator +//! could ever hold a pointer the collector no longer tracks — a relocating minor +//! would leave a stale pointer and the checksum would diverge (or the run would +//! crash) in the default arm only. The interleaved `keep` array forces nursery +//! collections while the un-rooted accumulator is live, so the collector is +//! actually exercised against the changed frame. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +const SOURCE: &str = r#" +// Hot reducer over a typed-array PARAMETER: spec-ABI proves `arr` is one +// specific Float64Array, so `arr[i]` inlines and `x`/`s` are Number by +// construction under #8619. The `keep` array churns the nursery so a moving +// minor runs while the (un-rooted) accumulator is live. +function reduce(arr: Float64Array, n: number): number { + let s = 0.0; + const keep: number[] = []; + for (let i = 0; i < n; i++) { + let x = arr[i] + 1.0; + s = s + x * 0.5; + if ((i & 31) === 0) { + keep.push(x); + if (keep.length > 64) keep.shift(); + } + } + let t = 0.0; + for (const k of keep) { t = t + k; } + return s + t; +} + +// Out-of-range / negative integer indices: a typed-array read is `undefined` +// there, and `undefined + 1.0` is the Number NaN — never a pointer or a string. +function edges(arr: Float64Array): number { + let acc = 0.0; + for (let i = -2; i < 6; i++) { + let x = arr[i] + 1.0; + acc = acc + (x !== x ? 100.0 : x); + } + return acc; +} + +// A different numeric kind, folded with subtraction. +function reduceI32(arr: Int32Array, n: number): number { + let s = 0.0; + for (let i = 0; i < n; i++) { + let x = arr[i] - 3.0; + s = s + x; + } + return s; +} + +let f = new Float64Array(256); +for (let i = 0; i < 256; i++) { f[i] = i * 0.25; } +let g = new Int32Array(256); +for (let i = 0; i < 256; i++) { g[i] = i - 128; } + +let acc = 0.0; +for (let r = 0; r < 6000; r++) { + acc = acc + reduce(f, 256) + reduceI32(g, 256) + edges(f); +} +console.log("acc:" + acc); +"#; + +/// Collector knobs cleared before each run so a developer's exported kill switch +/// cannot turn every arm into the never-relocates control. +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +/// `number_by_construction`: unset = default (the #8619 un-rooting); "0" = +/// disabled (pre-#8619 rooted accumulator). Keyed into the object cache, so +/// `--no-cache` is belt-and-suspenders. +fn compile(dir: &std::path::Path, nbc: Option<&str>) -> PathBuf { + let entry = dir.join("main.ts"); + let label = nbc.unwrap_or("default"); + let output = dir.join(format!("bin_nbc_{label}")); + std::fs::write(&entry, SOURCE).expect("write entry"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-auto-optimize"); + cmd.env_remove("PERRY_NUMBER_BY_CONSTRUCTION"); + if let Some(v) = nbc { + cmd.env("PERRY_NUMBER_BY_CONSTRUCTION", v); + } + let out = cmd.output().expect("run perry compile"); + assert!( + out.status.success(), + "perry compile (PERRY_NUMBER_BY_CONSTRUCTION={label}) failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + output +} + +fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str) -> String { + let mut arms: Vec> = vec![vec![]]; + for mb in ["1", "2", "4"] { + arms.push(vec![("PERRY_GC_SCAVENGE_NURSERY_MB", mb)]); + } + arms.push(vec![("PERRY_GEN_GC", "0")]); + + let mut first: Option = None; + for arm in &arms { + let mut cmd = Command::new(binary); + cmd.current_dir(dir); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + for (k, v) in arm { + cmd.env(k, v); + } + let run = cmd.output().expect("run compiled binary"); + let arm_label = if arm.is_empty() { + format!("{label}/default") + } else { + format!( + "{label}/{}", + arm.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ) + }; + assert!( + run.status.success(), + "[{arm_label}] compiled binary failed (exit {:?})\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stderr), + ); + let stdout = String::from_utf8_lossy(&run.stdout).into_owned(); + match &first { + None => first = Some(stdout), + Some(f) => assert_eq!( + &stdout, f, + "[{arm_label}] output differs between collector arms — a moving \ + minor left a stale root in this configuration" + ), + } + } + first.expect("at least one arm ran") +} + +#[test] +fn ta_view_accumulator_unroot_is_gc_correct() { + let dir = tempfile::tempdir().expect("tempdir"); + + // Rooted reference (fact disabled) and #8619 un-rooted arm, identical source. + let rooted_bin = compile(dir.path(), Some("0")); + let unrooted_bin = compile(dir.path(), None); + + let rooted_out = run_arms(&rooted_bin, dir.path(), "rooted"); + let unrooted_out = run_arms(&unrooted_bin, dir.path(), "unrooted"); + + assert!( + rooted_out.starts_with("acc:"), + "unexpected program output: {rooted_out:?}" + ); + assert_eq!( + rooted_out, unrooted_out, + "un-rooting the typed-array-derived accumulator (#8619) changed observable \ + output vs the rooted build — an un-rooted slot that can hold a pointer, or \ + a semantic divergence in the arithmetic fast path" + ); +} From 2b1683d154fef6a09f792f6e5d041c2cdea13bd9 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Sat, 22 Aug 2026 16:30:05 -0400 Subject: [PATCH 4/6] fix(compile): --report-size duplicate-body false positives + overclaimed size Two accuracy fixes to the report enrichment from #8579, both found by testing against a live compiled binary rather than trusting the code: - Duplicate function/static-data body detection keyed on an FNV-1a hash + size check. CodeRabbit correctly flagged that a hash collision at the same size could fabricate a false duplicate. Fixed properly rather than reworded: key directly on the exact byte slice (&[u8] is Ord) instead of hashing it, which is exact by construction and no more expensive to implement. - The "duplicate crate instance" finding's first draft claimed its total_bytes were recoverable shipped-binary size. Verified directly (md5 + objdump on the extracted archive members from a real compiled program) that perry-runtime and perry-stdlib DO redundantly compile some shared dependencies (gimli, confirmed byte-identical across their two separate .a archives) -- but a successful link only pulls ONE physical copy per symbol (a linker errors on true duplicate- symbol inclusion), so every byte attributed is real, in-use code in the shipped binary, not a duplicate sitting in it twice. Renamed duplicate_crate_versions -> duplicate_crate_instances in the JSON schema (unreleased, so free to fix) and set estimated_bytes to 0 for this finding so it can't misrank against suggestions that genuinely shrink the shipped binary. The report and suggestion text now say this is a compile-time/archive-size finding explicitly, instead of overclaiming a shipped-binary-size win that isn't there. --- CLAUDE.md | 2 +- Cargo.lock | 154 +++++++++--------- Cargo.toml | 2 +- ...627-report-size-duplicate-body-accuracy.md | 3 + .../perry/src/commands/compile/size_report.rs | 113 +++++++------ 5 files changed, 137 insertions(+), 137 deletions(-) create mode 100644 changelog.d/8627-report-size-duplicate-body-accuracy.md diff --git a/CLAUDE.md b/CLAUDE.md index 61bf8dcebb..94d8b53bf4 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.1519 +**Current Version:** 0.5.1520 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index ebcce9bbe9..2689c5821c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5598,7 +5598,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -5660,7 +5660,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-dispatch", "serde", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "cc", "libc", @@ -5677,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "inkwell", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-dispatch", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -5739,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "async-trait", @@ -5776,14 +5776,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "serde", "serde_json", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5802,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "clap", @@ -5817,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "block2", "objc2", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "argon2", "perry-ffi", @@ -5836,7 +5836,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "reqwest", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bcrypt", "perry-ffi", @@ -5853,7 +5853,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rusqlite", @@ -5861,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "scraper", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "cron", @@ -5887,7 +5887,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -5895,7 +5895,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rust_decimal", @@ -5903,7 +5903,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "serde_json", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5919,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -5927,14 +5927,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "http-body-util", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "lazy_static", @@ -5965,7 +5965,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "bytes", @@ -5990,7 +5990,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lazy_static", "perry-ffi", @@ -6000,7 +6000,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lru", "perry-ffi", @@ -6020,7 +6020,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bson", "futures-util", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "chrono", "perry-ffi", @@ -6050,7 +6050,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "nanoid", "perry-ffi", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "bytes", "perry-ffi", @@ -6072,7 +6072,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "const-oid 0.10.2", "der 0.8.0", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lettre", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "fancy-regex", "notify", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "printpdf", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "sqlx", @@ -6130,7 +6130,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "governor", "perry-ffi", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "fast_image_resize", "image", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "lazy_static", "perry-ffi", @@ -6157,7 +6157,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "serde", @@ -6173,7 +6173,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "perry-runtime", @@ -6182,7 +6182,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "uuid", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ffi", "regex", @@ -6200,7 +6200,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "futures-util", "lazy_static", @@ -6213,7 +6213,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "brotli", "flate2", @@ -6223,7 +6223,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "dashmap", "once_cell", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-api-manifest", @@ -6250,7 +6250,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-diagnostics", @@ -6262,7 +6262,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -6304,14 +6304,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6406,14 +6406,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "perry-hir", @@ -6422,14 +6422,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "itoa", @@ -6447,7 +6447,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "rand 0.10.1", "serde", @@ -6457,7 +6457,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.0", @@ -6480,7 +6480,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-ui-test" @@ -6524,11 +6524,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1519" +version = "0.5.1520" [[package]] name = "perry-ui-tvos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "block2", @@ -6562,7 +6562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "block2", "libc", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "base64 0.22.1", "libc", @@ -6595,14 +6595,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "anyhow", "base64 0.22.1", @@ -6618,7 +6618,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1519" +version = "0.5.1520" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 2623e8a948..f6b934f2fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -316,7 +316,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1519" +version = "0.5.1520" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/8627-report-size-duplicate-body-accuracy.md b/changelog.d/8627-report-size-duplicate-body-accuracy.md new file mode 100644 index 0000000000..209c5ab2c8 --- /dev/null +++ b/changelog.d/8627-report-size-duplicate-body-accuracy.md @@ -0,0 +1,3 @@ +### Fixed + +- `perry compile --report-size` (from [#8579](https://github.com/PerryTS/perry/pull/8579)): duplicate function/static-data body detection now keys directly on the exact byte slice instead of an FNV-1a hash + size check — a hash collision at the same size could previously fabricate a false duplicate finding (caught in CodeRabbit review, fixed before this PR rather than just reworded, since the fix was as cheap as the hash was). Also corrected the "duplicate crate instance" finding's framing: verified directly (md5 + objdump on the extracted archive members) that while `perry-runtime`/`perry-stdlib` do redundantly compile some shared dependencies (`gimli` confirmed byte-identical across their two separate `.a` archives), a successful link only pulls one physical copy per symbol — so the attributed bytes are real, in-use code in the shipped binary, not a duplicate sitting in it twice. The report and its "Suggestions" section now say this explicitly (renamed `duplicate_crate_versions` to `duplicate_crate_instances` in the JSON schema, and `estimated_bytes` for this finding is `0` rather than a size claim) instead of overclaiming a shipped-binary-size win that isn't there. diff --git a/crates/perry/src/commands/compile/size_report.rs b/crates/perry/src/commands/compile/size_report.rs index 6bf5481927..9a7874878a 100644 --- a/crates/perry/src/commands/compile/size_report.rs +++ b/crates/perry/src/commands/compile/size_report.rs @@ -18,7 +18,7 @@ //! (static-archive compile, then a raw `cc`/`ld` link of those archives plus //! LLVM-emitted object code). This is a symbol-table-only view of whatever //! made it into the final link — code/data attribution, duplicate function -//! bodies, duplicate crate versions, generic-monomorphization cost, and a +//! bodies, duplicate crate instances, generic-monomorphization cost, and a //! few named cost patterns (panics, `Debug`/`Display` formatting, vtables). use std::collections::BTreeMap; @@ -67,13 +67,18 @@ struct DuplicateBody { symbols: Vec, } +/// Same crate name compiled independently more than once (proof, not +/// inference — a distinct v0-mangling disambiguator hash per build, unlike +/// reading `Cargo.lock`, which only proves a version is *resolvable*). +/// +/// This is a compile-time / intermediate-archive-size finding, not a +/// shipped-binary-size one: a successful link proves each hash's content is +/// linked at most once (the linker errors on a true duplicate-symbol +/// inclusion), so `total_bytes` is real, in-use code in the final binary — +/// not bytes recoverable by deduplicating it there. #[derive(Serialize)] -struct DuplicateCrateVersion { +struct DuplicateCrateInstance { crate_name: String, - /// The v0-mangling disambiguator hash for each distinct build of this - /// crate name actually linked into the binary — proof, not inference, - /// that more than one copy is present (unlike reading `Cargo.lock`, - /// which only proves more than one version is *resolvable*). hashes: Vec, total_bytes: u64, } @@ -105,7 +110,7 @@ struct SizeReport { largest: Vec, generic_families: Vec, duplicate_bodies: Vec, - duplicate_crate_versions: Vec, + duplicate_crate_instances: Vec, patterns: Vec, suggestions: Vec, } @@ -272,7 +277,7 @@ fn build_report(exe_path: &Path) -> anyhow::Result { let mut family_totals: BTreeMap<(String, String), (usize, u64)> = BTreeMap::new(); let mut crate_hashes: BTreeMap> = BTreeMap::new(); let mut crate_hash_bytes: BTreeMap<(String, String), u64> = BTreeMap::new(); - let mut body_hashes: BTreeMap> = BTreeMap::new(); // hash -> [(symbol, size)] + let mut body_bytes: BTreeMap<&[u8], Vec<(String, u64)>> = BTreeMap::new(); // exact bytes -> [(symbol, size)] let mut pattern_totals: BTreeMap<&'static str, (u64, usize)> = BTreeMap::new(); for sym in &raw { @@ -321,13 +326,12 @@ fn build_report(exe_path: &Path) -> anyhow::Result { if let Ok(section) = file.section_by_index(object::SectionIndex(sym.section as usize)) { if let Ok(Some(bytes)) = section.data_range(sym.address, sym.size) { - // FNV-1a: fast, dependency-free, and collisions here only cost - // a false "these might be duplicates" that the exact byte - // slices grouped under the same hash would still need to - // agree on — good enough for a diagnostic report. - let hash = fnv1a(bytes); - body_hashes - .entry(hash) + // Keyed on the exact byte slice (`&[u8]` is `Ord`), not a + // hash of it — a duplicate-body finding is a claim serious + // enough that a hash collision must not be able to fabricate + // one. + body_bytes + .entry(bytes) .or_default() .push((demangled.clone(), sym.size)); } @@ -359,13 +363,9 @@ fn build_report(exe_path: &Path) -> anyhow::Result { generic_families.sort_by_key(|a| std::cmp::Reverse(a.total_bytes)); generic_families.truncate(REPORT_TOP_FAMILIES); - let mut duplicate_bodies: Vec = body_hashes + let mut duplicate_bodies: Vec = body_bytes .into_values() .filter(|group| group.len() > 1) - // Same-hash groups can still differ in size if two DIFFERENT-length - // symbols' byte ranges happened to collide in the (rare) FNV-1a sense; - // require the sizes to actually match before calling it a duplicate. - .filter(|group| group.iter().all(|(_, size)| *size == group[0].1)) .map(|group| { let size = group[0].1; let copies = group.len(); @@ -380,7 +380,7 @@ fn build_report(exe_path: &Path) -> anyhow::Result { duplicate_bodies.sort_by_key(|a| std::cmp::Reverse(a.wasted_bytes)); duplicate_bodies.truncate(REPORT_TOP_DUPLICATES); - let mut duplicate_crate_versions: Vec = crate_hashes + let mut duplicate_crate_instances: Vec = crate_hashes .into_iter() .filter(|(_, hashes)| hashes.len() > 1) .map(|(crate_name, hashes)| { @@ -393,14 +393,14 @@ fn build_report(exe_path: &Path) -> anyhow::Result { .unwrap_or(0) }) .sum(); - DuplicateCrateVersion { + DuplicateCrateInstance { crate_name, hashes: hashes.into_iter().collect(), total_bytes, } }) .collect(); - duplicate_crate_versions.sort_by_key(|a| std::cmp::Reverse(a.total_bytes)); + duplicate_crate_instances.sort_by_key(|a| std::cmp::Reverse(a.total_bytes)); let mut patterns: Vec = pattern_totals .into_iter() @@ -409,7 +409,7 @@ fn build_report(exe_path: &Path) -> anyhow::Result { patterns.sort_by_key(|a| std::cmp::Reverse(a.bytes)); let suggestions = build_suggestions( - &duplicate_crate_versions, + &duplicate_crate_instances, &generic_families, &duplicate_bodies, &patterns, @@ -438,36 +438,47 @@ fn build_report(exe_path: &Path) -> anyhow::Result { largest: largest_all, generic_families, duplicate_bodies, - duplicate_crate_versions, + duplicate_crate_instances, patterns, suggestions, }) } fn build_suggestions( - duplicate_crate_versions: &[DuplicateCrateVersion], + duplicate_crate_instances: &[DuplicateCrateInstance], generic_families: &[GenericFamily], duplicate_bodies: &[DuplicateBody], patterns: &[PatternTotal], ) -> Vec { let mut out = Vec::new(); - for dup in duplicate_crate_versions { + for dup in duplicate_crate_instances { out.push(Suggestion { - kind: "duplicate-crate-instance", + kind: "duplicate-compile-crate-instance", summary: format!( - "`{}` is linked {} times under different builds ({}) — Cargo.lock likely already \ - agrees on one version; this is `perry-runtime`/`perry-stdlib` each independently \ - compiling their own copy as separate `cargo build` invocations, so identical code \ - doesn't dedupe across the resulting `.a` archives. Extending Perry's existing \ - archive-dedup pass (today scoped to `dedup_runtime_for_tier3`/`dedup_stdlib_for_tier3`) \ - to the default build path would recover up to {}", + "`{}` is compiled independently {} times ({}) — once each inside \ + `perry-runtime`'s and `perry-stdlib`'s separate `cargo build` invocations, not \ + a Cargo.lock version conflict. This is redundant COMPILE work and bloats the \ + intermediate `.a` archives; it is NOT necessarily {} of recoverable shipped-\ + binary size — a successful link proves each hash's content is linked at most \ + once (the linker errors on a true duplicate-symbol inclusion), so every byte \ + attributed here is real, in-use code in this binary, not waste sitting twice in \ + it. Extending Perry's existing archive-dedup pass (today scoped to \ + `dedup_runtime_for_tier3`/`dedup_stdlib_for_tier3`) to the default build path \ + would speed up incremental/auto-optimize builds and shrink the intermediate \ + archives; whether it also shrinks a given shipped binary depends on whether that \ + binary's link happens to need both hash-variants — a separate, per-binary claim \ + this report does not make.", dup.crate_name, dup.hashes.len(), dup.hashes.join(", "), human_bytes(dup.total_bytes), ), - estimated_bytes: dup.total_bytes, + // Deliberately not `dup.total_bytes`: that is real, in-use code + // in THIS binary (see summary), not a recoverable-bytes claim — + // giving it a nonzero estimate here would misrank it against + // suggestions that genuinely shrink the shipped binary. + estimated_bytes: 0, }); } @@ -558,17 +569,6 @@ const PATTERNS: &[PatternMatcher] = &[ }), ]; -/// FNV-1a — fast, dependency-free, good enough to bucket candidate duplicate -/// bodies before the exact-size check in `build_report` confirms them. -fn fnv1a(bytes: &[u8]) -> u64 { - let mut hash: u64 = 0xcbf29ce484222325; - for &b in bytes { - hash ^= b as u64; - hash = hash.wrapping_mul(0x100000001b3); - } - hash -} - /// Demangle a Rust symbol name. `rustc_demangle` returns non-Rust input /// unchanged — the normal case for libc/system symbols — and `crate_of` /// below buckets those as `native/other`. @@ -580,7 +580,7 @@ fn demangle(name: &str) -> String { /// segment) and, when present, the v0-mangling disambiguator hash right /// after it (`crate_name[16 hex digits]`). Two symbols from the SAME crate /// NAME but DIFFERENT hashes are proof two separate builds of that crate -/// both made it into the final link — see `DuplicateCrateVersion`. +/// both made it into the final link — see `DuplicateCrateInstance`. /// /// `::method` / `::method` associated-fn forms put the /// crate name one level in; the leading `<` is stripped before reading it. @@ -716,14 +716,17 @@ fn render_markdown(report: &SizeReport) -> String { )); } - if !report.duplicate_crate_versions.is_empty() { - out.push_str("\n## Duplicate crate versions\n\n"); + if !report.duplicate_crate_instances.is_empty() { + out.push_str("\n## Duplicate crate instances\n\n"); out.push_str( - "Same crate name linked more than once under a different build (proven from the \ - symbol table's own disambiguator hash, not inferred from `Cargo.lock`).\n\n", + "Same crate name compiled independently more than once (proven from the symbol \ + table's own disambiguator hash, not inferred from `Cargo.lock`). This is a \ + compile-time / intermediate-archive-size finding: a successful link proves each \ + hash's content is linked at most once, so the `Total` column is real, in-use code \ + in this binary — not bytes recoverable by deduplicating it here.\n\n", ); out.push_str("| Total | Copies | Crate |\n|---|---|---|\n"); - for dup in &report.duplicate_crate_versions { + for dup in &report.duplicate_crate_instances { out.push_str(&format!( "| {} | {} | `{}` |\n", human_bytes(dup.total_bytes), @@ -884,12 +887,6 @@ mod tests { ); } - #[test] - fn fnv1a_is_deterministic_and_distinguishes_different_bytes() { - assert_eq!(fnv1a(b"hello"), fnv1a(b"hello")); - assert_ne!(fnv1a(b"hello"), fnv1a(b"world")); - } - #[test] fn human_bytes_picks_the_right_unit() { assert_eq!(human_bytes(512), "512 B"); From d52ee93ef539247160eaf8fee59774050b00ea3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 22:54:38 +0200 Subject: [PATCH 5/6] chore(merge): PR-key #8625's changelog fragment; drop #8627's version bump Fragments are PR-keyed so in-flight PRs never collide; 8619 is the issue. The version bump is the maintainer's at merge time. Stacks #8623, #8625, #8627. --- CLAUDE.md | 2 +- Cargo.lock | 154 +++++++++--------- Cargo.toml | 2 +- ...5-ta-view-param-number-by-construction.md} | 0 4 files changed, 79 insertions(+), 79 deletions(-) rename changelog.d/{8619-ta-view-param-number-by-construction.md => 8625-ta-view-param-number-by-construction.md} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 94d8b53bf4..61bf8dcebb 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.1520 +**Current Version:** 0.5.1519 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2689c5821c..ebcce9bbe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5598,7 +5598,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "base64 0.22.1", @@ -5660,7 +5660,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-dispatch", "serde", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "cc", "libc", @@ -5677,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "inkwell", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-hir", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-hir", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-dispatch", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-hir", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "base64 0.22.1", @@ -5739,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-hir", @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "async-trait", @@ -5776,14 +5776,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "serde", "serde_json", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1520" +version = "0.5.1519" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5802,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "clap", @@ -5817,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "block2", "objc2", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "argon2", "perry-ffi", @@ -5836,7 +5836,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "reqwest", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "bcrypt", "perry-ffi", @@ -5853,7 +5853,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "rusqlite", @@ -5861,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "scraper", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "perry-runtime", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "chrono", "cron", @@ -5887,7 +5887,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "chrono", "perry-ffi", @@ -5895,7 +5895,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "rust_decimal", @@ -5903,7 +5903,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "serde_json", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5919,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "perry-runtime", @@ -5927,14 +5927,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "bytes", "http-body-util", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "bytes", "lazy_static", @@ -5965,7 +5965,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "bytes", @@ -5990,7 +5990,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "lazy_static", "perry-ffi", @@ -6000,7 +6000,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "lru", "perry-ffi", @@ -6020,7 +6020,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "chrono", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "bson", "futures-util", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "chrono", "perry-ffi", @@ -6050,7 +6050,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "nanoid", "perry-ffi", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "bytes", "perry-ffi", @@ -6072,7 +6072,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "const-oid 0.10.2", "der 0.8.0", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "lettre", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "fancy-regex", "notify", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "printpdf", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "sqlx", @@ -6130,7 +6130,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "governor", "perry-ffi", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "fast_image_resize", "image", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "lazy_static", "perry-ffi", @@ -6157,7 +6157,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "serde", @@ -6173,7 +6173,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "perry-runtime", @@ -6182,7 +6182,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "uuid", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ffi", "regex", @@ -6200,7 +6200,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "futures-util", "lazy_static", @@ -6213,7 +6213,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "brotli", "flate2", @@ -6223,7 +6223,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "dashmap", "once_cell", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-api-manifest", @@ -6250,7 +6250,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-diagnostics", @@ -6262,7 +6262,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "base64 0.22.1", @@ -6304,14 +6304,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6406,14 +6406,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "perry-hir", @@ -6422,14 +6422,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "itoa", @@ -6447,7 +6447,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "rand 0.10.1", "serde", @@ -6457,7 +6457,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.0", @@ -6480,7 +6480,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "block2", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "block2", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1520" +version = "0.5.1519" [[package]] name = "perry-ui-test" @@ -6524,11 +6524,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1520" +version = "0.5.1519" [[package]] name = "perry-ui-tvos" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "block2", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "block2", @@ -6562,7 +6562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "block2", "libc", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "base64 0.22.1", "libc", @@ -6595,14 +6595,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "anyhow", "base64 0.22.1", @@ -6618,7 +6618,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1520" +version = "0.5.1519" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index f6b934f2fa..2623e8a948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -316,7 +316,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1520" +version = "0.5.1519" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/8619-ta-view-param-number-by-construction.md b/changelog.d/8625-ta-view-param-number-by-construction.md similarity index 100% rename from changelog.d/8619-ta-view-param-number-by-construction.md rename to changelog.d/8625-ta-view-param-number-by-construction.md From 08a1b24bfcd7ef6607ea2d7e27d19a2a7aa2a1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 23:25:06 +0200 Subject: [PATCH 6/6] test(gc): make the #8619 differential fixture actually relocate The fixture measured copied_objects=0 with a single GC cycle: `keep` pushed unboxed numbers, so the nursery never filled and the collector never moved anything. The differential still compared arithmetic, but as a GC test it could not fail for the reason it exists. Push a fresh object per iteration instead (~100 copied objects per cycle over ~600 cycles), and assert the two arms lower differently so a fixpoint that stops firing cannot make the comparison vacuous. Verified on a #8625-bearing release build: 14 runs across default, SCAVENGE_NURSERY_MB=1/2/4, GEN_GC=0, FORCE_EVACUATE=1 and VERIFY_EVACUATION=1 all agree at acc:34378500, matching the node oracle. --- .../gc_ta_view_accumulator_unroot_8619.rs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs index 6cc723e7d9..01ceb4e290 100644 --- a/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs +++ b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs @@ -26,6 +26,14 @@ //! crash) in the default arm only. The interleaved `keep` array forces nursery //! collections while the un-rooted accumulator is live, so the collector is //! actually exercised against the changed frame. +//! +//! `keep` pushes a fresh OBJECT rather than an unboxed number ON PURPOSE, and +//! that is load-bearing: with plain numbers the fixture measured +//! `copied_objects=0` over a single GC cycle, so nothing was ever relocated and +//! the differential degenerated into an arithmetic comparison. With objects it +//! measures ~100 copied objects per cycle across ~600 cycles. If you shrink this +//! fixture, re-check `PERRY_GC_DIAG=1 … | grep copied_objects` first: a GC test +//! that never moves anything cannot fail for the reason it exists. use std::path::PathBuf; use std::process::Command; @@ -41,17 +49,21 @@ const SOURCE: &str = r#" // minor runs while the (un-rooted) accumulator is live. function reduce(arr: Float64Array, n: number): number { let s = 0.0; - const keep: number[] = []; + const keep: { v: number; tag: string }[] = []; for (let i = 0; i < n; i++) { let x = arr[i] + 1.0; s = s + x * 0.5; - if ((i & 31) === 0) { - keep.push(x); - if (keep.length > 64) keep.shift(); + if ((i & 3) === 0) { + // A fresh OBJECT per push, not an unboxed number: the nursery has to + // actually fill and relocate for this test to mean anything. Measured + // copied=0 when this pushed plain numbers -- the collector never moved, + // so the differential was nearly vacuous as a GC test. + keep.push({ v: x, tag: "k" + (i & 7) }); + if (keep.length > 96) keep.shift(); } } let t = 0.0; - for (const k of keep) { t = t + k; } + for (const k of keep) { t = t + k.v + k.tag.length; } return s + t; } @@ -189,6 +201,20 @@ fn ta_view_accumulator_unroot_is_gc_correct() { let rooted_bin = compile(dir.path(), Some("0")); let unrooted_bin = compile(dir.path(), None); + // The differential is only meaningful if the two arms actually lower + // DIFFERENTLY. If a future change stops the fixpoint from proving the + // accumulator numeric, both arms become the rooted lowering, their outputs + // agree trivially, and this test passes while covering nothing (CLAUDE.md's + // "the gate runs but its subject never did"). Assert the subject fired. + let rooted_img = std::fs::read(&rooted_bin).expect("read rooted binary"); + let unrooted_img = std::fs::read(&unrooted_bin).expect("read un-rooted binary"); + assert_ne!( + rooted_img, unrooted_img, + "PERRY_NUMBER_BY_CONSTRUCTION=0 and the default produced byte-identical \ + binaries — the #8619 un-rooting did not fire, so the differential below \ + is vacuous. Fix the fixture or the fixpoint, do not delete this assert." + ); + let rooted_out = run_arms(&rooted_bin, dir.path(), "rooted"); let unrooted_out = run_arms(&unrooted_bin, dir.path(), "unrooted");