diff --git a/crates/perry-runtime/src/arena/block.rs b/crates/perry-runtime/src/arena/block.rs index f718307733..50b5b502e5 100644 --- a/crates/perry-runtime/src/arena/block.rs +++ b/crates/perry-runtime/src/arena/block.rs @@ -51,9 +51,20 @@ fn alloc_block(min_size: usize) -> ArenaBlock { min_size.div_ceil(BLOCK_SIZE) * BLOCK_SIZE }; let layout = Layout::from_size_align(size, 16).unwrap(); - let data = unsafe { alloc(layout) }; + let mut data = unsafe { alloc(layout) }; if data.is_null() { - panic!("Failed to allocate arena block of {} bytes", size); + // The OS refused memory. Try one emergency full collection — + // idle-block dealloc and malloc sweep can return real pages — + // then retry once before giving up. + if crate::gc::gc_try_emergency_reclaim() { + data = unsafe { alloc(layout) }; + } + } + if data.is_null() { + panic!( + "Failed to allocate arena block of {} bytes (heap exhausted after emergency GC)", + size + ); } ArenaBlock { data, diff --git a/crates/perry-runtime/src/gc/heap_budget.rs b/crates/perry-runtime/src/gc/heap_budget.rs new file mode 100644 index 0000000000..5ea48bcc29 --- /dev/null +++ b/crates/perry-runtime/src/gc/heap_budget.rs @@ -0,0 +1,201 @@ +//! Device-derived heap budget (2026-07-09 GC audit, theme T1). +//! +//! Split from `policy.rs` (repo lint caps files at 2000 lines). See the +//! banner comment below for the full design rationale. + +use std::sync::OnceLock; + +use super::policy::{ + GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, GC_MOVING_DEFER_HARD_CAP_BYTES, + GC_OLD_GEN_RECLAIM_GROWTH_BYTES, GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES, + GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES, + GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES, GC_TRIGGER_ABSOLUTE_CEILING, +}; + +// ───────────────────────────────────────────────────────────────────────── +// Device-derived heap budget (2026-07-09 GC audit, theme T1 "device-blind +// policy"). +// +// Every sizing constant in this collector was tuned on 16-64 GB desktop +// machines. On a watch-class device (~30-60 MB jetsam budget) or a small +// container, the 128 MB first trigger alone exceeds the OS-imposed process +// budget — the process was jetsam/OOM-killed before the collector ever ran +// once. The budget below derives an upper bound for this process's memory +// from, in priority order: +// +// 1. `PERRY_GC_HEAP_LIMIT` — explicit deployer override, in MB. +// 2. `os_proc_available_memory()` — Apple embedded (iOS/tvOS/watchOS/ +// visionOS): bytes left before jetsam, sampled at first GC use. +// 3. cgroup `memory.max` / `memory.limit_in_bytes` — containers +// (via the existing `js_process_constrained_memory` parser). +// 4. Half of physical RAM (`js_os_totalmem`) — an allowance, not a +// claim on the whole machine. +// +// Budgets of ≥1 GB clamp nothing (every scaled fraction exceeds its +// desktop default), and are represented as `None` so all accessors stay on +// their historical constant path — desktop/server behavior is unchanged. +// ───────────────────────────────────────────────────────────────────────── + +pub(crate) fn gc_heap_budget_bytes() -> Option { + static CACHED: OnceLock> = OnceLock::new(); + *CACHED.get_or_init(|| { + if let Ok(v) = std::env::var("PERRY_GC_HEAP_LIMIT") { + if let Ok(mb) = v.trim().parse::() { + if mb > 0 { + return Some((mb as usize).saturating_mul(1024 * 1024)); + } + } + } + let mut budget: Option = None; + let mut consider = |candidate: f64| { + if candidate.is_finite() && candidate >= 1024.0 * 1024.0 { + let c = candidate as usize; + budget = Some(budget.map_or(c, |b| b.min(c))); + } + }; + #[cfg(any( + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos" + ))] + { + extern "C" { + // libSystem, iOS 13+/watchOS 6+: bytes this process may + // still allocate before hitting its jetsam limit. + fn os_proc_available_memory() -> usize; + } + let avail = unsafe { os_proc_available_memory() }; + if avail > 0 { + consider(avail as f64); + } + } + consider(crate::process::js_process_constrained_memory()); + let total = crate::os::js_os_totalmem(); + if total.is_finite() && total > 0.0 { + consider(total / 2.0); + } + match budget { + Some(b) if b < 1024 * 1024 * 1024 => Some(b), + _ => None, + } + }) +} + +/// `default.min(budget/den × num).max(floor)`; the historical default on +/// unbudgeted (desktop/server) machines. +fn budget_scaled(default: usize, num: usize, den: usize, floor: usize) -> usize { + budget_scaled_with(gc_heap_budget_bytes(), default, num, den, floor) +} + +pub(super) fn budget_scaled_with( + budget: Option, + default: usize, + num: usize, + den: usize, + floor: usize, +) -> usize { + match budget { + Some(budget) => default.min((budget / den).saturating_mul(num)).max(floor), + None => default, + } +} + +macro_rules! budget_scaled_accessor { + ($(#[$doc:meta])* $name:ident, $default:expr, $num:expr, $den:expr, $floor:expr) => { + $(#[$doc])* + pub(crate) fn $name() -> usize { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| budget_scaled($default, $num, $den, $floor)) + } + }; +} + +budget_scaled_accessor!( + /// First-GC / adaptive-trigger ceiling: a quarter of the device budget, + /// capped at the historical 128 MB. + gc_trigger_absolute_ceiling_bytes, + GC_TRIGGER_ABSOLUTE_CEILING, + 1, + 4, + 2 * 1024 * 1024 +); +budget_scaled_accessor!( + /// Post-collection headroom floor (historically 16 MB) — scales down + /// with the trigger so a small-budget device doesn't get 16 MB of + /// headroom on an 8 MB trigger. + gc_trigger_headroom_floor_bytes, + 16 * 1024 * 1024, + 1, + 32, + 1024 * 1024 +); +budget_scaled_accessor!( + gc_old_gen_reclaim_threshold_dyn_bytes, + GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES, + 1, + 8, + 4 * 1024 * 1024 +); +budget_scaled_accessor!( + gc_old_gen_reclaim_growth_dyn_bytes, + GC_OLD_GEN_RECLAIM_GROWTH_BYTES, + 1, + 12, + 2 * 1024 * 1024 +); +budget_scaled_accessor!( + gc_copy_promotion_handoff_min_dyn_bytes, + GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, + 1, + 16, + 2 * 1024 * 1024 +); +budget_scaled_accessor!( + gc_moving_defer_hard_cap_dyn_bytes, + GC_MOVING_DEFER_HARD_CAP_BYTES, + 1, + 4, + 2 * 1024 * 1024 +); +budget_scaled_accessor!( + gc_tiny_parse_in_use_trigger_dyn_bytes, + GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES, + 1, + 8, + 2 * 1024 * 1024 +); +budget_scaled_accessor!( + gc_tiny_parse_full_gc_in_use_trigger_dyn_bytes, + GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES, + 1, + 16, + 1024 * 1024 +); + +/// RSS evacuation-pressure thresholds (historically 192/256 MB — above the +/// entire process budget of every small device, so the pressure arms never +/// fired exactly where they matter most). +pub(crate) fn gc_rss_pressure_dyn_bytes() -> u64 { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + budget_scaled( + super::oldgen::RSS_PRESSURE_BYTES as usize, + 1, + 2, + 16 * 1024 * 1024, + ) as u64 + }) +} + +pub(crate) fn gc_rss_hard_pressure_dyn_bytes() -> u64 { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + budget_scaled( + super::oldgen::RSS_HARD_PRESSURE_BYTES as usize, + 2, + 3, + 24 * 1024 * 1024, + ) as u64 + }) +} diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index b71fa437a1..e83b03cfac 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -161,9 +161,15 @@ pub fn gc_malloc(size: usize, obj_type: u8) -> *mut u8 { gc_check_trigger(); unsafe { - let raw = alloc(layout); + let mut raw = alloc(layout); + if raw.is_null() && super::gc_try_emergency_reclaim() { + raw = alloc(layout); + } if raw.is_null() { - panic!("gc_malloc: failed to allocate {} bytes", total); + panic!( + "gc_malloc: failed to allocate {} bytes (heap exhausted after emergency GC)", + total + ); } let header = raw as *mut GcHeader; @@ -211,7 +217,13 @@ pub fn gc_malloc_batch(sizes: &[usize], obj_type: u8) -> Vec<*mut u8> { let layout = Layout::from_size_align(total, 8).unwrap(); let raw = alloc(layout); if raw.is_null() { - panic!("gc_malloc_batch: failed to allocate {} bytes", total); + // Inside the IN_ALLOC window the emergency reclaim refuses + // to run (re-entrancy); batch callers are rare and small, + // so just report exhaustion. + panic!( + "gc_malloc_batch: failed to allocate {} bytes (heap exhausted)", + total + ); } let header = raw as *mut GcHeader; (*header).obj_type = obj_type; diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index d74a0ca5f7..03430d4301 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -38,6 +38,8 @@ pub use types::*; mod policy; pub(crate) use policy::gc_runtime_safepoint; pub use policy::*; +mod heap_budget; +pub use heap_budget::*; mod telemetry; pub use telemetry::*; mod malloc; @@ -248,11 +250,42 @@ fn gc_collect_full_mark_sweep_with_trigger(trigger: GcTriggerSnapshot) -> GcColl GcCycleState::new_full(trigger).run_to_completion() } -#[allow(dead_code)] fn gc_collect_emergency_full() -> GcCollectOutcome { gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Emergency)) } +/// Last-ditch recovery for a failed heap allocation (2026-07-09 audit): +/// run one synchronous full mark-sweep and let the caller retry the +/// allocation once. Returns false (caller proceeds straight to its panic) +/// when collecting here would be unsound: re-entrant emergency, inside a +/// collection/allocation bookkeeping window, or mid-budgeted-cycle. +/// +/// The workspace builds with `panic = "unwind"`, and these OOM panics +/// cross `extern "C"` frames into aborts — on a memory-limited process +/// (cgroup `memory.max`, jetsam) dying without even attempting a +/// collection wasted the one chance to shed a heap full of garbage. +/// +/// The conservative stack scan is forced for the same reason the +/// alloc-point direct arm forces it: this runs at an arbitrary allocation +/// site where locals of the current call chain may not be spilled to +/// shadow slots. +pub(crate) fn gc_try_emergency_reclaim() -> bool { + thread_local! { + static IN_EMERGENCY: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + if IN_EMERGENCY.with(|c| c.get()) { + return false; + } + if GC_FLAGS.with(|f| f.get()) & GC_FLAG_IN_ALLOC != 0 || gc_budgeted_cycle_active() { + return false; + } + IN_EMERGENCY.with(|c| c.set(true)); + let _scan = roots::ManualGcScanGuard::force_full_scan(); + let _ = gc_collect_emergency_full(); + IN_EMERGENCY.with(|c| c.set(false)); + true +} + #[cfg(test)] pub(super) fn test_gc_collect_emergency_full_trace_json() -> serde_json::Value { let outcome = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot { diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 1d32a485f9..df420d9486 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -258,7 +258,7 @@ pub(super) fn evacuation_policy_initial_decision( ..EvacuationPolicyDecision::default() }; } - if rss_bytes >= RSS_PRESSURE_BYTES { + if rss_bytes >= gc_rss_pressure_dyn_bytes() { return EvacuationPolicyDecision { allowed, considered: true, @@ -431,7 +431,7 @@ pub(super) fn evacuation_policy_final_decision( // Previously these gates `return`ed before the RSS checks, so a heap of // sparsely-pinned blocks could sit above the hard threshold forever with // reason `reclaimable_candidate_bytes_below_threshold`. - let hard_rss_pressure = snapshot.rss_bytes >= RSS_HARD_PRESSURE_BYTES; + let hard_rss_pressure = snapshot.rss_bytes >= gc_rss_hard_pressure_dyn_bytes(); if hard_rss_pressure { decision.enabled = true; decision.reason = "rss_hard_pressure"; @@ -468,7 +468,7 @@ pub(super) fn evacuation_policy_final_decision( decision.reason = if !object_bytes_pass && block_bytes_pass { // Only the granule metric cleared the bar — the new W3 path. "releasable_block_bytes" - } else if snapshot.rss_bytes >= RSS_PRESSURE_BYTES { + } else if snapshot.rss_bytes >= gc_rss_pressure_dyn_bytes() { "rss_pressure" } else if snapshot.old_page_selected_pages > 0 && snapshot.tenured_still_in_nursery_bytes < MIN_TENURED_NURSERY_BYTES diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index ae0ef7a22e..17bbd99fb3 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1,3 +1,4 @@ +use super::heap_budget::*; use super::*; /// Hard work budget for ordinary automatic GC steps once the collector is @@ -204,6 +205,24 @@ pub(super) const GC_THRESHOLD_MAX_BYTES: usize = 1024 * 1024 * 1024; // 1 GB /// which is exactly the bench-RSS scenario this is targeting. pub(super) const GC_TRIGGER_ABSOLUTE_CEILING: usize = 128 * 1024 * 1024; +// Device-derived heap budget: see gc/heap_budget.rs (split out for the +// 2000-line file lint). + +/// The arena-bytes trigger as the collector should compare it: the raw +/// cell while armed (explicit re-arms/bumps may legitimately exceed the +/// ceiling — headroom floor over a big live set, medium-parse bumps), the +/// device-derived ceiling while the cell still holds its desktop-default +/// const initializer. +pub(super) fn effective_next_arena_trigger() -> usize { + if GC_TRIGGER_ARMED.with(|a| a.get()) { + GC_NEXT_TRIGGER_BYTES.with(|c| c.get()) + } else { + GC_NEXT_TRIGGER_BYTES + .with(|c| c.get()) + .min(gc_trigger_absolute_ceiling_bytes()) + } +} + thread_local! { /// Lower bound for the next GC trigger. Bumped after each /// `gc_collect_inner` based on collection effectiveness (see the @@ -226,6 +245,15 @@ thread_local! { pub(super) static GC_NEXT_TRIGGER_BYTES: std::cell::Cell = const { std::cell::Cell::new(GC_THRESHOLD_INITIAL_BYTES) }; + /// Whether GC_NEXT_TRIGGER_BYTES has been explicitly set on this thread + /// (re-arm after a collection, parse bump, tiny-parse lowering). While + /// false the cell still holds the desktop-default const initializer and + /// `effective_next_arena_trigger` substitutes the device-derived ceiling + /// instead — an ARMED trigger above the ceiling is legitimate (big live + /// set headroom floor, medium-parse bumps) and must not be clamped. + pub(super) static GC_TRIGGER_ARMED: std::cell::Cell = + const { std::cell::Cell::new(false) }; + /// Per-program adaptive GC step. Doubles (up to MAX) when sweeps /// are mostly-garbage; halves (down to 16MB) when sweeps reclaim /// little. Used to compute the next trigger after each GC as @@ -275,10 +303,10 @@ pub(super) fn gc_bump_arena_trigger_target( step: usize, is_tiny_parse: bool, ) -> usize { - let bytes_step = step.min(GC_THRESHOLD_INITIAL_BYTES); + let bytes_step = step.min(gc_trigger_absolute_ceiling_bytes()); let target = bytes_now.saturating_add(bytes_step); if is_tiny_parse { - target.min(GC_TRIGGER_ABSOLUTE_CEILING) + target.min(gc_trigger_absolute_ceiling_bytes()) } else { target } @@ -727,8 +755,7 @@ pub(super) fn flush_deferred_gc_request() { pub fn gc_suppress() { if !gen_gc_enabled() - && crate::arena::arena_in_use_bytes() - >= GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES + && crate::arena::arena_in_use_bytes() >= gc_tiny_parse_full_gc_in_use_trigger_dyn_bytes() { crate::arena::arena_start_fresh_general_block(); } @@ -769,9 +796,9 @@ pub fn gc_bump_malloc_trigger() { if is_tiny_parse { let use_gen_gc = gen_gc_enabled(); let in_use_trigger = if use_gen_gc { - GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES + gc_tiny_parse_in_use_trigger_dyn_bytes() } else { - GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES + gc_tiny_parse_full_gc_in_use_trigger_dyn_bytes() }; if crate::arena::arena_in_use_bytes() < in_use_trigger { return; @@ -785,6 +812,7 @@ pub fn gc_bump_malloc_trigger() { GC_NEXT_TRIGGER_BYTES.with(|trigger| { if trigger.get() > bytes_now { trigger.set(bytes_now); + GC_TRIGGER_ARMED.with(|a| a.set(true)); } }); gc_check_trigger(); @@ -821,6 +849,7 @@ pub fn gc_collect_pending_suppressed_parse() { GC_NEXT_TRIGGER_BYTES.with(|trigger| { if trigger.get() > total { trigger.set(total); + GC_TRIGGER_ARMED.with(|a| a.set(true)); } }); gc_check_trigger(); @@ -838,7 +867,7 @@ pub fn gc_schedule_parse_boundary_collection_if_pressure() { if !gen_gc_enabled() { return; } - if crate::arena::arena_in_use_bytes() < GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES { + if crate::arena::arena_in_use_bytes() < gc_tiny_parse_in_use_trigger_dyn_bytes() { return; } GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); @@ -846,9 +875,9 @@ pub fn gc_schedule_parse_boundary_collection_if_pressure() { #[inline] pub(super) fn old_reclaim_pressure_due(old_in_use: usize, baseline: usize) -> bool { - (old_in_use >= GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES - && baseline < GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES) - || old_in_use.saturating_sub(baseline) >= GC_OLD_GEN_RECLAIM_GROWTH_BYTES + (old_in_use >= gc_old_gen_reclaim_threshold_dyn_bytes() + && baseline < gc_old_gen_reclaim_threshold_dyn_bytes()) + || old_in_use.saturating_sub(baseline) >= gc_old_gen_reclaim_growth_dyn_bytes() } #[inline] @@ -857,7 +886,7 @@ pub(super) fn copied_minor_promotion_handoff_pressure_due( old_in_use: usize, baseline: usize, ) -> bool { - promotable_bytes >= GC_COPY_PROMOTION_HANDOFF_MIN_BYTES + promotable_bytes >= gc_copy_promotion_handoff_min_dyn_bytes() && old_reclaim_pressure_due(old_in_use.saturating_add(promotable_bytes), baseline) } @@ -891,7 +920,9 @@ pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> ) { return false; } - if crate::arena::copying_active_survivor_in_use_bytes() < GC_COPY_PROMOTION_HANDOFF_MIN_BYTES { + if crate::arena::copying_active_survivor_in_use_bytes() + < gc_copy_promotion_handoff_min_dyn_bytes() + { return false; } let promotable = copied_minor_promotable_active_survivor_bytes(); @@ -971,8 +1002,12 @@ pub(super) fn gc_bump_malloc_trigger_with_snapshot(current: usize, bytes_now: us // Only raise — never lower — so this can't accidentally trip a // pending collection that the existing trigger had already armed. GC_NEXT_TRIGGER_BYTES.with(|c| { - if bytes_trigger > c.get() { + // Compare against the effective (budget-clamped) trigger, not the + // raw cell: on a small-budget device the cell's un-armed default + // (128 MB) would otherwise swallow every legitimate parse bump. + if bytes_trigger > effective_next_arena_trigger() { c.set(bytes_trigger); + GC_TRIGGER_ARMED.with(|a| a.set(true)); if !is_tiny_parse { GC_TRIGGER_BUMPED.with(|b| b.set(true)); } @@ -1097,10 +1132,11 @@ fn gc_finish_arena_trigger_collection(pre_in_use: usize, outcome: GcCollectOutco // approaches the ceiling doesn't thrash on every fresh // allocation. let stepped = new_total.saturating_add(step); - let capped = stepped.min(GC_TRIGGER_ABSOLUTE_CEILING); - let floor = new_total.saturating_add(16 * 1024 * 1024); + let capped = stepped.min(gc_trigger_absolute_ceiling_bytes()); + let floor = new_total.saturating_add(gc_trigger_headroom_floor_bytes()); let next_trigger = std::cmp::max(capped, floor); GC_NEXT_TRIGGER_BYTES.with(|c| c.set(next_trigger)); + GC_TRIGGER_ARMED.with(|a| a.set(true)); // Rebaseline the malloc-count trigger only if this collection // actually swept malloc objects. Copied-minor arena collections // may skip the malloc sweep while count pressure is still below @@ -1244,7 +1280,7 @@ pub fn gc_check_trigger() { // pass the hard cap (a mega-expression that reached no poll), fall // through and collect non-moving here so growth stays bounded. if gc_moving_loop_polls_enabled() - && crate::arena::arena_total_bytes() < GC_MOVING_DEFER_HARD_CAP_BYTES + && crate::arena::arena_total_bytes() < gc_moving_defer_hard_cap_dyn_bytes() { GC_SAFEPOINT_PENDING.with(|p| p.set(true)); return; @@ -1358,10 +1394,10 @@ fn gc_budgeted_resume_blocked() -> bool { } pub(super) fn gc_old_reclaim_debt_bytes(old_in_use: usize, baseline: usize) -> u64 { - let trigger = if baseline < GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES { - GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES + let trigger = if baseline < gc_old_gen_reclaim_threshold_dyn_bytes() { + gc_old_gen_reclaim_threshold_dyn_bytes() } else { - baseline.saturating_add(GC_OLD_GEN_RECLAIM_GROWTH_BYTES) + baseline.saturating_add(gc_old_gen_reclaim_growth_dyn_bytes()) }; old_in_use.saturating_sub(trigger) as u64 } @@ -1377,8 +1413,7 @@ fn gc_budgeted_due_trigger() -> Option { } let total = crate::arena::arena_total_bytes(); - let next_arena_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); - if total >= next_arena_trigger { + if total >= effective_next_arena_trigger() { return Some(BudgetedGcTrigger::ArenaBytes); } diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 2c3ca6a14e..04ee520da6 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -215,3 +215,62 @@ fn test_copying_minor_promotion_handoff_uses_predicted_old_pressure() { 20 * 1024 * 1024, )); } + +// 2026-07-09 audit (device-blind policy): budget-scaled threshold math. +#[test] +fn test_budget_scaled_clamps_only_under_budget() { + use super::super::heap_budget::budget_scaled_with; + const MB: usize = 1024 * 1024; + // Unbudgeted (desktop/server): historical default unchanged. + assert_eq!(budget_scaled_with(None, 128 * MB, 1, 4, 2 * MB), 128 * MB); + // 64 MB budget (watch-class): quarter-budget trigger. + assert_eq!( + budget_scaled_with(Some(64 * MB), 128 * MB, 1, 4, 2 * MB), + 16 * MB + ); + // 256 MB container: still clamped below the default. + assert_eq!( + budget_scaled_with(Some(256 * MB), 128 * MB, 1, 4, 2 * MB), + 64 * MB + ); + // Big budget: fraction exceeds the default → default wins. + assert_eq!( + budget_scaled_with(Some(900 * MB), 128 * MB, 1, 4, 2 * MB), + 128 * MB + ); + // Degenerate tiny budget: floor holds. + assert_eq!(budget_scaled_with(Some(MB), 128 * MB, 1, 4, 2 * MB), 2 * MB); +} + +// The un-armed trigger cell (desktop-default const initializer) reads as +// the device ceiling; an armed trigger above the ceiling is legitimate +// (headroom floor over a big live set) and must NOT be clamped. +#[test] +fn test_effective_arena_trigger_respects_armed_values() { + use super::super::heap_budget::gc_trigger_absolute_ceiling_bytes; + use super::super::policy::{ + effective_next_arena_trigger, GC_NEXT_TRIGGER_BYTES, GC_TRIGGER_ARMED, + }; + let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); + let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); + + GC_TRIGGER_ARMED.with(|c| c.set(false)); + GC_NEXT_TRIGGER_BYTES.with(|c| c.set(usize::MAX / 2)); + assert_eq!( + effective_next_arena_trigger(), + gc_trigger_absolute_ceiling_bytes(), + "un-armed trigger must clamp to the device ceiling" + ); + + GC_TRIGGER_ARMED.with(|c| c.set(true)); + let above_ceiling = gc_trigger_absolute_ceiling_bytes() * 3; + GC_NEXT_TRIGGER_BYTES.with(|c| c.set(above_ceiling)); + assert_eq!( + effective_next_arena_trigger(), + above_ceiling, + "armed triggers above the ceiling are legitimate and must survive" + ); + + GC_NEXT_TRIGGER_BYTES.with(|c| c.set(prev_trigger)); + GC_TRIGGER_ARMED.with(|c| c.set(prev_armed)); +} diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 2a462215b9..7db06c16c3 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -356,9 +356,20 @@ pub(crate) fn read_thread_cpu_micros() -> (f64, f64) { (0.0, 0.0) } -/// Get resident set size (RSS) in bytes using platform-specific APIs +/// Get resident set size (RSS) in bytes using platform-specific APIs. +/// +/// 2026-07-09 audit: the mach `task_info` path is identical on every Apple +/// OS, but was cfg-gated to macOS only — so RSS read 0 on iOS/tvOS/watchOS/ +/// visionOS and every RSS-pressure GC heuristic was silently dead exactly +/// where memory is scarcest. Android reads the same procfs file as Linux. pub(crate) fn get_rss_bytes() -> u64 { - #[cfg(target_os = "macos")] + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos" + ))] { use std::mem; extern "C" { @@ -397,14 +408,23 @@ pub(crate) fn get_rss_bytes() -> u64 { 0 } } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "android"))] { - // Read /proc/self/statm - second field is RSS in pages + // Read /proc/self/statm - second field is RSS in pages. + // Page size must be queried: 16 K (many Android/Asahi kernels) and + // 64 K (some aarch64 distros) pages under-reported RSS 4-16× with + // the old hardcoded 4096, inflating every RSS threshold to match. if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") { let parts: Vec<&str> = statm.split_whitespace().collect(); if parts.len() >= 2 { if let Ok(pages) = parts[1].parse::() { - return pages * 4096; // page size is typically 4KB + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + let page_size = if page_size > 0 { + page_size as u64 + } else { + 4096 + }; + return pages * page_size; } } } @@ -443,7 +463,16 @@ pub(crate) fn get_rss_bytes() -> u64 { } } } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + #[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "linux", + target_os = "android", + target_os = "windows" + )))] { 0 }