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
31 changes: 28 additions & 3 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::expr::FnCtx;
use crate::module::LlModule;
use crate::stmt;
use crate::strings::StringPool;
use crate::types::{LlvmType, DOUBLE, I1, I32, I64};
use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR};

use super::opts::CrossModuleCtx;
use super::typed_abi::{
Expand Down Expand Up @@ -568,6 +568,25 @@ pub(super) fn compile_closure(
if typed_public_trampoline.is_some() {
lf.linkage = "internal".to_string();
}

// gh #6206 / #6081: closures/arrows compiled WITHOUT a shadow frame left
// their pointer-typed params/locals invisible to the exact-roots copying
// minor (production skips the conservative native-stack scan), so an
// evacuating GC fired mid-body swept values reachable only from the
// closure's own frame — the referrer then read freed-and-reused memory.
// Emit the same frame the top-level function path gets (function.rs).
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m = crate::collectors::collect_pointer_typed_locals(params, body, &flat_const_ids);
lf.enable_shadow_frame(m.len() as u32);
m
} else {
std::collections::HashMap::new()
};
let shadow_slot_clears_after_stmt =
crate::collectors::collect_shadow_slot_clear_points(body, &shadow_slot_map);

let _ = lf.create_block("entry");

let mut closure_boxed_vars = module_boxed_vars.clone();
Expand All @@ -581,6 +600,12 @@ pub(super) fn compile_closure(
for p in params {
let arg_name = format!("%arg{}", p.id);
let slot = super::arguments::store_param_slot(blk, p, &closure_boxed_vars, &arg_name);
if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &slot_idx.to_string()), (PTR, &slot)],
);
}
map.insert(p.id, slot);
}
map
Expand Down Expand Up @@ -802,8 +827,8 @@ pub(super) fn compile_closure(
pending_declares: Vec::new(),
integer_locals: native_facts.integer_locals(),
unsigned_i32_locals: native_facts.unsigned_i32_locals(),
shadow_slot_map: std::collections::HashMap::new(),
shadow_slot_clears_after_stmt: std::collections::HashMap::new(),
shadow_slot_map,
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
cached_lengths: HashMap::new(),
Expand Down
77 changes: 72 additions & 5 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::expr::FnCtx;
use crate::module::LlModule;
use crate::stmt;
use crate::strings::StringPool;
use crate::types::{LlvmType, DOUBLE, I1, I32, I64};
use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR};

use super::helpers::scoped_static_method_name;
use super::opts::CrossModuleCtx;
Expand Down Expand Up @@ -291,6 +291,28 @@ pub(super) fn compile_method(
if typed_public_trampoline.is_some() || force_generic_body {
lf.linkage = "internal".to_string();
}

// gh #6206 / #6081: methods were compiled WITHOUT a shadow frame — same
// exact-roots liveness hole as closures (see compile_closure). One extra
// slot roots the receiver (`this` is a pointer value reachable from
// nothing else when the caller holds it only in a register temp).
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m = crate::collectors::collect_pointer_typed_locals(
&method.params,
&method.body,
&flat_const_ids,
);
lf.enable_shadow_frame(m.len() as u32 + 1);
m
} else {
std::collections::HashMap::new()
};
let this_shadow_slot_idx = shadow_slot_map.len() as u32;
let shadow_slot_clears_after_stmt =
crate::collectors::collect_shadow_slot_clear_points(&method.body, &shadow_slot_map);

let _ = lf.create_block("entry");

let mut method_boxed_vars = module_boxed_vars.clone();
Expand All @@ -302,10 +324,22 @@ pub(super) fn compile_method(
let blk = lf.block_mut(0).unwrap();
let this_slot = blk.alloca(DOUBLE);
blk.store(DOUBLE, "%this_arg", &this_slot);
if super::helpers::shadow_stack_enabled() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)],
);
}
let mut map = HashMap::new();
for p in &method.params {
let arg_name = format!("%arg{}", p.id);
let slot = super::arguments::store_param_slot(blk, p, &method_boxed_vars, &arg_name);
if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &slot_idx.to_string()), (PTR, &slot)],
);
}
map.insert(p.id, slot);
}
(this_slot, map)
Expand Down Expand Up @@ -422,8 +456,8 @@ pub(super) fn compile_method(
pending_declares: Vec::new(),
integer_locals: native_facts.integer_locals(),
unsigned_i32_locals: native_facts.unsigned_i32_locals(),
shadow_slot_map: std::collections::HashMap::new(),
shadow_slot_clears_after_stmt: std::collections::HashMap::new(),
shadow_slot_map,
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
cached_lengths: HashMap::new(),
Expand Down Expand Up @@ -1147,6 +1181,27 @@ pub(super) fn compile_static_method(
let ic_base = llmod.ic_counter;
let buffer_alias_base = llmod.buffer_alias_counter;
let lf = llmod.define_function(&llvm_name, DOUBLE, params);

// gh #6206 / #6081: same shadow-frame emission as compile_method — static
// method bodies were equally invisible to the exact-roots copying minor.
// One extra slot roots the resolved receiver: static `this` is usually
// the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a
// REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()`
// dynamic dispatch, and that object may be reachable only from this slot.
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m =
crate::collectors::collect_pointer_typed_locals(&f.params, &f.body, &flat_const_ids);
lf.enable_shadow_frame(m.len() as u32 + 1);
m
} else {
std::collections::HashMap::new()
};
let this_shadow_slot_idx = shadow_slot_map.len() as u32;
let shadow_slot_clears_after_stmt =
crate::collectors::collect_shadow_slot_clear_points(&f.body, &shadow_slot_map);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
let _ = lf.create_block("entry");

let mut static_boxed_vars = module_boxed_vars.clone();
Expand Down Expand Up @@ -1179,10 +1234,22 @@ pub(super) fn compile_static_method(
&[(DOUBLE, &class_ref_lit)],
);
blk.store(DOUBLE, &resolved_this, &this_slot);
if super::helpers::shadow_stack_enabled() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)],
);
}
let mut map = HashMap::new();
for p in &f.params {
let arg_name = format!("%arg{}", p.id);
let slot = super::arguments::store_param_slot(blk, p, &static_boxed_vars, &arg_name);
if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &slot_idx.to_string()), (PTR, &slot)],
);
}
map.insert(p.id, slot);
}
(this_slot, map)
Expand Down Expand Up @@ -1298,8 +1365,8 @@ pub(super) fn compile_static_method(
pending_declares: Vec::new(),
integer_locals: native_facts.integer_locals(),
unsigned_i32_locals: native_facts.unsigned_i32_locals(),
shadow_slot_map: std::collections::HashMap::new(),
shadow_slot_clears_after_stmt: std::collections::HashMap::new(),
shadow_slot_map,
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
cached_lengths: HashMap::new(),
Expand Down
13 changes: 13 additions & 0 deletions crates/perry-runtime/src/array/iter_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ pub extern "C" fn js_array_map(
let length = (*arr).length;
let scope = crate::gc::RuntimeHandleScope::new();
let rooted = RootedIterArray::new(&scope, arr);
// Root the callback closure across the iteration. A callback allocated
// by a frameless caller (arrow/method — #6081) is reachable ONLY via
// this raw param + the native stack, which an evacuating minor does NOT
// scan (copied-minor eligibility requires no conservative stack scan).
// Closures are non-movable, so an unrooted one is swept in place mid-
// loop → the next dispatch calls freed memory ("object is not a
// function" / wild-pointer crash). Masked by PERRY_GEN_GC_EVACUATE=0,
// whose non-moving minor DOES run the conservative scan. See gh #6206.
let cb_handle = scope.root_raw_const_ptr(callback);
let _tg = DenseThisGuard::bind_undefined();

// ECMA-262 §23.1.3.20 step 5: ArraySpeciesCreate(O, len) runs BEFORE
Expand Down Expand Up @@ -204,6 +213,7 @@ pub extern "C" fn js_array_map(
}
};
// JS .map() callback receives (element, index, array).
let callback = cb_handle.get_raw_const_ptr::<ClosureHeader>();
let mapped = js_closure_call3(callback, element, i as f64, rooted.receiver());
if is_plain {
let result = result_arr(&result_rooted);
Expand Down Expand Up @@ -285,6 +295,8 @@ pub extern "C" fn js_array_filter(
let length = (*arr).length;
let scope = crate::gc::RuntimeHandleScope::new();
let rooted = RootedIterArray::new(&scope, arr);
// Root the callback across the loop — see js_array_map / gh #6206.
let cb_handle = scope.root_raw_const_ptr(callback);
let _tg = DenseThisGuard::bind_undefined();

// ECMA-262 §23.1.3.7 step 5: ArraySpeciesCreate(O, 0) runs before the
Expand Down Expand Up @@ -312,6 +324,7 @@ pub extern "C" fn js_array_filter(
None => continue,
}
};
let callback = cb_handle.get_raw_const_ptr::<ClosureHeader>();
let keep = js_closure_call3(callback, element, i as f64, rooted.receiver());
// Proper truthy check: handles NaN-boxed booleans (TAG_FALSE != 0.0 but is falsy)
if crate::value::js_is_truthy(keep) != 0 {
Expand Down
58 changes: 58 additions & 0 deletions crates/perry-runtime/src/gc/oldgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,65 @@ pub(super) fn select_old_page_defrag_pages_from_snapshot(
selection
}

/// gh #6206 test hook: the defrag machinery's unit tests exercise the
/// selection/copy/re-remember mechanics directly and must bypass the
/// production off-gate below. Thread-local so parallel tests don't race.
#[cfg(test)]
thread_local! {
pub(crate) static OLD_DEFRAG_TEST_OVERRIDE: std::cell::Cell<Option<bool>> =
const { std::cell::Cell::new(None) };
}

/// RAII enable for the defrag unit tests: forces the off-gate open on this
/// thread for the guard's lifetime.
#[cfg(test)]
pub(crate) struct OldDefragTestEnable;

#[cfg(test)]
impl OldDefragTestEnable {
pub(crate) fn new() -> Self {
OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(Some(true)));
OldDefragTestEnable
}
}

#[cfg(test)]
impl Drop for OldDefragTestEnable {
fn drop(&mut self) {
OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(None));
}
}

fn old_page_defrag_enabled() -> bool {
#[cfg(test)]
if let Some(v) = OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.get()) {
return v;
}
use std::sync::OnceLock;
static OPT_IN: OnceLock<bool> = OnceLock::new();
*OPT_IN.get_or_init(|| {
matches!(
std::env::var("PERRY_GC_OLD_DEFRAG").as_deref(),
Ok("1") | Ok("on") | Ok("true")
)
})
}

pub(super) fn select_old_page_defrag_pages(force: bool) -> OldPageDefragSelection {
// gh #6206: old-page defrag evacuation is OFF pending a rewrite-contract
// fix. With defrag active, a reader can observe a pre-move address of a
// defrag-moved old object long after the cycle (wild-pointer crash /
// silently corrupt cached value); the reproducer corrupts 6/6 with defrag
// enabled and is clean 6/6 with it disabled, on the same binary, while
// every heap-payload slot (arrays in-length, object fields, Map entries)
// verifies as correctly rewritten — the stale reference lives on a
// non-heap path (address-keyed cache / IC / side table) the defrag
// rewrite doesn't reach. Nursery evacuation and tenured promotion (the
// reclaim-critical moving paths) are unaffected. Re-enable for
// debugging/bisection with PERRY_GC_OLD_DEFRAG=1.
if !old_page_defrag_enabled() {
return OldPageDefragSelection::default();
}
let snapshot = crate::arena::old_page_meta_snapshot();
select_old_page_defrag_pages_from_snapshot(&snapshot, force)
}
Expand Down
37 changes: 37 additions & 0 deletions crates/perry-runtime/src/gc/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub(super) struct OldYoungEdgeMissing {
pub(super) parent: usize,
pub(super) slot: usize,
pub(super) child: usize,
// gh #6206: edge-type diagnostics for the verifier panic.
pub(super) parent_obj_type: u8,
pub(super) child_obj_type: u8,
pub(super) parent_is_old_arena: bool,
pub(super) parent_marked: bool,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
Expand All @@ -79,17 +84,49 @@ pub(super) struct OldYoungEdgeVerifyStats {
pub(super) checked_old_to_young_edges: usize,
pub(super) missing_edges: usize,
pub(super) first_missing: Option<OldYoungEdgeMissing>,
// gh #6206: per-type histograms of missing edges
pub(super) missing_by_parent_type: [u32; 32],
pub(super) missing_by_child_type: [u32; 32],
pub(super) missing_parent_malloc: u32,
pub(super) missing_parent_unmarked: u32,
}

impl OldYoungEdgeVerifyStats {
#[inline]
pub(super) fn record_missing(&mut self, parent: usize, slot: usize, child: usize) {
self.record_missing_diag(parent, slot, child, 0, 0, false, false);
}

#[inline]
#[allow(clippy::too_many_arguments)]
pub(super) fn record_missing_diag(
&mut self,
parent: usize,
slot: usize,
child: usize,
parent_obj_type: u8,
child_obj_type: u8,
parent_is_old_arena: bool,
parent_marked: bool,
) {
self.missing_edges = self.missing_edges.saturating_add(1);
self.missing_by_parent_type[(parent_obj_type as usize) & 31] += 1;
self.missing_by_child_type[(child_obj_type as usize) & 31] += 1;
if !parent_is_old_arena {
self.missing_parent_malloc += 1;
}
if !parent_marked {
self.missing_parent_unmarked += 1;
}
if self.first_missing.is_none() {
self.first_missing = Some(OldYoungEdgeMissing {
parent,
slot,
child,
parent_obj_type,
child_obj_type,
parent_is_old_arena,
parent_marked,
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/gc/tests/barrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,9 @@ fn test_old_young_edge_verifier_trace_json_shape() {
parent: 0x1111,
slot: 0x2222,
child: 0x3333,
..Default::default()
}),
..Default::default()
};
trace.record_phase("old_young_edge_verify", std::time::Duration::from_micros(7));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn test_copying_minor_promotes_survivor_on_fourth_survival() {

#[test]
fn test_copying_minor_preserves_old_page_accounting_for_defrag_policy() {
let _defrag = OldDefragTestEnable::new();
struct ResetGcTestState {
pinned_header: *mut GcHeader,
}
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/tests/cycle_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ fn bounded_minor_fallback_preserves_age_and_trace_fields() {

#[test]
fn budgeted_minor_fallback_ignores_forced_evacuation_and_stays_non_moving() {
let _defrag = OldDefragTestEnable::new();
let _guard = CopyingNurseryTestGuard::new(2);
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();
let _force = EnvVarGuard::set("PERRY_GC_FORCE_EVACUATE", "1");
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/tests/oldgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ fn test_old_page_defrag_skips_non_movable_buffer_and_typed_array() {

#[test]
fn test_old_page_defrag_re_remembers_young_child_after_collection_clear() {
let _defrag = OldDefragTestEnable::new();
struct ResetGcTestState;

impl Drop for ResetGcTestState {
Expand Down
Loading
Loading