From 52a5ac4bb5b086c669df903efce30b2df4e04dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 20:56:56 +0200 Subject: [PATCH 01/11] fix(gc): pace PERRY_GC_ZEAL by allocation instead of collecting at every poll Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- crates/perry-runtime/src/gc/mod.rs | 2 +- crates/perry-runtime/src/gc/policy.rs | 53 +++++- .../src/gc/tests/fromspace_protect.rs | 156 +++++++++++++++ crates/perry-runtime/src/gc/zeal.rs | 177 +++++++++++++++++- 4 files changed, 373 insertions(+), 15 deletions(-) diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 19adab65e2..555a1c899f 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -136,7 +136,7 @@ mod zeal; pub use verify::*; pub use zeal::{ copying_minor_cycles, loop_polls_reached, moved_objects_total, zeal_forced_collections, - zeal_liveness_report, + zeal_liveness_report, zeal_polls_paced, }; pub(crate) use zeal::{gc_zeal_enabled, note_loop_poll_reached, note_zeal_forced_collection}; #[cfg(feature = "diagnostics")] diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 1451183889..bbb3a39051 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -584,6 +584,27 @@ impl Drop for LegacyGcPacingGuard { } } +/// Pin moving-loop polls ON for the duration of the returned guard, so a test +/// can drive `js_gc_loop_safepoint` without depending on the process-wide +/// default (which the `OnceLock` fixes from the environment once per test +/// binary — exactly the ambient dependency #7726's own regression hid behind). +#[cfg(test)] +pub(super) struct MovingLoopPollsGuard(Option); + +#[cfg(test)] +impl MovingLoopPollsGuard { + pub(super) fn on() -> Self { + Self(GC_MOVING_LOOP_POLLS_TEST_OVERRIDE.with(|cell| cell.replace(Some(true)))) + } +} + +#[cfg(test)] +impl Drop for MovingLoopPollsGuard { + fn drop(&mut self) { + GC_MOVING_LOOP_POLLS_TEST_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + /// Pin legacy GC pacing (moving-loop polls OFF) for the duration of the returned /// guard. See [`LegacyGcPacingGuard`] and [`gc_moving_loop_polls_enabled`]. #[cfg(test)] @@ -2385,15 +2406,33 @@ pub extern "C" fn js_gc_loop_safepoint() { // #7604: the only reliable answer to "did the compile-time half take // effect". Past the opt-in, so a default binary never touches it. super::note_loop_poll_reached(); - // Zeal (#7154 tooling) collects at EVERY poll, not only when the alloc-point - // arm already deferred one. Zeal cannot conjure a poll codegen never emitted, - // so the `gc_moving_loop_polls_enabled()` gate above still applies — see - // `gc/zeal.rs` for why that means "compile AND run with - // `PERRY_GC_MOVING_LOOP_POLLS=1`". - if !GC_SAFEPOINT_PENDING.with(Cell::get) && !super::gc_zeal_enabled() { - return; + // Zeal (#7154 tooling) collects at polls the deferral flag would skip, not + // only when the alloc-point arm already deferred one. Zeal cannot conjure a + // poll codegen never emitted, so the `gc_moving_loop_polls_enabled()` gate + // above still applies — see `gc/zeal.rs` for why that means "compile AND run + // with `PERRY_GC_MOVING_LOOP_POLLS=1`". + // + // ★ #7726: "at polls", not "at EVERY poll". Unpaced, this arm cost ~511 µs + // per loop iteration to relocate a mean of 5.9 objects, which made zeal + // unusable on any real workload the moment #7721 turned back-edge polls on + // by default (24 minutes for a 19 s program). The stride is a bound on + // forced collections, not a heuristic — see `gc/zeal.rs`. + let zeal = super::gc_zeal_enabled(); + if !GC_SAFEPOINT_PENDING.with(Cell::get) { + if !zeal { + return; + } + if !super::zeal::zeal_poll_collection_due(crate::arena::copying_from_space_in_use_bytes()) { + super::zeal::note_zeal_poll_paced(); + return; + } } gc_safepoint_moving_minor(); + if zeal { + // Rearm from the level AFTER the collection, so the next forced one + // needs a full stride of new allocation on top of the survivors. + super::zeal::note_zeal_poll_collection(crate::arena::copying_from_space_in_use_bytes()); + } } struct BudgetedGcStepGuard; diff --git a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs index ebaae098f2..208caa393f 100644 --- a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs +++ b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs @@ -372,6 +372,162 @@ fn zeal_collects_at_a_safepoint_with_no_pressure_due() { ); } +// --------------------------------------------------------------------------- +// Zeal pacing (#7726). Zeal used to force a collection at EVERY back-edge poll. +// That was affordable only while the polls themselves were a compile-time +// opt-in nobody took; #7721 made them default-ON and the same instrument became +// ~511 us of fixed collection cost per loop iteration — 24 minutes for a 19 s +// program, which is an instrument nobody switches on. +// +// Both directions are asserted, per the kill-policy: the stride must BOUND the +// forced collections, and `=0` must still give the literal every-poll mode. +// --------------------------------------------------------------------------- + +#[test] +fn zeal_alloc_stride_knob_parses_both_states() { + use super::super::zeal::parse_zeal_alloc_kb; + // Default when unset or unparseable — a typo must not silently select the + // unusable every-poll mode. + assert_eq!(parse_zeal_alloc_kb(None), 4096); + assert_eq!(parse_zeal_alloc_kb(Some("banana")), 4096); + assert_eq!(parse_zeal_alloc_kb(Some("")), 4096); + // 0 is MEANINGFUL, not garbage: it restores pre-#7726 every-poll zeal. + assert_eq!(parse_zeal_alloc_kb(Some("0")), 0); + assert_eq!(parse_zeal_alloc_kb(Some("16")), 16 * 1024); + assert_eq!(parse_zeal_alloc_kb(Some(" 64 ")), 64 * 1024); +} + +/// ★ The regression test for #7726, and the one that would have caught it. +/// +/// Drives a hot poll loop — the shape of every real workload under zeal — and +/// asserts the forced collections are BOUNDED well below the poll count. Before +/// the fix this ratio was exactly 1.0 (70,968 forced collections for 70,963 +/// polls on the measured workload), so this assertion fails on the old code. +/// +/// It is paired with two liveness assertions, because "fast" is trivially +/// achievable by collecting nothing and that would be a worse regression than +/// the one being fixed (CLAUDE.md, four ways a gate cannot fail — #4): the run +/// must still force collections, and those collections must still MOVE objects. +#[test] +fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _polls = super::super::policy::MovingLoopPollsGuard::on(); + let _zeal = super::super::zeal::ZealGuard::set(true); + let _stride = super::super::zeal::ZealStrideGuard::set(4096); + super::super::zeal::reset_zeal_pacing_for_test(); + + const POLLS: u64 = 2_000; + let forced_before = zeal_forced_collections(); + let moved_before = moved_objects_total(); + let paced_before = zeal_polls_paced(); + + for _ in 0..POLLS { + // Allocate, root it, then poll — a loop body that produces new nursery + // material every iteration, which is what makes the unpaced instrument + // collect every time. + let leaf = young_leaf(); + js_shadow_slot_set(0, string_bits(leaf)); + js_gc_loop_safepoint(); + } + + let forced = zeal_forced_collections() - forced_before; + let moved = moved_objects_total() - moved_before; + let paced = zeal_polls_paced() - paced_before; + + // THE BOUND. Each `young_leaf` is a few tens of bytes, so 2000 of them is + // well under 200 KB; at a 4 KB stride that is a few dozen collections, not + // 2000. A generous ceiling keeps this from being an allocator-size test + // while still failing loudly on the pre-fix 1:1 behaviour. + assert!( + forced < POLLS / 4, + "zeal must PACE its forced collections: {forced} forced for {POLLS} polls \ + (pre-#7726 this was 1:1, which cost 24 minutes on a 19 s program)" + ); + assert_eq!( + forced + paced, + POLLS, + "every poll must be accounted for as either forced or paced \ + (forced={forced} paced={paced})" + ); + + // LIVENESS 1: pacing must not have turned zeal off. A run that forces zero + // collections is the vacuous-green shape, not a fix. + assert!( + forced > 0, + "zeal must still force collections — a paced instrument that never \ + collects is a worse regression than the slow one it replaced" + ); + // LIVENESS 2: and those collections must still RELOCATE. Zeal exists to + // make an unrooted value move on its first exposure; a paced minor that + // leaves survivors in place would surface nothing. + assert!( + moved > 0, + "zeal's paced collections must still MOVE survivors (moved={moved})" + ); +} + +/// The OFF state of the pacing knob, per the kill-policy: `=0` must restore the +/// literal every-poll semantics, which is the right setting for a small fixture +/// (`gc_instrument_smoke.sh` pins it) or a window executed exactly once. +#[test] +fn zeal_alloc_stride_zero_restores_every_poll_collection() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _polls = super::super::policy::MovingLoopPollsGuard::on(); + let _zeal = super::super::zeal::ZealGuard::set(true); + let _stride = super::super::zeal::ZealStrideGuard::set(0); + super::super::zeal::reset_zeal_pacing_for_test(); + + const POLLS: u64 = 32; + let forced_before = zeal_forced_collections(); + for _ in 0..POLLS { + let leaf = young_leaf(); + js_shadow_slot_set(0, string_bits(leaf)); + js_gc_loop_safepoint(); + } + assert_eq!( + zeal_forced_collections() - forced_before, + POLLS, + "PERRY_GC_ZEAL_ALLOC_KB=0 must collect at EVERY poll — that escape \ + hatch is what a once-executed bug window needs" + ); +} + +/// The pacing is a monotone high-water mark, not a "bytes since last time" +/// delta, and the difference is the whole bound. If a forced collection +/// reclaims nothing — an escalation to a non-moving full mark-sweep, which +/// #7592 and #7682 both produced in the field — a delta-based pacer would find +/// the threshold still met and collect again at the very next poll, restoring +/// the livelock it was meant to remove. Rearming from the level measured AFTER +/// the collection makes the next one cost a full stride of genuinely new +/// allocation no matter what the collector managed to free. +#[test] +fn zeal_pacing_rearms_above_survivors_so_a_useless_collection_cannot_loop() { + use super::super::zeal::{ + note_zeal_poll_collection, reset_zeal_pacing_for_test, zeal_poll_collection_due, + ZealStrideGuard, + }; + let _stride = ZealStrideGuard::set(4096); + reset_zeal_pacing_for_test(); + + // A collection that freed NOTHING: from-space still holds 1 MB afterwards. + note_zeal_poll_collection(1024 * 1024); + assert!( + !zeal_poll_collection_due(1024 * 1024), + "a collection that reclaimed nothing must NOT be immediately due again \ + — that is the #7592 livelock shape" + ); + assert!( + !zeal_poll_collection_due(1024 * 1024 + 4095), + "still short of one full stride of new allocation" + ); + assert!( + zeal_poll_collection_due(1024 * 1024 + 4096), + "one full stride of NEW material above the survivors makes it due again" + ); +} + /// A zealous minor that leaves survivors in place would move nothing, so it /// could not surface a stale-pointer bug at all. Zeal therefore implies forced /// evacuation, UNCONDITIONALLY. diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index 03d56f451f..e30e70235e 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -51,6 +51,57 @@ //! liveness counter this module exposes was unreadable from a compiled program //! and the documented check could not be performed. //! +//! # Pacing: why "every poll" is not a usable default (#7254, #7726) +//! +//! Point 1 above says "every loop back-edge poll", and until #7726 it meant +//! that literally. That was affordable only for as long as back-edge polls were +//! a compile-time opt-in nobody took: with `PERRY_GC_MOVING_LOOP_POLLS` default +//! OFF (#7161), a compute-only program reached **no** loop safepoint, so zeal +//! forced nothing, cost nothing — and proved nothing. #7721 made the poll +//! default ON, which is a large throughput win for the collector and turned +//! zeal from free-and-vacuous into correct-but-unusable in the same commit. +//! +//! Measured on the pinned quiet host, a tree-walking-interpreter workload +//! (`iso_miss.ts`, 19 s without zeal): +//! +//! | rounds | polls | forced collections | wall | +//! |---|--:|--:|--:| +//! | 1 | 70,963 | 70,968 | 36.3 s | +//! | 2 | 141,926 | 141,931 | 72.7 s | +//! | 40 (extrapolated, linear) | ~2.84 M | ~2.84 M | ~24 min | +//! +//! Perfectly linear — this was never a livelock, it was ~511 µs of fixed +//! per-collection cost (root scan over the shadow stack plus ~55 side-table +//! scanners) paid once per loop iteration, to relocate a mean of **5.9 +//! objects**. Practically all of that work is the collection's fixed overhead, +//! not the relocation zeal exists to stress. An instrument whose smallest +//! honest run takes 24 minutes is an instrument nobody switches on, and #7254 +//! had already logged "a striking concentration of multi-minute-plus runs" +//! under this pairing without triaging it. +//! +//! So zeal is **allocation-paced**: it forces a collection at the first poll at +//! which `PERRY_GC_ZEAL_ALLOC_KB` of new nursery material has accumulated since +//! the last one (default 4 KB — roughly a nursery block's worth of objects, and +//! ~1/4000th of the 16 MB cap the ordinary scavenge trigger uses). This is V8's +//! `--gc-interval` model and SpiderMonkey's `gcZeal(mode, frequency)`, both of +//! which pace for the same reason. +//! +//! **`PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics**, and +//! that is the right setting for a small fixture (`gc_instrument_smoke.sh` pins +//! it) or for a window executed only once. What pacing gives up is precisely +//! that: a bug window crossed a single time may now sit between two forced +//! collections. A window that *recurs* — every shape in the #7154 family, which +//! is why the reproducers are loops — is still caught, just after N KB of +//! allocation rather than on the first iteration. +//! +//! The pacing is a **monotone** high-water mark, not a "bytes since" delta: +//! each forced collection rearms to `from_space_after + stride`, so a collection +//! that reclaims nothing (an escalation to a non-moving full mark-sweep) still +//! demands another `stride` bytes of real allocation before the next one. Total +//! forced collections are therefore bounded by `bytes_allocated / stride` +//! whatever the collector does with them — the property that makes the fix a +//! bound rather than a hope, and the one #7592's livelock lacked. +//! //! # Why there is no allocation-point level //! //! An obvious `PERRY_GC_ZEAL=2` would collect at every allocation. It was @@ -116,6 +167,113 @@ pub(crate) fn note_zeal_forced_collection() { ZEAL_FORCED.fetch_add(1, Ordering::Relaxed); } +// ------------------------------------------------------------- pacing (#7726) + +/// Default stride: 4 KB of new nursery material between zeal-forced collections. +/// +/// Chosen against both ends of the range this has to serve, measured rather +/// than picked: it keeps `gc_instrument_smoke.sh`'s ~80 KB fixture at ~20 +/// forced collections (the gate asserts zeal collects strictly more often than +/// pressure alone, so a stride that starved it would turn a real gate vacuous), +/// while bounding a 19 s interpreter workload to a few tens of thousands of +/// collections instead of 2.84 million. +const ZEAL_DEFAULT_STRIDE_BYTES: usize = 4 * 1024; + +/// Pure knob parse for `PERRY_GC_ZEAL_ALLOC_KB`, in KB. `Some(0)` is a +/// deliberate, meaningful value — "collect at every poll", the pre-#7726 +/// semantics — so it must not be filtered out the way a nonsense value is. +pub(crate) fn parse_zeal_alloc_kb(raw: Option<&str>) -> usize { + raw.and_then(|s| s.trim().parse::().ok()) + .map(|kb| kb.saturating_mul(1024)) + .unwrap_or(ZEAL_DEFAULT_STRIDE_BYTES) +} + +/// Bytes of new nursery material required between zeal-forced collections. +pub(crate) fn zeal_poll_stride_bytes() -> usize { + #[cfg(test)] + if let Some(stride) = ZEAL_STRIDE_OVERRIDE.with(std::cell::Cell::get) { + return stride; + } + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + parse_zeal_alloc_kb(std::env::var("PERRY_GC_ZEAL_ALLOC_KB").ok().as_deref()) + }) +} + +#[cfg(test)] +thread_local! { + /// Test-only stride override, thread-local for the same reason `ZEAL_OVERRIDE` is. + static ZEAL_STRIDE_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// RAII test override for the pacing stride. +#[cfg(test)] +pub(crate) struct ZealStrideGuard(Option); + +#[cfg(test)] +impl ZealStrideGuard { + pub(crate) fn set(stride_bytes: usize) -> Self { + Self(ZEAL_STRIDE_OVERRIDE.with(|cell| cell.replace(Some(stride_bytes)))) + } +} + +#[cfg(test)] +impl Drop for ZealStrideGuard { + fn drop(&mut self) { + ZEAL_STRIDE_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + +thread_local! { + /// From-space high-water mark at or above which the next zeal-forced + /// collection is due. Per-thread because the arena it measures is. + /// + /// Starts at 0 so the FIRST poll always collects: a program that allocates + /// less than one stride in total must still exercise the instrument rather + /// than silently becoming a run in which zeal did nothing. + static ZEAL_NEXT_FORCE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Is a zeal-forced collection due at this poll, given current from-space bytes? +#[inline] +pub(crate) fn zeal_poll_collection_due(from_space_bytes: usize) -> bool { + from_space_bytes >= ZEAL_NEXT_FORCE_BYTES.with(std::cell::Cell::get) +} + +/// Rearm the pacing high-water mark after a zeal poll ran the safepoint. +/// +/// Takes the from-space level measured *after* the collection, so the next +/// forced collection needs a full stride of genuinely new allocation on top of +/// whatever survived. See the module docs for why this is a high-water mark +/// rather than a delta. +#[inline] +pub(crate) fn note_zeal_poll_collection(from_space_bytes_after: usize) { + let next = from_space_bytes_after.saturating_add(zeal_poll_stride_bytes()); + ZEAL_NEXT_FORCE_BYTES.with(|cell| cell.set(next)); +} + +/// Polls at which zeal declined to collect because the stride was not yet met. +static ZEAL_POLLS_PACED: AtomicU64 = AtomicU64::new(0); + +#[inline] +pub(crate) fn note_zeal_poll_paced() { + ZEAL_POLLS_PACED.fetch_add(1, Ordering::Relaxed); +} + +/// How many back-edge polls the pacing skipped. Reported in the zeal verdict so +/// a run states its own pacing rather than leaving the operator to infer it +/// from a collection count that looks lower than it "should" be. +pub fn zeal_polls_paced() -> u64 { + ZEAL_POLLS_PACED.load(Ordering::Relaxed) +} + +#[cfg(test)] +pub(crate) fn reset_zeal_pacing_for_test() { + ZEAL_NEXT_FORCE_BYTES.with(|cell| cell.set(0)); +} + /// How many collections zeal has forced. A zeal run that reports `0` here /// exercised nothing (most often: the binary was compiled without /// `PERRY_GC_MOVING_LOOP_POLLS=1` and the workload never reached the event @@ -206,6 +364,8 @@ pub fn zeal_liveness_report() -> Option> { moved_objects_total(), loop_polls_reached(), super::policy::gc_moving_loop_polls_enabled(), + zeal_polls_paced(), + zeal_poll_stride_bytes(), )) } @@ -224,10 +384,13 @@ pub(crate) fn zeal_verdict( moved: u64, loop_polls: u64, polls_requested: bool, + paced_polls: u64, + stride_bytes: usize, ) -> Result { let summary = format!( "[gc-zeal] forced_collections={forced} copying_minors={cycles} \ - moved_objects={moved} loop_polls={loop_polls}" + moved_objects={moved} loop_polls={loop_polls} \ + paced_polls={paced_polls} stride_bytes={stride_bytes}" ); let cause = if forced == 0 { Some("no safepoint ever forced a collection") @@ -275,14 +438,14 @@ mod verdict_tests { /// different causes and the message has to name the right one. #[test] fn a_zeal_run_that_exercised_nothing_is_an_error() { - let no_safepoint = zeal_verdict(0, 0, 0, 0, false).expect_err("forced=0 must be an error"); + let no_safepoint = zeal_verdict(0, 0, 0, 0, false, 0, 4096).expect_err("forced=0 must be an error"); assert!(no_safepoint.contains("no safepoint ever forced a collection")); // Zeal DID force collections and every one was escalated to a full // mark-sweep, which moves nothing. `forced > 0` alone would have called // this run live. let all_escalated = - zeal_verdict(4096, 0, 0, 4096, true).expect_err("cycles=0 must be an error"); + zeal_verdict(4096, 0, 0, 4096, true, 0, 4096).expect_err("cycles=0 must be an error"); assert!(all_escalated.contains("escalated to a non-moving full")); assert!(all_escalated.contains("copying_minors=0")); } @@ -296,7 +459,7 @@ mod verdict_tests { /// `loop_polls` says "live"; no loop body was covered at all. #[test] fn polls_requested_but_never_reached_is_an_error() { - let armed_never_fired = zeal_verdict(5, 5, 4, 0, true) + let armed_never_fired = zeal_verdict(5, 5, 4, 0, true, 0, 4096) .expect_err("polls requested and none reached must be an error"); assert!(armed_never_fired.contains("NOT ONE back-edge poll")); assert!(armed_never_fired.contains("loop_polls=0")); @@ -304,13 +467,13 @@ mod verdict_tests { // ...and the SAME counters without the request are fine: an // event-loop-boundary zeal run is a legitimate, weaker mode, and // failing it would make the verdict wrong rather than strict. - assert!(zeal_verdict(5, 5, 4, 0, false).is_ok()); + assert!(zeal_verdict(5, 5, 4, 0, false, 0, 4096).is_ok()); } /// ...and YES, with the numbers, when it did fire. #[test] fn a_zeal_run_that_moved_objects_is_reported_ok() { - let ok = zeal_verdict(741_630, 741_630, 8_899_560, 741_630, true) + let ok = zeal_verdict(741_630, 741_630, 8_899_560, 741_630, true, 0, 4096) .expect("a moving run must pass"); assert!(ok.contains("forced_collections=741630")); assert!(ok.contains("copying_minors=741630")); @@ -325,6 +488,6 @@ mod verdict_tests { /// argue with a test. #[test] fn a_copying_minor_with_no_survivors_is_not_a_failure() { - assert!(zeal_verdict(1, 1, 0, 1, true).is_ok()); + assert!(zeal_verdict(1, 1, 0, 1, true, 0, 4096).is_ok()); } } From a357aba68f2b25515586f471b1789bea5300b704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:04:38 +0200 Subject: [PATCH 02/11] gate(gc): budgeted zeal-termination arm, docs, and renumber to #7728 Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- CLAUDE.md | 5 +- crates/perry-runtime/src/gc/policy.rs | 4 +- .../src/gc/tests/fromspace_protect.rs | 8 +- crates/perry-runtime/src/gc/zeal.rs | 11 +- scripts/gc_instrument_smoke.sh | 100 ++++++++++++++++++ 5 files changed, 115 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33850c1792..ba74258fb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,10 +140,11 @@ A "GC value live but not rooted across a collection point" bug is invisible at c |---|---|---| | `PERRY_GC_PROTECT_FROMSPACE=1` (or `poison`) | the from-space reset performed by the **copying minor** (`arena::copying_reset_from_spaces_and_flip`). Retired Eden + active-survivor blocks are detached into a bounded quarantine, poison-filled (`0xDEADBEEFBAADF0DE`, `obj_type = 0xDE`) and, at `=1`, `mprotect(PROT_NONE)`d. A stale deref then SIGSEGVs at the faulting instruction; the installed reporter names the address, the retiring minor, and the last-known object's `obj_type`/size, then restores `SIG_DFL` and re-faults so a core/debugger still sees the real site. `poison` skips `mprotect`. | change the non-moving minor's `arena_reset_empty_blocks`, the full mark-sweep's reclaim, old-gen defrag, or the malloc sweep. **A run with zero copying minors protects nothing** — check that `PERRY_GC_DIAG=1` prints a `[gc-fromspace-protect] retired_set=#N` line. | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` (default 4) | how many retired page-sets stay quarantined. Evicted sets are restored to RW and **recycled back into Eden**, never `dealloc`'d, so footprint is bounded at `N × from-space bytes`. `0` is clamped to 1 — a depth of 0 would read as ON and protect nothing. **Raise this when a suspected bug does not fault**: a value can cross hundreds of collections between its last valid observation and its stale use (one per back-edge poll under zeal). #7154's `new C(…)` reproducer needs `800` — its constructor crosses 600 polls, so the default 4 misses it silently. | — | -| `PERRY_GC_ZEAL=1` | forces an evacuating minor at every **GC safepoint**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it emit loop polls — those need the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161), and even then codegen emits **no poll** for a provably alloc-free loop body (by design, `loop_purity::loop_may_allocate`) nor for the specialized `for` / `for-of` / `for-in` lowerings (by omission — see `emit_gc_loop_safepoint`'s COVERAGE note). Zeal on a poll-free binary only fires at event-loop boundaries; a compute-only loop never collects. **You no longer have to remember to check this**: since #7604 a zeal run prints `[gc-zeal] forced_collections=N copying_minors=M moved_objects=K` at exit and **exits 70** if N or M is zero, so a run that exercised nothing is a red run rather than a green one. (`process.exit()` and an uncaught throw bypass the exit boundary and get no verdict.) There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. | +| `PERRY_GC_ZEAL=1` | forces an evacuating minor at **GC safepoints**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. **Allocation-PACED since #7728** — see the row below; it used to collect at every single poll, which cost 24 minutes on a 19 s program once #7721 made polls default-ON. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it emit loop polls — those come from the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS` (**default ON since #7721**; it was off from #7161 until then, which is why zeal used to look free — it was collecting nothing), and even then codegen emits **no poll** for a provably alloc-free loop body (by design, `loop_purity::loop_may_allocate`) nor for the specialized `for` / `for-of` / `for-in` lowerings (by omission — see `emit_gc_loop_safepoint`'s COVERAGE note). Zeal on a poll-free binary only fires at event-loop boundaries; a compute-only loop never collects. **You no longer have to remember to check this**: since #7604 a zeal run prints `[gc-zeal] forced_collections=N copying_minors=M moved_objects=K loop_polls=P paced_polls=Q stride_bytes=S` at exit and **exits 70** if N or M is zero, so a run that exercised nothing is a red run rather than a green one. (`process.exit()` and an uncaught throw bypass the exit boundary and get no verdict.) There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. | +| `PERRY_GC_ZEAL_ALLOC_KB=N` (default 4) | how much NEW nursery material must accumulate between zeal-forced collections. Zeal's cost is ~511 us of fixed root-scan per collection to relocate a mean of 5.9 objects, so unpaced "every back-edge poll" is one whole collection per loop iteration. The stride is a monotone high-water mark (rearmed to `from_space_after + N`), so total forced collections are bounded by `bytes_allocated / N` even when a collection reclaims nothing. **`=0` restores the literal every-poll mode** — use it for a small fixture, or for a bug window executed exactly once. | change WHICH safepoints are eligible, or weaken evacuation: a paced collection is the same collection, just less often. A recurring window is still caught, after N KB of allocation rather than on the first iteration. | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | now **implies** `PERRY_GC_FROMSPACE_SCAN=1`. It used to be inert alone (the scan never ran, so nothing aborted, and the run reported success). | — | -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage. +`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Loop polls are default-ON since #7721, so in-loop coverage no longer needs a flag — check `loop_polls=` in the exit verdict rather than assuming. If a hunt needs maximum sensitivity on a small program, add `PERRY_GC_ZEAL_ALLOC_KB=0`. ### GC knob kill-policy (binding) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index bbb3a39051..fcbe22ba94 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -587,7 +587,7 @@ impl Drop for LegacyGcPacingGuard { /// Pin moving-loop polls ON for the duration of the returned guard, so a test /// can drive `js_gc_loop_safepoint` without depending on the process-wide /// default (which the `OnceLock` fixes from the environment once per test -/// binary — exactly the ambient dependency #7726's own regression hid behind). +/// binary — exactly the ambient dependency #7728's own regression hid behind). #[cfg(test)] pub(super) struct MovingLoopPollsGuard(Option); @@ -2412,7 +2412,7 @@ pub extern "C" fn js_gc_loop_safepoint() { // above still applies — see `gc/zeal.rs` for why that means "compile AND run // with `PERRY_GC_MOVING_LOOP_POLLS=1`". // - // ★ #7726: "at polls", not "at EVERY poll". Unpaced, this arm cost ~511 µs + // ★ #7728: "at polls", not "at EVERY poll". Unpaced, this arm cost ~511 µs // per loop iteration to relocate a mean of 5.9 objects, which made zeal // unusable on any real workload the moment #7721 turned back-edge polls on // by default (24 minutes for a 19 s program). The stride is a bound on diff --git a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs index 208caa393f..ac5035a7ae 100644 --- a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs +++ b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs @@ -373,7 +373,7 @@ fn zeal_collects_at_a_safepoint_with_no_pressure_due() { } // --------------------------------------------------------------------------- -// Zeal pacing (#7726). Zeal used to force a collection at EVERY back-edge poll. +// Zeal pacing (#7728). Zeal used to force a collection at EVERY back-edge poll. // That was affordable only while the polls themselves were a compile-time // opt-in nobody took; #7721 made them default-ON and the same instrument became // ~511 us of fixed collection cost per loop iteration — 24 minutes for a 19 s @@ -391,13 +391,13 @@ fn zeal_alloc_stride_knob_parses_both_states() { assert_eq!(parse_zeal_alloc_kb(None), 4096); assert_eq!(parse_zeal_alloc_kb(Some("banana")), 4096); assert_eq!(parse_zeal_alloc_kb(Some("")), 4096); - // 0 is MEANINGFUL, not garbage: it restores pre-#7726 every-poll zeal. + // 0 is MEANINGFUL, not garbage: it restores pre-#7728 every-poll zeal. assert_eq!(parse_zeal_alloc_kb(Some("0")), 0); assert_eq!(parse_zeal_alloc_kb(Some("16")), 16 * 1024); assert_eq!(parse_zeal_alloc_kb(Some(" 64 ")), 64 * 1024); } -/// ★ The regression test for #7726, and the one that would have caught it. +/// ★ The regression test for #7728, and the one that would have caught it. /// /// Drives a hot poll loop — the shape of every real workload under zeal — and /// asserts the forced collections are BOUNDED well below the poll count. Before @@ -442,7 +442,7 @@ fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { assert!( forced < POLLS / 4, "zeal must PACE its forced collections: {forced} forced for {POLLS} polls \ - (pre-#7726 this was 1:1, which cost 24 minutes on a 19 s program)" + (pre-#7728 this was 1:1, which cost 24 minutes on a 19 s program)" ); assert_eq!( forced + paced, diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index e30e70235e..d1584b35ae 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -51,9 +51,9 @@ //! liveness counter this module exposes was unreadable from a compiled program //! and the documented check could not be performed. //! -//! # Pacing: why "every poll" is not a usable default (#7254, #7726) +//! # Pacing: why "every poll" is not a usable default (#7254, #7728) //! -//! Point 1 above says "every loop back-edge poll", and until #7726 it meant +//! Point 1 above says "every loop back-edge poll", and until #7728 it meant //! that literally. That was affordable only for as long as back-edge polls were //! a compile-time opt-in nobody took: with `PERRY_GC_MOVING_LOOP_POLLS` default //! OFF (#7161), a compute-only program reached **no** loop safepoint, so zeal @@ -167,7 +167,7 @@ pub(crate) fn note_zeal_forced_collection() { ZEAL_FORCED.fetch_add(1, Ordering::Relaxed); } -// ------------------------------------------------------------- pacing (#7726) +// ------------------------------------------------------------- pacing (#7728) /// Default stride: 4 KB of new nursery material between zeal-forced collections. /// @@ -180,7 +180,7 @@ pub(crate) fn note_zeal_forced_collection() { const ZEAL_DEFAULT_STRIDE_BYTES: usize = 4 * 1024; /// Pure knob parse for `PERRY_GC_ZEAL_ALLOC_KB`, in KB. `Some(0)` is a -/// deliberate, meaningful value — "collect at every poll", the pre-#7726 +/// deliberate, meaningful value — "collect at every poll", the pre-#7728 /// semantics — so it must not be filtered out the way a nonsense value is. pub(crate) fn parse_zeal_alloc_kb(raw: Option<&str>) -> usize { raw.and_then(|s| s.trim().parse::().ok()) @@ -438,7 +438,8 @@ mod verdict_tests { /// different causes and the message has to name the right one. #[test] fn a_zeal_run_that_exercised_nothing_is_an_error() { - let no_safepoint = zeal_verdict(0, 0, 0, 0, false, 0, 4096).expect_err("forced=0 must be an error"); + let no_safepoint = + zeal_verdict(0, 0, 0, 0, false, 0, 4096).expect_err("forced=0 must be an error"); assert!(no_safepoint.contains("no safepoint ever forced a collection")); // Zeal DID force collections and every one was escalated to a full diff --git a/scripts/gc_instrument_smoke.sh b/scripts/gc_instrument_smoke.sh index 93ff9e17a0..91239b974e 100755 --- a/scripts/gc_instrument_smoke.sh +++ b/scripts/gc_instrument_smoke.sh @@ -38,6 +38,14 @@ PERRY_BIN="$(cd "$(dirname "$PERRY_BIN")" && pwd)/$(basename "$PERRY_BIN")" export PERRY_RUNTIME_DIR="${PERRY_RUNTIME_DIR:-$(dirname "$PERRY_BIN")}" export PERRY_NO_AUTO_OPTIMIZE=1 +# Arms 1-3 and 5 drive the SMALL fixture below, which is sized so that literal +# every-poll zeal costs seconds. Pin the strongest semantics for them +# explicitly (#7728 made the shipped default allocation-paced), so this gate +# keeps testing "a collection at every single poll" rather than silently +# following whatever the default becomes. Arm 6 is the one that asserts the +# DEFAULT is usable, and it deliberately does not set this. +export PERRY_GC_ZEAL_ALLOC_KB=0 + WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -280,9 +288,101 @@ if ! grep -q 'stale forwarded pointer' <<<"$repro_out"; then fi echo " reproduced as pinned (exit $repro_rc, stale forwarded pointer) -- #7254 still open, tracked not silent" +# ---- arm 6: zeal must TERMINATE at the shipped default (#7728) -------------- +# +# Every arm above runs the ~1200-poll fixture, which is deliberately tiny +# ("costs seconds rather than minutes"). That sizing is exactly why this gate +# could not see #7728: zeal forced a collection at EVERY back-edge poll, and on +# a small enough fixture that is indistinguishable from a paced instrument. The +# moment #7721 turned back-edge polls on by default, the same instrument cost +# ~511 us per loop iteration on real code -- 24 minutes for a 19 s program, i.e. +# an instrument nobody can switch on, with nothing in CI to say so. +# +# So this arm is the one that is allowed to be slow-ish and is BUDGETED. It runs +# a workload with a realistic poll count at the DEFAULT stride (note the +# explicit unset -- the export at the top of this file pins every-poll mode for +# the small fixture, which would defeat the whole point here) and requires +# correct output inside a wall-clock budget. Unpaced, this fixture takes ~200 s; +# paced, a few. The budget sits between those, so the arm fails on a regression +# rather than merely getting slower. +echo +echo "== arm 6: zeal terminates at the DEFAULT stride, on a realistic poll count ==" + +cat > "$WORK/scale.ts" <<'TS' +function run(n: number): number { + let acc = 0; + let i = 0; + while (i < n) { + const rec = { a: i, b: "v", c: i + 1 }; + acc = acc + (rec.c as number) - (rec.a as number); + i = i + 1; + } + return acc; +} +console.log("sum", run(400000)); +TS + +"$PERRY_BIN" compile "$WORK/scale.ts" -o "$WORK/scale" >/dev/null + +ZEAL_BUDGET_S="${PERRY_ZEAL_SMOKE_BUDGET_S:-60}" +scale_start=$(date +%s) +set +e +# `env -u` so the every-poll pin from the top of the file does NOT apply: this +# arm's entire subject is the SHIPPED DEFAULT. +scale_out="$(env -u PERRY_GC_ZEAL_ALLOC_KB PERRY_GC_ZEAL=1 \ + perl -e 'alarm shift; exec @ARGV' "$ZEAL_BUDGET_S" "$WORK/scale" 2>&1)" +scale_rc=$? +set -e +scale_elapsed=$(( $(date +%s) - scale_start )) + +if [[ $scale_rc -ne 0 ]]; then + echo "FAIL [arm6]: zeal did not complete in ${ZEAL_BUDGET_S}s (exit $scale_rc," >&2 + echo " elapsed ${scale_elapsed}s). PERRY_GC_ZEAL is the primary instrument" >&2 + echo " for moving-GC correctness bugs; one that does not terminate is one" >&2 + echo " nobody will use. This is #7728's shape: a forced collection at" >&2 + echo " EVERY back-edge poll, ~511 us each, once polls became default-ON." >&2 + echo "$scale_out" | tail -20 >&2 + exit 1 +fi +if ! grep -q '^sum 400000$' <<<"$scale_out"; then + echo "FAIL [arm6]: wrong answer under zeal at the default stride:" >&2 + grep '^sum' <<<"$scale_out" >&2 || echo "(no 'sum' line)" >&2 + exit 1 +fi + +# NON-VACUITY. A fast arm proves nothing unless zeal actually collected and +# actually moved -- "fast because it collects nothing" would be a worse +# regression than the slow instrument it replaced. +zeal_line="$(grep -m1 '^\[gc-zeal\] forced_collections=' <<<"$scale_out" || true)" +if [[ -z "$zeal_line" ]]; then + echo "FAIL [arm6]: no [gc-zeal] verdict line -- cannot tell whether zeal ran." >&2 + exit 1 +fi +scale_forced="$(grep -oE 'forced_collections=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" +scale_minors="$(grep -oE 'copying_minors=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" +scale_moved="$(grep -oE 'moved_objects=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" +scale_polls="$(grep -oE 'loop_polls=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" +if [[ "$scale_forced" -eq 0 || "$scale_minors" -eq 0 || "$scale_moved" -eq 0 ]]; then + echo "FAIL [arm6]: zeal finished fast because it did NOTHING" >&2 + echo " ($zeal_line)." >&2 + echo " Pacing must bound the instrument, not disable it." >&2 + exit 1 +fi +# ...and the pacing must genuinely be pacing: far fewer collections than polls. +# Pre-#7728 this ratio was 1:1, which is the regression itself. +if [[ "$scale_polls" -gt 0 && "$scale_forced" -ge "$scale_polls" ]]; then + echo "FAIL [arm6]: zeal forced $scale_forced collections for $scale_polls polls." >&2 + echo " That is the unpaced 1:1 behaviour #7728 removed." >&2 + exit 1 +fi +echo " correct output in ${scale_elapsed}s (budget ${ZEAL_BUDGET_S}s), $zeal_line" + echo echo "PASS: instruments inert when off (0 retirements), live when on" echo " (no-zeal=$nozeal_retired, zeal=$zeal_retired retirements), program correct in all arms." echo " Quarantine clean over $probe_count real probes (allocation-point route)." echo " ZEAL+VERIFY_EVACUATION pairing live and correct on known-good code," echo " and still pins #7254's open reproducer rather than staying silent about it." +echo " Zeal terminates at the shipped default on a realistic poll count" +echo " (${scale_elapsed}s of a ${ZEAL_BUDGET_S}s budget) while still forcing" +echo " $scale_forced collections that moved $scale_moved objects." From fdf90e7cf085e133908afdd9694d4f77d5c6a2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:05:58 +0200 Subject: [PATCH 03/11] docs(changelog): fragment for the zeal allocation pacing (#7729) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- changelog.d/7729-gc-zeal-allocation-pacing.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 changelog.d/7729-gc-zeal-allocation-pacing.md diff --git a/changelog.d/7729-gc-zeal-allocation-pacing.md b/changelog.d/7729-gc-zeal-allocation-pacing.md new file mode 100644 index 0000000000..058227f832 --- /dev/null +++ b/changelog.d/7729-gc-zeal-allocation-pacing.md @@ -0,0 +1,28 @@ +### Fixed + +- **`PERRY_GC_ZEAL=1` terminates again — it is now allocation-paced instead of collecting at every back-edge poll (#7728).** + + Zeal is the primary instrument for moving-GC correctness bugs, and half of the pairing that produced the precise fault behind #7682. It had stopped completing on real workloads: `gc-handoff/apps/iso_miss.ts`, a tree-walking interpreter that runs in 19 s, timed out at 240 s with no output under zeal. + + **It was never a livelock, and the last-known-good build was never good.** Scaling the round count on the pinned quiet host gave 70,968 forced collections in 36.3 s for one round and 141,931 in 72.7 s for two — perfectly linear, so the full workload was ~24 minutes rather than a hang. Zeal forced a collection at *every* back-edge poll: ~511 µs of fixed per-collection cost (root scan over the shadow stack plus ~55 side-table scanners) to relocate a mean of **5.9 objects**. Nearly all of the work was the collection's fixed overhead, not the relocation zeal exists to stress. + + The earlier build that ran "instantly with the correct answer" was **vacuous**. `PERRY_GC_MOVING_LOOP_POLLS` was default-OFF there (#7161), so a compute-only program reached no loop safepoint and zeal forced nothing; that build also predates the #7604 exit verdict, so it exited 0 in silence. #7721 flipped the poll default ON — correctly, it is a large collector win — and in the same commit turned zeal from free-and-vacuous into correct-but-unusable. Isolated rather than assumed: the *old* compiler with `PERRY_GC_MOVING_LOOP_POLLS=1` forced at compile and run time already costs 35.8 s for one round under zeal against 0.62 s without, so no commit broke zeal — zeal was never paced, and the poll default is what exposed it. #7254 had already logged "a striking concentration of multi-minute-plus runs" under this pairing and left the population untriaged; this is that triage. + + Zeal now forces a collection at the first poll at which `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material has accumulated — the model V8 (`--gc-interval`) and SpiderMonkey (`gcZeal(mode, frequency)`) both use, and for the same reason. The stride is a **monotone high-water mark**, not a "bytes since" delta: each forced collection rearms to `from_space_after + stride`, so a collection that reclaims nothing (an escalation to a non-moving full mark-sweep, which #7592 and #7682 both produced in the field) still demands another full stride of genuinely new allocation. Total forced collections are bounded by `bytes_allocated / stride` whatever the collector does with them, which makes this a bound rather than a hope. + + `PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics — the right setting for a small fixture or a bug window executed exactly once. What pacing gives up, stated rather than buried: a window crossed a single time may now fall between two forced collections; a window that *recurs* (every shape in the #7154 family, which is why the reproducers are loops) is still caught, after N KB of allocation instead of on the first iteration. + +### Changed + +- **`scripts/gc_instrument_smoke.sh` gains a budgeted zeal-termination arm, and pins every-poll mode for its existing arms.** + + The gate ran zeal end-to-end and was green throughout. It could not see the regression: its fixture is deliberately sized at ~1200 polls "so the zeal arm costs seconds rather than minutes", and at that size every-poll and paced are indistinguishable. Arm 6 runs a 400k-iteration workload at the *shipped default* (with `env -u` so the file's every-poll pin does not apply) and requires correct output inside a wall-clock budget sitting between the paced cost and the unpaced ~200 s. It asserts non-vacuity too — forced collections, copying minors and moved objects all non-zero, and `forced < loop_polls` — because "fast because it collects nothing" would be a worse regression than the slow instrument it replaced. Arms 1–3 and 5 now set `PERRY_GC_ZEAL_ALLOC_KB=0` explicitly, so they keep testing the strongest semantics rather than silently following whatever the default becomes. + +- **CLAUDE.md's instrument table** documents the pacing knob and loses a stale claim: it still said loop back-edge polls were "default off since #7161", which #7721 had made false. That sentence is why zeal's real cost was invisible. + +### Tests + +- `zeal_pacing_bounds_forced_collections_but_still_moves_objects` — the regression test, in the required `cargo-test` gate. Drives a hot poll loop and asserts forced collections stay bounded well below the poll count, paired with two liveness assertions (collections still forced, survivors still moved). Sabotage-checked: pinning the stride to 0 fails it with `2000 forced for 2000 polls`, exactly the pre-fix ratio. +- `zeal_alloc_stride_zero_restores_every_poll_collection` — the OFF state of the new knob, per the binding GC knob kill-policy. +- `zeal_pacing_rearms_above_survivors_so_a_useless_collection_cannot_loop` — pins the monotone high-water mark against a delta-based rewrite that would restore the livelock shape. +- `zeal_alloc_stride_knob_parses_both_states` — including that `0` is a meaningful value rather than garbage to be defaulted away. From 4c92a9e5e0a56a7b03cad67eec48fda22d7d359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:09:35 +0200 Subject: [PATCH 04/11] refactor(gc): keep the zeal pacing inside the !pending branch so the default poll path is unchanged Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- crates/perry-runtime/src/gc/policy.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index fcbe22ba94..4cb12b9051 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -2417,22 +2417,27 @@ pub extern "C" fn js_gc_loop_safepoint() { // unusable on any real workload the moment #7721 turned back-edge polls on // by default (24 minutes for a 19 s program). The stride is a bound on // forced collections, not a heuristic — see `gc/zeal.rs`. - let zeal = super::gc_zeal_enabled(); + // + // The zeal work all sits inside the `!pending` branch on purpose: a default + // (zeal-off) build reaches exactly the same one cached-bool read and return + // it did before, and the deferral-drain path below is untouched. if !GC_SAFEPOINT_PENDING.with(Cell::get) { - if !zeal { + if !super::gc_zeal_enabled() { return; } if !super::zeal::zeal_poll_collection_due(crate::arena::copying_from_space_in_use_bytes()) { super::zeal::note_zeal_poll_paced(); return; } - } - gc_safepoint_moving_minor(); - if zeal { - // Rearm from the level AFTER the collection, so the next forced one - // needs a full stride of new allocation on top of the survivors. + gc_safepoint_moving_minor(); + // Rearm from the level measured AFTER the collection, so the next + // forced one costs a full stride of new allocation on top of whatever + // survived — see `gc/zeal.rs` for why this is a high-water mark and not + // a delta. super::zeal::note_zeal_poll_collection(crate::arena::copying_from_space_in_use_bytes()); + return; } + gc_safepoint_moving_minor(); } struct BudgetedGcStepGuard; From 5cc2ace0c6086e539dc5e8d9a97479f0e8960a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:10:41 +0200 Subject: [PATCH 05/11] gate(gc): widen the zeal-termination budget and refresh the CI step comment (#7728) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- .github/workflows/test.yml | 5 ++++- scripts/gc_instrument_smoke.sh | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 415b61d8ab..6a0deba4ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1206,7 +1206,10 @@ jobs: # run zero copying minors (the #6942 / #7024 / #7025 failure mode). # The detection property itself is a required-gate unit test: # gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref. - # ~20s: the fixture is sized for ~1200 back-edge polls, not #7154's 240k. + # Arms 1-3/5 use a fixture sized for ~1200 back-edge polls (not #7154's + # 240k), pinned to every-poll zeal. Arm 6 (#7728) is the budgeted one: + # a realistic poll count at the SHIPPED default, which is the axis that + # a ~1200-poll fixture structurally cannot see. - name: GC rooting-bug instruments (inert-when-off, live-when-on) run: ./scripts/gc_instrument_smoke.sh target/release/perry diff --git a/scripts/gc_instrument_smoke.sh b/scripts/gc_instrument_smoke.sh index 91239b974e..38b9c6cf43 100755 --- a/scripts/gc_instrument_smoke.sh +++ b/scripts/gc_instrument_smoke.sh @@ -324,7 +324,10 @@ TS "$PERRY_BIN" compile "$WORK/scale.ts" -o "$WORK/scale" >/dev/null -ZEAL_BUDGET_S="${PERRY_ZEAL_SMOKE_BUDGET_S:-60}" +# 90s, against a paced cost of a few seconds and an unpaced ~200s on the quiet +# host. Wide enough that a slow shared runner does not flake it, narrow enough +# that the unpaced 1:1 behaviour cannot fit inside it. +ZEAL_BUDGET_S="${PERRY_ZEAL_SMOKE_BUDGET_S:-90}" scale_start=$(date +%s) set +e # `env -u` so the every-poll pin from the top of the file does NOT apply: this From ec718203af513438a7aa8893f95cbed33da5eb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:11:25 +0200 Subject: [PATCH 06/11] docs(gc): note zeal's allocation pacing in the memory-model and rooting-invariant pages (#7728) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- docs/src/internals/gc-rooting-invariant.md | 5 ++++- docs/src/internals/memory-model.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/src/internals/gc-rooting-invariant.md b/docs/src/internals/gc-rooting-invariant.md index 989a50702f..4b79fe8d65 100644 --- a/docs/src/internals/gc-rooting-invariant.md +++ b/docs/src/internals/gc-rooting-invariant.md @@ -361,7 +361,10 @@ python3 scripts/gc_root_dominance_check.py .perry-trace/llvm \ From #7196: -- `PERRY_GC_ZEAL=1` — collect at every safepoint. Slow, thorough. +- `PERRY_GC_ZEAL=1` — collect at safepoints, allocation-paced (#7728): one forced + collection per `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material. + Slow, thorough. Add `PERRY_GC_ZEAL_ALLOC_KB=0` for the literal every-poll mode + when the window you are hunting executes only once — it is far slower. - `PERRY_GC_PROTECT_FROMSPACE=1` — `mprotect` from-space after evacuation so a stale read faults immediately instead of reading plausible garbage. - `PERRY_GC_FROMSPACE_SCAN_ABORT` — now actually runs. diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index d8ed0b03dd..1e602eb4da 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -132,7 +132,7 @@ to collapse that detection latency. **All are default-off and inert when off.** | `PERRY_GC_PROTECT_FROMSPACE=1` | After an **evacuating (copying) minor**, do not recycle from-space. Retired Eden and active-survivor blocks are detached into a bounded quarantine, filled with a poison pattern whose first byte reads as an invalid `obj_type` (`0xDE`), and `mprotect(PROT_NONE)`'d over their page-aligned interior. A stale dereference then SIGSEGVs **at the faulting instruction**, with the holder still on the stack. The installed reporter prints the faulting address, which minor retired it, and the last-known object that lived there (`obj_type`, size) plus a native backtrace, then restores `SIG_DFL` and returns so the instruction re-faults — a core file or debugger still sees the real crash site. | | `PERRY_GC_PROTECT_FROMSPACE=poison` | As above without `mprotect`: poison only. Use where a fault is unwanted, or for the sub-page block edges `mprotect` cannot cover (those are always poison-filled and counted separately). | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` | How many retired page-sets stay quarantined (default `4`, minimum `1`). Expired sets are restored to read/write and **recycled back into Eden**, never freed, so the quarantine is a ring: steady-state footprint is bounded by `N × from-space bytes` and no `mprotect`'d page is ever handed to the system allocator. | -| `PERRY_GC_ZEAL=1` | Force an evacuating minor at **every GC safepoint** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. Implies `PERRY_GC_FORCE_EVACUATE`, so survivors actually move. (Until #7611 an ambient `PERRY_GEN_GC_EVACUATE=0` silently vetoed that, leaving zeal moving nothing and therefore surfacing nothing — the knob was deleted for exactly that footgun.) Zeal also does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect. Modelled on V8 `--stress-scavenge` / SpiderMonkey `gcZeal`. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | +| `PERRY_GC_ZEAL=1` | Force an evacuating minor at **GC safepoints** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. **Allocation-paced since #7728** (`PERRY_GC_ZEAL_ALLOC_KB`, default 4; `=0` restores the literal every-poll mode): unpaced, one collection per loop iteration cost ~511 µs to relocate a mean of 5.9 objects, which made zeal unusable on real workloads once #7721 turned back-edge polls on by default. Implies `PERRY_GC_FORCE_EVACUATE`, so survivors actually move. (Until #7611 an ambient `PERRY_GEN_GC_EVACUATE=0` silently vetoed that, leaving zeal moving nothing and therefore surfacing nothing — the knob was deleted for exactly that footgun.) Zeal also does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect. Modelled on V8 `--stress-scavenge` / SpiderMonkey `gcZeal`. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | Abort on the **first** offending slot the whole-heap from-space scan finds, printing slot, holder, target (including the target's `obj_type`) and a collector backtrace. Now implies `PERRY_GC_FROMSPACE_SCAN=1`; previously it was silently inert on its own. | These instruments have explicit caveats, because each has burned a prior From 9a8cd94830ecee94c38393c52f53165eb8e7748e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 22:07:48 +0200 Subject: [PATCH 07/11] docs(gc): replace the stride rationale with the measured sweep (#7728) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- crates/perry-runtime/src/gc/zeal.rs | 62 +++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index d1584b35ae..70e382445a 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -10,15 +10,19 @@ //! why the #7154 hunt needed a `zod` workload and ten rounds. //! //! Zeal removes the coincidence. Modelled on V8's `--stress-scavenge` and -//! SpiderMonkey's `gcZeal`, it forces an **evacuating** minor at every GC -//! safepoint, so an unrooted value moves on its FIRST exposure, deterministically. +//! SpiderMonkey's `gcZeal`, it forces an **evacuating** minor at GC safepoints, +//! so an unrooted value moves on its first exposure, deterministically. Since +//! #7728 that is allocation-PACED rather than literally every safepoint — see +//! the pacing section below for why, and for the escape hatch that restores it. //! //! # What the knob actually gates //! //! `PERRY_GC_ZEAL=1`: //! -//! 1. Every loop back-edge poll (`js_gc_loop_safepoint`) runs a minor, instead -//! of only draining an already-deferred one (`GC_SAFEPOINT_PENDING`). +//! 1. A loop back-edge poll (`js_gc_loop_safepoint`) runs a minor, instead of +//! only draining an already-deferred one (`GC_SAFEPOINT_PENDING`) — at the +//! first poll past each `PERRY_GC_ZEAL_ALLOC_KB` of new nursery material +//! (#7728); at EVERY poll when that is `0`. //! 2. Every outermost microtask-pump safepoint runs a minor, instead of only //! when `gc_budgeted_due_trigger()` reports nursery/old pressure. //! 3. `gc_force_evacuate_enabled()` becomes true, so the minor **moves** every @@ -33,11 +37,12 @@ //! ## Point 1 requires a compile-time opt-in too //! //! Loop back-edge polls are only *emitted* when the compiler ran with -//! `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161). Zeal cannot -//! conjure a poll that codegen never emitted. A binary compiled without polls -//! still gets (2) and (3) — event-loop-boundary zeal — but a compute-only loop -//! that never yields will not collect at all. **For the #7154 hunt, compile AND -//! run with `PERRY_GC_MOVING_LOOP_POLLS=1`.** +//! `PERRY_GC_MOVING_LOOP_POLLS` on — **default ON since #7721**, off from #7161 +//! until then. Zeal cannot conjure a poll that codegen never emitted. A binary +//! compiled with `PERRY_GC_MOVING_LOOP_POLLS=0` still gets (2) and (3) — +//! event-loop-boundary zeal — but a compute-only loop that never yields will not +//! collect at all. That configuration is exactly what made zeal look free before +//! #7721: it was collecting nothing. Check `loop_polls=` in the exit verdict. //! //! Codegen also emits no poll for a provably alloc-free loop body (by design — //! `loop_purity::loop_may_allocate`) nor for the specialized `for` / `for-of` / @@ -62,7 +67,7 @@ //! zeal from free-and-vacuous into correct-but-unusable in the same commit. //! //! Measured on the pinned quiet host, a tree-walking-interpreter workload -//! (`iso_miss.ts`, 19 s without zeal): +//! (`iso_miss.ts`, 4.5 s without zeal there): //! //! | rounds | polls | forced collections | wall | //! |---|--:|--:|--:| @@ -86,6 +91,12 @@ //! `--gc-interval` model and SpiderMonkey's `gcZeal(mode, frequency)`, both of //! which pace for the same reason. //! +//! Measured on the same workload after the change: **98.8 s and the correct +//! answer**, with 193,087 forced collections out of 2,838,560 polls, all of them +//! copying minors, relocating 3,115,719 objects. Unpaced the same run is +//! ~1,426 s. See `ZEAL_DEFAULT_STRIDE_BYTES` for the stride sweep that picked +//! the default. +//! //! **`PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics**, and //! that is the right setting for a small fixture (`gc_instrument_smoke.sh` pins //! it) or for a window executed only once. What pacing gives up is precisely @@ -171,12 +182,31 @@ pub(crate) fn note_zeal_forced_collection() { /// Default stride: 4 KB of new nursery material between zeal-forced collections. /// -/// Chosen against both ends of the range this has to serve, measured rather -/// than picked: it keeps `gc_instrument_smoke.sh`'s ~80 KB fixture at ~20 -/// forced collections (the gate asserts zeal collects strictly more often than -/// pressure alone, so a stride that starved it would turn a real gate vacuous), -/// while bounding a 19 s interpreter workload to a few tens of thousands of -/// collections instead of 2.84 million. +/// Measured, not picked. The whole sweep below is ONE binary and ONE env var on +/// the pinned quiet host — the interpreter workload at a quarter scale, whose +/// `loop_polls` is **283,852 in every row**, so the only thing the knob changes +/// is the decision to collect, not the number of safepoints: +/// +/// | `ALLOC_KB` | forced collections | moved objects | wall | +/// |---|--:|--:|--:| +/// | 0 (pre-#7728) | 283,857 | 1,629,647 | 142.6 s | +/// | 1 | 70,929 | 815,460 | 36.1 s | +/// | **4 (default)** | **19,314** | **325,830** | **10.2 s** | +/// | 16 | 5,070 | 129,959 | 3.0 s | +/// | 64 | 1,291 | 52,357 | 1.1 s | +/// +/// Row 0 is the pre-fix behaviour reproduced exactly — 283,857 collections for +/// 283,852 polls, i.e. 1:1 — and it is what made the full-scale workload take +/// ~24 minutes. +/// +/// 4 KB rather than the faster 16/64 is deliberate: this is a *correctness* +/// instrument, so the default errs toward sensitivity. It still collects once +/// per ~15 loop iterations, which catches a recurring window almost +/// immediately, while being 14x cheaper than unpaced. An operator who wants +/// speed raises it; one who wants a once-executed window sets `0`. +/// +/// Every row keeps `copying_minors == forced_collections` and `moved > 0`, so +/// no stride silently degrades the instrument into non-moving sweeps. const ZEAL_DEFAULT_STRIDE_BYTES: usize = 4 * 1024; /// Pure knob parse for `PERRY_GC_ZEAL_ALLOC_KB`, in KB. `Some(0)` is a From fefd28ded918a13cc1fed06126d20419091a2bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 22:08:13 +0200 Subject: [PATCH 08/11] docs(changelog): fold in the measured zeal pacing results (#7729) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- changelog.d/7729-gc-zeal-allocation-pacing.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/changelog.d/7729-gc-zeal-allocation-pacing.md b/changelog.d/7729-gc-zeal-allocation-pacing.md index 058227f832..d3aac0a8cc 100644 --- a/changelog.d/7729-gc-zeal-allocation-pacing.md +++ b/changelog.d/7729-gc-zeal-allocation-pacing.md @@ -10,6 +10,18 @@ Zeal now forces a collection at the first poll at which `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material has accumulated — the model V8 (`--gc-interval`) and SpiderMonkey (`gcZeal(mode, frequency)`) both use, and for the same reason. The stride is a **monotone high-water mark**, not a "bytes since" delta: each forced collection rearms to `from_space_after + stride`, so a collection that reclaims nothing (an escalation to a non-moving full mark-sweep, which #7592 and #7682 both produced in the field) still demands another full stride of genuinely new allocation. Total forced collections are bounded by `bytes_allocated / stride` whatever the collector does with them, which makes this a bound rather than a hope. + **Measured on the pinned quiet host.** The full workload under zeal goes from a 240 s timeout (unpaced cost ~1,426 s) to **98.8 s with the correct answer**, forcing 193,087 collections out of 2,838,560 polls — all of them copying minors, relocating 3,115,719 objects. The stride sweep below is one binary and one env var, at a quarter scale, and `loop_polls` is **283,852 in every row**, so the knob changes only the decision to collect: + + | `ALLOC_KB` | forced collections | moved objects | wall | + |---|--:|--:|--:| + | 0 (pre-fix) | 283,857 | 1,629,647 | 142.6 s | + | 1 | 70,929 | 815,460 | 36.1 s | + | **4 (default)** | **19,314** | **325,830** | **10.2 s** | + | 16 | 5,070 | 129,959 | 3.0 s | + | 64 | 1,291 | 52,357 | 1.1 s | + + Row 0 reproduces the pre-fix 1:1 behaviour exactly on the shipped binary. Every row keeps `copying_minors == forced_collections` and `moved > 0`, so no stride degrades the instrument into non-moving sweeps. 4 KB rather than the faster 16/64 is deliberate — this is a correctness instrument, so the default errs toward sensitivity, still collecting once per ~15 loop iterations while being 14x cheaper than unpaced. The zeal-OFF path is untouched: the same workload without zeal is 4.49 s before and after. + `PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics — the right setting for a small fixture or a bug window executed exactly once. What pacing gives up, stated rather than buried: a window crossed a single time may now fall between two forced collections; a window that *recurs* (every shape in the #7154 family, which is why the reproducers are loops) is still caught, after N KB of allocation instead of on the first iteration. ### Changed From a903b2d0e3469c1d08fa4e5536309b3fb4930426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 22:08:29 +0200 Subject: [PATCH 09/11] docs(changelog): use the quiet-host baseline rather than the loaded-box figure Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- changelog.d/7729-gc-zeal-allocation-pacing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/7729-gc-zeal-allocation-pacing.md b/changelog.d/7729-gc-zeal-allocation-pacing.md index d3aac0a8cc..2d01d7a615 100644 --- a/changelog.d/7729-gc-zeal-allocation-pacing.md +++ b/changelog.d/7729-gc-zeal-allocation-pacing.md @@ -2,7 +2,7 @@ - **`PERRY_GC_ZEAL=1` terminates again — it is now allocation-paced instead of collecting at every back-edge poll (#7728).** - Zeal is the primary instrument for moving-GC correctness bugs, and half of the pairing that produced the precise fault behind #7682. It had stopped completing on real workloads: `gc-handoff/apps/iso_miss.ts`, a tree-walking interpreter that runs in 19 s, timed out at 240 s with no output under zeal. + Zeal is the primary instrument for moving-GC correctness bugs, and half of the pairing that produced the precise fault behind #7682. It had stopped completing on real workloads: `gc-handoff/apps/iso_miss.ts`, a tree-walking interpreter, timed out at 240 s with no output under zeal (it runs in 4.5 s on the quiet bench host without it). **It was never a livelock, and the last-known-good build was never good.** Scaling the round count on the pinned quiet host gave 70,968 forced collections in 36.3 s for one round and 141,931 in 72.7 s for two — perfectly linear, so the full workload was ~24 minutes rather than a hang. Zeal forced a collection at *every* back-edge poll: ~511 µs of fixed per-collection cost (root scan over the shadow stack plus ~55 side-table scanners) to relocate a mean of **5.9 objects**. Nearly all of the work was the collection's fixed overhead, not the relocation zeal exists to stress. From 1416b7ce73bbbff7ff355e6181a1b4e0dd4b568c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 22:29:12 +0200 Subject: [PATCH 10/11] gate(gc): make arm 6's fixture escape and key the check on the collections/polls ratio (#7728) Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- changelog.d/7729-gc-zeal-allocation-pacing.md | 8 ++- scripts/gc_instrument_smoke.sh | 66 ++++++++++++++----- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/changelog.d/7729-gc-zeal-allocation-pacing.md b/changelog.d/7729-gc-zeal-allocation-pacing.md index 2d01d7a615..b7726166d2 100644 --- a/changelog.d/7729-gc-zeal-allocation-pacing.md +++ b/changelog.d/7729-gc-zeal-allocation-pacing.md @@ -28,7 +28,13 @@ - **`scripts/gc_instrument_smoke.sh` gains a budgeted zeal-termination arm, and pins every-poll mode for its existing arms.** - The gate ran zeal end-to-end and was green throughout. It could not see the regression: its fixture is deliberately sized at ~1200 polls "so the zeal arm costs seconds rather than minutes", and at that size every-poll and paced are indistinguishable. Arm 6 runs a 400k-iteration workload at the *shipped default* (with `env -u` so the file's every-poll pin does not apply) and requires correct output inside a wall-clock budget sitting between the paced cost and the unpaced ~200 s. It asserts non-vacuity too — forced collections, copying minors and moved objects all non-zero, and `forced < loop_polls` — because "fast because it collects nothing" would be a worse regression than the slow instrument it replaced. Arms 1–3 and 5 now set `PERRY_GC_ZEAL_ALLOC_KB=0` explicitly, so they keep testing the strongest semantics rather than silently following whatever the default becomes. + The gate ran zeal end-to-end and was green throughout. It could not see the regression: its fixture is deliberately sized at ~1200 polls "so the zeal arm costs seconds rather than minutes", and at that size every-poll and paced are indistinguishable. Arm 6 runs a 200k-iteration workload at the *shipped default* (with `env -u`, so the file's every-poll pin does not apply to it). + + **The discriminator is the collections-to-polls ratio, not the clock**, and that is a deliberate choice. Measured, the fixture is 0.49 s paced against 11.85 s unpaced — a real 24x, but both fit inside any budget loose enough not to flake on a shared runner, so a wall-clock assertion alone would be decoration. The ratio is host-independent and exact: the regression's signature is one forced collection per poll (measured 200,069 for 200,064), the shipped default is 1-in-40, and the gate fails above 1-in-4. The budget survives as the weaker "does it terminate at all" guard. + + Two things had to be fixed for the arm to mean anything, both found by running it rather than reasoning about it. Its records must **escape**: the obvious fixture allocates a record per iteration and drops it, which scalar-replaces into nothing — 6 forced collections and 17 moved objects over 400,000 polls, an arm that ran the loop but never gave the collector anything to relocate. Pushing into a bounded rolling array takes `moved_objects` from 17 to 640,364. And the arm is sabotage-checked: forcing it back to every-poll makes it fail with the 1:1 signature. + + It asserts non-vacuity too — forced collections, copying minors and moved objects all non-zero — because "fast because it collects nothing" would be a worse regression than the slow instrument it replaced. Arms 1–3 and 5 now set `PERRY_GC_ZEAL_ALLOC_KB=0` explicitly, so they keep testing the strongest semantics rather than silently following whatever the default becomes. - **CLAUDE.md's instrument table** documents the pacing knob and loses a stale claim: it still said loop back-edge polls were "default off since #7161", which #7721 had made false. That sentence is why zeal's real cost was invisible. diff --git a/scripts/gc_instrument_smoke.sh b/scripts/gc_instrument_smoke.sh index 38b9c6cf43..4234455c5b 100755 --- a/scripts/gc_instrument_smoke.sh +++ b/scripts/gc_instrument_smoke.sh @@ -298,28 +298,54 @@ echo " reproduced as pinned (exit $repro_rc, stale forwarded pointer) -- #7254 # ~511 us per loop iteration on real code -- 24 minutes for a 19 s program, i.e. # an instrument nobody can switch on, with nothing in CI to say so. # -# So this arm is the one that is allowed to be slow-ish and is BUDGETED. It runs -# a workload with a realistic poll count at the DEFAULT stride (note the -# explicit unset -- the export at the top of this file pins every-poll mode for -# the small fixture, which would defeat the whole point here) and requires -# correct output inside a wall-clock budget. Unpaced, this fixture takes ~200 s; -# paced, a few. The budget sits between those, so the arm fails on a regression -# rather than merely getting slower. +# This arm runs a workload with a realistic poll count at the DEFAULT stride +# (note the explicit `env -u` -- the export at the top of this file pins +# every-poll mode for the small fixture, which would defeat the whole point +# here). +# +# THE DISCRIMINATOR IS THE RATIO, NOT THE CLOCK, and that is a deliberate +# choice rather than an oversight. Measured on the quiet host, this fixture is +# 0.49 s paced against 11.85 s unpaced -- a real 24x, but both fit inside any +# budget loose enough not to flake on a shared CI runner, so a wall-clock +# assertion here would be decoration. `forced_collections` vs `loop_polls` is +# host-independent and exact: the regression's signature is one forced +# collection per poll (measured 200,069 for 200,064), and the shipped default +# is 1-in-40. The 4x threshold below sits between them with room for a future +# default anywhere up to 1-in-4. +# +# The wall-clock budget is kept as the weaker "does it terminate AT ALL" guard, +# sized generously on purpose. echo echo "== arm 6: zeal terminates at the DEFAULT stride, on a realistic poll count ==" +# The records must ESCAPE. The obvious version of this fixture allocates a +# record per iteration and drops it, which scalar-replaces into nothing: it +# measured 6 forced collections and 17 moved objects over 400,000 polls, i.e. +# an arm that ran the loop but never gave the collector anything to relocate. +# Pushing into a bounded rolling array keeps a real live set (and the string +# concat allocates too), which is what turns `moved_objects` from 17 into +# 640,364. cat > "$WORK/scale.ts" <<'TS' function run(n: number): number { + const keep: any[] = []; let acc = 0; let i = 0; while (i < n) { - const rec = { a: i, b: "v", c: i + 1 }; + const rec = { a: i, b: "v" + (i % 7), c: i + 1 }; + keep.push(rec); + if (keep.length > 64) { + keep.shift(); + } acc = acc + (rec.c as number) - (rec.a as number); i = i + 1; } - return acc; + let tail = 0; + for (let k = 0; k < keep.length; k++) { + tail = tail + (keep[k].a as number); + } + return acc + (tail - tail); } -console.log("sum", run(400000)); +console.log("sum", run(200000)); TS "$PERRY_BIN" compile "$WORK/scale.ts" -o "$WORK/scale" >/dev/null @@ -347,7 +373,7 @@ if [[ $scale_rc -ne 0 ]]; then echo "$scale_out" | tail -20 >&2 exit 1 fi -if ! grep -q '^sum 400000$' <<<"$scale_out"; then +if ! grep -q '^sum 200000$' <<<"$scale_out"; then echo "FAIL [arm6]: wrong answer under zeal at the default stride:" >&2 grep '^sum' <<<"$scale_out" >&2 || echo "(no 'sum' line)" >&2 exit 1 @@ -371,11 +397,19 @@ if [[ "$scale_forced" -eq 0 || "$scale_minors" -eq 0 || "$scale_moved" -eq 0 ]]; echo " Pacing must bound the instrument, not disable it." >&2 exit 1 fi -# ...and the pacing must genuinely be pacing: far fewer collections than polls. -# Pre-#7728 this ratio was 1:1, which is the regression itself. -if [[ "$scale_polls" -gt 0 && "$scale_forced" -ge "$scale_polls" ]]; then - echo "FAIL [arm6]: zeal forced $scale_forced collections for $scale_polls polls." >&2 - echo " That is the unpaced 1:1 behaviour #7728 removed." >&2 +# ...and the pacing must genuinely be pacing. THIS is the assertion that fails +# on the regression: pre-#7728 the ratio was 1:1 (measured 200,069 forced for +# 200,064 polls); the shipped default is ~1:40. +if [[ "$scale_polls" -le 0 ]]; then + echo "FAIL [arm6]: zero back-edge polls -- the loop this arm measures did not" >&2 + echo " run, so the ratio below would compare nothing against nothing." >&2 + exit 1 +fi +if [[ $(( scale_forced * 4 )) -ge "$scale_polls" ]]; then + echo "FAIL [arm6]: zeal forced $scale_forced collections for $scale_polls polls" >&2 + echo " (threshold: fewer than one per 4 polls). That is the unpaced" >&2 + echo " behaviour #7728 removed -- one whole evacuating minor per loop" >&2 + echo " iteration, which took a 5 s program to ~24 minutes." >&2 exit 1 fi echo " correct output in ${scale_elapsed}s (budget ${ZEAL_BUDGET_S}s), $zeal_line" From efc56f0af4dc607f8382c6a64088f9b8daf2ab84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 23:18:19 +0200 Subject: [PATCH 11/11] chore: bump version to 0.5.1428 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- 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 ba74258fb5..6abb1b1f85 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.1427 +**Current Version:** 0.5.1428 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d68b86a9a0..779ae1e3b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1427" +version = "0.5.1428" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1427" +version = "0.5.1428" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1427" +version = "0.5.1428" [[package]] name = "perry-ui-tvos" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1427" +version = "0.5.1428" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 13413226ad..b751c95bff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1427" +version = "0.5.1428" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"