diff --git a/CLAUDE.md b/CLAUDE.md index 71e42849a3..2898b71a13 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.1337 +**Current Version:** 0.5.1338 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2793db161f..b6a75fc249 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1337" +version = "0.5.1338" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1337" +version = "0.5.1338" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1337" +version = "0.5.1338" [[package]] name = "perry-ui-tvos" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1337" +version = "0.5.1338" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 40d624c389..0070cc4e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1337" +version = "0.5.1338" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7594-survivor-promotion-handoff-livelock.md b/changelog.d/7594-survivor-promotion-handoff-livelock.md new file mode 100644 index 0000000000..1569956079 --- /dev/null +++ b/changelog.d/7594-survivor-promotion-handoff-livelock.md @@ -0,0 +1,31 @@ +**perf(gc): break the survivor-promotion handoff livelock (#7592)** + +`copied_minor_promotion_handoff_due` replaces a minor collection with a full +mark-sweep to make room in old-gen for survivors about to be promoted. But a +full mark-sweep is non-moving — it promotes nothing — so it cannot relieve the +pressure that scheduled it: the survivor space still holds the same bytes, the +reclaim baseline it resets does not count them, and the predicate is true again +at the very next minor. The copying minor that would have performed the +promotion never ran. + +Measured on `json_pipeline` at 200k records: 19 of 22 collections were +`survivor_promotion_bytes` fulls, each freeing 0.0 MB at ~400 ms — 7.6 s of an +8.6 s phase, with peak RSS the same whether they ran or not. + +Latched to one handoff per copying minor: the handoff makes room, the copying +minor performs the promotion that consumes it. The latch clears only on a +*copying* minor, since a non-moving minor fallback promotes nothing and would +reinstate the livelock at half rate. The guard precedes the +`copied_minor_promotable_active_survivor_bytes()` walk, so a suppressed handoff +also skips that O(n) survivor pass. + +The same workload now runs 6 collections instead of 22, with the copying minor +promoting 110 MB. `build_out` at 500k records goes 57,242 ms → 10,551 ms +(5.4×), output hash identical; peak RSS rises 17–24 % at the large sizes, +because the run performs fewer collections. + +Confined to workloads that hit the livelock: measuring both arms back to back on +one host (the pinned ratchet baseline is from 0.5.1315 on another machine and +cannot separate this change from drift), all 12 probes agree on every semantic +counter across 144 metric medians, with only ungated RSS/wall cells moving, all +≤ 0.12 %. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 18f0691192..f5f0ea54e1 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1276,6 +1276,9 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( trace.pause_us = start.elapsed().as_micros() as u64; trace.capture_layout_scans(); } + // #7592: this is the promotion the survivor-promotion handoff exists to + // enable, so it releases the latch that suppressed a repeat handoff. + note_copying_minor_completed(); maybe_schedule_old_reclaim_after_copied_minor(); retune_after_scavenge( collector.stats.eden_live_bytes, diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 546f231b73..b6d9537464 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -176,6 +176,11 @@ pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCol prev & GC_FLAG_IN_ALLOC }); if copied_minor_promotion_handoff_due(trigger.kind) { + // #7592: latch before running it. This full is non-moving and promotes + // nothing, so it cannot relieve the survivor pressure that scheduled + // it; without the latch the predicate is still true at the next minor + // and the collector livelocks on fulls that free nothing. + note_survivor_promotion_handoff_full(); let outcome = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture( GcTriggerKind::SurvivorPromotionBytes, )); diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 77a5bb494e..3d47859afa 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -774,6 +774,13 @@ thread_local! { /// `gc_check_trigger`: the full collection must not recursively trigger /// another reclaim if a hook it runs allocates. pub(super) static GC_OLD_RECLAIM_IN_PROGRESS: Cell = const { Cell::new(false) }; + /// #7592: a survivor-promotion handoff full has run and the copying minor + /// it was scheduled for has not. See + /// `survivor_promotion_handoff_awaiting_minor`. + static SURVIVOR_HANDOFF_AWAITING_MINOR: Cell = const { Cell::new(false) }; + /// Count of handoffs the latch has suppressed — see + /// `survivor_promotion_handoff_suppressions`. + static SURVIVOR_HANDOFF_SUPPRESSIONS: Cell = const { Cell::new(0) }; /// Phase 2/3 of the moving-GC project: set when an alloc-point nursery /// trigger fires while moving mode is on, deferring the collection to the /// next precise-root safepoint (event-loop boundary or a codegen loop @@ -1214,6 +1221,46 @@ pub(super) fn copied_minor_promotable_active_survivor_bytes() -> usize { promotable } +/// #7592: whether a survivor-promotion handoff full has run without the copying +/// minor it exists to enable having run since. +/// +/// The handoff replaces a minor with a full mark-sweep to make room in old-gen +/// for survivors that are about to be promoted. But a full mark-sweep is +/// **non-moving — it promotes nothing**, so it cannot itself relieve the +/// pressure it was scheduled for: the survivor space still holds the same +/// bytes, the reclaim baseline it resets does not count them, and the predicate +/// is immediately true again. Without this latch the next minor is intercepted +/// too, and the collector livelocks on full collections that free nothing — +/// measured on #7592's `json_pipeline` as 19 consecutive fulls, each freeing +/// 0.0 MB at ~400 ms, which was 7.6 s of an 8.6 s phase. +/// +/// One handoff per copying minor is the invariant: the handoff makes room, the +/// minor does the promotion that consumes it. +pub(super) fn survivor_promotion_handoff_awaiting_minor() -> bool { + SURVIVOR_HANDOFF_AWAITING_MINOR.with(Cell::get) +} + +pub(super) fn note_survivor_promotion_handoff_full() { + SURVIVOR_HANDOFF_AWAITING_MINOR.with(|flag| flag.set(true)); +} + +/// How many handoffs the latch has suppressed. Every one of these was a full +/// mark-sweep that would have freed nothing — on `main` this counter would have +/// read 18 for #7592's 200k `json_pipeline` run. It exists so the suppression +/// itself is observable: the latch short-circuits before the arena inspection, +/// so a test with an empty heap cannot otherwise distinguish "suppressed" from +/// "there was no pressure anyway". +pub(super) fn survivor_promotion_handoff_suppressions() -> u64 { + SURVIVOR_HANDOFF_SUPPRESSIONS.with(Cell::get) +} + +/// Clear the latch — called only when a *copying* minor completes, since only +/// that collector promotes. A non-moving minor fallback promotes nothing, so +/// re-arming on one would reinstate the livelock at half rate. +pub(super) fn note_copying_minor_completed() { + SURVIVOR_HANDOFF_AWAITING_MINOR.with(|flag| flag.set(false)); +} + pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> bool { if !matches!( trigger_kind, @@ -1221,6 +1268,13 @@ pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> ) { return false; } + // A handoff full has already run and promoted nothing; let the copying + // minor it was scheduled for actually happen (#7592). Placed before the + // survivor walk below so a suppressed handoff also skips that O(n) pass. + if survivor_promotion_handoff_awaiting_minor() { + SURVIVOR_HANDOFF_SUPPRESSIONS.with(|n| n.set(n.get().saturating_add(1))); + return false; + } if crate::arena::copying_active_survivor_in_use_bytes() < gc_copy_promotion_handoff_min_dyn_bytes() { diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 28e5f1fbed..84be4a90bb 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -224,6 +224,62 @@ fn test_copying_minor_promotion_handoff_uses_predicted_old_pressure() { )); } +/// #7592: a handoff full must not repeat without the copying minor it exists to +/// enable. +/// +/// The handoff replaces a minor with a full mark-sweep to make room for +/// survivors about to be promoted, but a full mark-sweep is non-moving and +/// promotes nothing — so it cannot relieve the pressure that scheduled it, and +/// the predicate is true again at the next minor. Without the latch that is a +/// livelock: json_pipeline at 200k records ran 19 consecutive +/// `survivor_promotion_bytes` fulls, each freeing 0.0 MB at ~400 ms. +/// +/// The latch short-circuits before any arena inspection, so this asserts the +/// suppression itself rather than reproducing the heap state that arms it. +#[test] +fn test_survivor_promotion_handoff_waits_for_the_copying_minor() { + let _guard = GcTestIsolationGuard::new(); + + note_copying_minor_completed(); + assert!( + !survivor_promotion_handoff_awaiting_minor(), + "latch must start clear" + ); + + note_survivor_promotion_handoff_full(); + assert!(survivor_promotion_handoff_awaiting_minor()); + // Assert the SUPPRESSION, not just the `false`. With an empty heap the + // predicate returns false at the survivor-occupancy check regardless, so a + // bare `assert!(!due)` passes with the latch deleted — it would be a test + // that cannot fail. The counter only moves if the latch branch ran. + for kind in [GcTriggerKind::ArenaBytes, GcTriggerKind::MallocCount] { + let before = survivor_promotion_handoff_suppressions(); + assert!( + !copied_minor_promotion_handoff_due(kind), + "a second handoff must be suppressed while the first still awaits \ + its copying minor ({kind:?})" + ); + assert_eq!( + survivor_promotion_handoff_suppressions(), + before + 1, + "the latch branch must be what rejected it ({kind:?})" + ); + } + // A trigger kind the handoff never applies to must not be counted as a + // latch suppression — it is rejected earlier, on its own merits. + let before = survivor_promotion_handoff_suppressions(); + assert!(!copied_minor_promotion_handoff_due(GcTriggerKind::Direct)); + assert_eq!(survivor_promotion_handoff_suppressions(), before); + + // Only a COPYING minor clears it: a non-moving minor fallback promotes + // nothing and would reinstate the livelock at half rate. + note_copying_minor_completed(); + assert!( + !survivor_promotion_handoff_awaiting_minor(), + "the copying minor consumes the handoff" + ); +} + // 2026-07-09 audit (device-blind policy): budget-scaled threshold math. #[test] fn test_budget_scaled_clamps_only_under_budget() {