Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions crates/perry-runtime/src/arena/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
201 changes: 201 additions & 0 deletions crates/perry-runtime/src/gc/heap_budget.rs
Original file line number Diff line number Diff line change
@@ -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<usize> {
static CACHED: OnceLock<Option<usize>> = OnceLock::new();
*CACHED.get_or_init(|| {
if let Ok(v) = std::env::var("PERRY_GC_HEAP_LIMIT") {
if let Ok(mb) = v.trim().parse::<u64>() {
if mb > 0 {
return Some((mb as usize).saturating_mul(1024 * 1024));
}
}
}
let mut budget: Option<usize> = 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<usize>,
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<usize> = 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<u64> = 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<u64> = OnceLock::new();
*CACHED.get_or_init(|| {
budget_scaled(
super::oldgen::RSS_HARD_PRESSURE_BYTES as usize,
2,
3,
24 * 1024 * 1024,
) as u64
})
}
18 changes: 15 additions & 3 deletions crates/perry-runtime/src/gc/malloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 34 additions & 1 deletion crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool> = 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
Comment on lines +272 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reset IN_EMERGENCY with a drop guard.

Line 264 can unwind before Line 265 clears the TLS flag, leaving future emergency reclaim permanently disabled on this thread if the panic is caught.

Suggested guard
 pub(crate) fn gc_try_emergency_reclaim() -> bool {
     thread_local! {
         static IN_EMERGENCY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
     }
+    struct EmergencyFlagReset;
+    impl Drop for EmergencyFlagReset {
+        fn drop(&mut self) {
+            IN_EMERGENCY.with(|c| c.set(false));
+        }
+    }
     if IN_EMERGENCY.with(|c| c.get()) {
         return false;
     }
@@
     }
     IN_EMERGENCY.with(|c| c.set(true));
+    let _emergency_flag_reset = EmergencyFlagReset;
     let _scan = roots::ManualGcScanGuard::force_full_scan();
     let _ = gc_collect_emergency_full();
-    IN_EMERGENCY.with(|c| c.set(false));
     true
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub(crate) fn gc_try_emergency_reclaim() -> bool {
thread_local! {
static IN_EMERGENCY: std::cell::Cell<bool> = 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
pub(crate) fn gc_try_emergency_reclaim() -> bool {
thread_local! {
static IN_EMERGENCY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
struct EmergencyFlagReset;
impl Drop for EmergencyFlagReset {
fn drop(&mut self) {
IN_EMERGENCY.with(|c| c.set(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 _emergency_flag_reset = EmergencyFlagReset;
let _scan = roots::ManualGcScanGuard::force_full_scan();
let _ = gc_collect_emergency_full();
true
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/mod.rs` around lines 252 - 266,
`gc_try_emergency_reclaim` can leave the thread-local `IN_EMERGENCY` flag stuck
on if `gc_collect_emergency_full` unwinds before the manual reset runs. Replace
the manual set/clear sequence with a drop guard scoped inside
`gc_try_emergency_reclaim` so the TLS flag is always cleared on exit, even
during panic unwinding, while preserving the existing early-return checks and
emergency scan flow.

}

#[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 {
Expand Down
6 changes: 3 additions & 3 deletions crates/perry-runtime/src/gc/oldgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading