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: 15 additions & 0 deletions crates/perry-runtime/src/closure/dynamic_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,11 @@ mod tests_1802 {
/// removed, the adapter sees every stored value's bits.
#[test]
fn dyn_prop_values_are_visited_in_mark_phase() {
// CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset
// (`test_clear_closure_side_tables`) clears it from parallel test
// threads, wiping this test's parked entry mid-assertion. Serialize
// against those guards, THEN against this module's own tests.
let _global = crate::gc::global_side_table_test_lock();
let _guard = SIDE_TABLE_TEST_LOCK.lock().unwrap();
// A unique synthetic closure address (just an integer key — the
// scanner doesn't deref it during value visitation; the
Expand Down Expand Up @@ -811,6 +816,11 @@ mod tests_1802 {

#[test]
fn dyn_prop_scanner_visits_values_without_holding_props_lock() {
// CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset
// (`test_clear_closure_side_tables`) clears it from parallel test
// threads, wiping this test's parked entry mid-assertion. Serialize
// against those guards, THEN against this module's own tests.
let _global = crate::gc::global_side_table_test_lock();
let _guard = SIDE_TABLE_TEST_LOCK.lock().unwrap();
let owner: usize = 0xC10C_AB1E_0000_1803;
let value_bits: u64 = 0x7FFD_AAAA_BBBB_CCCD;
Expand Down Expand Up @@ -845,6 +855,11 @@ mod tests_1802 {

#[test]
fn dyn_prop_get_ignores_non_closure_receivers() {
// CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset
// (`test_clear_closure_side_tables`) clears it from parallel test
// threads, wiping this test's parked entry mid-assertion. Serialize
// against those guards, THEN against this module's own tests.
let _global = crate::gc::global_side_table_test_lock();
let _guard = SIDE_TABLE_TEST_LOCK.lock().unwrap();
let obj = crate::object::js_object_alloc(0, 0) as usize;

Expand Down
5 changes: 4 additions & 1 deletion crates/perry-runtime/src/gc/copying.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,10 +1096,13 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility(
if crate::weakref::weak_target_holders_allocated() {
let phase_start = trace_phase_start(trace);
let valid_ptrs = build_valid_pointer_set();
// Enqueue FinalizationRegistry cleanup jobs on every trigger kind —
// see the matching WeakProcessing comment in cycle.rs (2026-07-09 GC
// audit: delivery was gated on the Manual trigger).
crate::weakref::process_weak_targets_after_mark(
&valid_ptrs,
/* minor_only = */ true,
matches!(trigger_kind, GcTriggerKind::Manual),
/* enqueue_callbacks = */ true,
);
trace_phase_record(trace, "weak_processing", phase_start);
}
Expand Down
11 changes: 7 additions & 4 deletions crates/perry-runtime/src/gc/cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1194,11 +1194,14 @@ impl GcCycleState {
}
let valid_ptrs = self.valid_ptrs.as_ref().expect("valid pointer set built");
let minor_only = self.minor.is_some();
let enqueue_callbacks = matches!(self.trigger_kind, GcTriggerKind::Manual);
// Enqueue FinalizationRegistry cleanup jobs on EVERY cycle
// kind, not just Manual (2026-07-09 GC audit: callbacks only
// ever fired after an explicit `gc()`). Enqueue-once per
// record is guaranteed by the record's pending-flag reset;
// delivery happens at the explicit-`gc()` tail or the next
// microtask-pump drain (`drain_pending_finalization_jobs`).
crate::weakref::process_weak_targets_after_mark(
valid_ptrs,
minor_only,
enqueue_callbacks,
valid_ptrs, minor_only, /* enqueue_callbacks = */ true,
);
let next = if minor_only {
AtomicFinalizeSubphase::MinorPrelude
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,11 @@ pub extern "C" fn js_gc_stats(

#[cfg(test)]
mod tests;

/// Crate-wide handle on the GC test-isolation lock — see
/// `tests::support::copying_nursery_isolation_lock`. Any test OUTSIDE the gc
/// module that populates-then-asserts a process-global side table (e.g.
/// `CLOSURE_PROPS`) must hold this, or the gc test guards' global state reset
/// on a parallel test thread can wipe its entries mid-test.
#[cfg(test)]
pub(crate) use tests::support::copying_nursery_isolation_lock as global_side_table_test_lock;
5 changes: 4 additions & 1 deletion crates/perry-runtime/src/gc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1899,7 +1899,10 @@ pub extern "C" fn js_gc_collect() {
/// forces the conservative native-stack scan (#4977); see `ManualGcScanGuard`.
fn manual_gc_collect_now() {
let _scan = super::roots::ManualGcScanGuard::force_full_scan();
crate::weakref::clear_pending_finalization_jobs();
// NOTE: pending finalization jobs from earlier AUTOMATIC cycles are NOT
// cleared here — each record enqueues exactly once (its pending flag is
// reset at enqueue time), so dropping the vec would lose those callbacks
// forever. The delivery below simply takes whatever is queued.
// An explicit `gc()` runs a FULL mark-sweep rather than the generational
// fast path. With gen-GC on (the default), `gc_collect_inner_with_trigger`
// dispatches a MINOR cycle, whose sweep skips dead-old-block reclamation
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/tests/copying.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod promise_side_tables;
mod survival_and_malloc;
mod weak_semantics;
use super::super::*;
Expand Down
101 changes: 101 additions & 0 deletions crates/perry-runtime/src/gc/tests/copying/promise_side_tables.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Promise side-table GC-death cleanup (2026-07-09 GC audit, wave 2 batch A).
//!
//! `PROMISE_SETTLE_LISTENERS`, `PROMISE_OVERFLOW_REACTIONS`, and
//! `PROMISE_ALL_STATES` key entries by promise address but root the parked
//! closures/result machinery strongly, pruning only at settle — so an
//! abandoned never-settling promise leaked everything it captured, forever.
//! They now mirror the `PROMISE_CONTEXTS` reference lifecycle: the
//! `PromiseCleanup` finalize arm drops a dead promise's entries in sweeping
//! collections, and the copied-minor from-space pass drops/rekeys entries via
//! the shared `copied_minor_promise_key_fate` classifier.

use super::*;

/// A DEAD (unrooted) pending promise's entries in all three side tables must
/// be dropped by the copied-minor from-space cleanup.
#[test]
fn test_dead_promise_side_table_entries_cleared_by_copied_minor() {
let _guard = CopyingNurseryTestGuard::new(1);

let p_addr = {
let p = crate::promise::js_promise_new();
assert!(crate::arena::pointer_in_nursery(p as usize));
crate::promise::scanners::test_park_promise_side_table_entries(p);
p as usize
};
// Not rooted: dead at the first minor.
js_shadow_slot_set(0, 0);

assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(p_addr),
(1, 1, 1),
"test premise: one entry parked in each table"
);

let _ = gc_collect_minor();

assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(p_addr),
(0, 0, 0),
"dead promise's settle-listener / overflow-reaction / Promise.all-state \
entries must be dropped (they can never fire and strongly root their \
payloads)"
);
}

/// A LIVE (rooted) pending promise must KEEP its entries across a copied
/// minor, rekeyed to the promise's post-move address by the registered
/// promise root scanner.
#[test]
fn test_live_promise_side_table_entries_rekeyed_by_copied_minor() {
let _guard = CopyingNurseryTestGuard::new(1);
// The guard clears the thread's mutable-scanner registry for isolation;
// this test is ABOUT key rewriting, so re-register the promise scanner.
gc_register_mutable_root_scanner(promise_mutable_root_scanner);

let p = crate::promise::js_promise_new();
let p_addr = p as usize;
crate::promise::scanners::test_park_promise_side_table_entries(p);
js_shadow_slot_set(0, ptr_bits(p_addr));

let _ = gc_collect_minor();

let moved = (js_shadow_slot_get(0) & POINTER_MASK) as usize;
assert_ne!(
moved, p_addr,
"test premise: the promise must actually move"
);
assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(moved),
(1, 1, 1),
"live promise's entries must survive, keyed by the moved address"
);
assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(p_addr),
(0, 0, 0),
"no entry may linger under the stale pre-move key"
);
}

/// The `GcFinalizeHookKind::PromiseCleanup` arm (sweeping collections /
/// malloc'd promises) routes through `clear_promise_context_for_gc`, which
/// must drop the dead promise's entries in all three tables.
#[test]
fn test_promise_cleanup_finalize_arm_clears_side_tables() {
let _guard = CopyingNurseryTestGuard::new(1);

let p = crate::promise::js_promise_new();
crate::promise::scanners::test_park_promise_side_table_entries(p);
assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(p as usize),
(1, 1, 1),
);

crate::promise::clear_promise_context_for_gc(p);

assert_eq!(
crate::promise::scanners::test_promise_side_table_counts_for(p as usize),
(0, 0, 0),
"the finalize arm must purge every side table keyed by the dead promise"
);
}
127 changes: 127 additions & 0 deletions crates/perry-runtime/src/gc/tests/copying/weak_semantics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,130 @@ fn test_copying_minor_weakmap_dead_key_entry_clears() {
key slot repaired to the moved address)"
);
}

// ---------------------------------------------------------------------------
// FinalizationRegistry on AUTOMATIC cycles + weak unregister tokens
// (2026-07-09 GC audit, wave 2 batch A).
// ---------------------------------------------------------------------------

extern "C" fn finreg_test_callback(
_closure: *const crate::closure::ClosureHeader,
_held: f64,
) -> f64 {
f64::from_bits(crate::value::TAG_UNDEFINED)
}

/// Allocate a FinalizationRegistry with a real (callable) cleanup closure and
/// root it in shadow slot 0. Returns nothing — read the registry back through
/// the slot so post-move addresses stay correct.
fn make_rooted_finreg() {
let cb = crate::closure::js_closure_alloc(finreg_test_callback as *const u8, 0);
let cb_val = f64::from_bits(ptr_bits(cb as usize));
let reg = crate::weakref::js_finreg_new(cb_val);
js_shadow_slot_set(0, object_bits(reg));
}

/// An AUTOMATIC collection (non-Manual trigger — `gc_collect_minor` captures
/// `GcTriggerKind::Direct`) must ENQUEUE the cleanup job for a collected
/// target, and a second automatic cycle must not enqueue it again
/// (enqueue-once via the record's pending-flag reset). Before the fix,
/// `enqueue_callbacks` was gated on `GcTriggerKind::Manual`, so ordinary
/// servers never ran any FinalizationRegistry cleanup callback.
#[test]
fn test_finreg_job_enqueued_after_automatic_copied_minor() {
let _guard = CopyingNurseryTestGuard::new(1);
make_rooted_finreg();

{
// Target reachable only from this scope — dead at the first minor.
let target = crate::object::js_object_alloc(0, 0);
let reg_v = f64::from_bits(js_shadow_slot_get(0));
let _ = crate::weakref::js_finreg_register(
reg_v,
f64::from_bits(object_bits(target)),
f64::from_bits(crate::value::TAG_TRUE),
f64::from_bits(crate::value::TAG_UNDEFINED),
);
}
assert_eq!(crate::weakref::pending_finalization_jobs_count(), 0);

let _ = gc_collect_minor();
assert_eq!(
crate::weakref::pending_finalization_jobs_count(),
1,
"automatic copied-minor must enqueue the cleanup job for the dead target"
);

let _ = gc_collect_minor();
assert_eq!(
crate::weakref::pending_finalization_jobs_count(),
1,
"a second automatic cycle must NOT re-enqueue the same record"
);
}

/// The microtask-pump drain converts recorded jobs into nextTick callback
/// invocations exactly once: it removes the record from the registry, queues
/// the tick job, and a second drain is a no-op.
#[test]
fn test_pump_drain_delivers_automatic_finreg_jobs_once() {
let _guard = CopyingNurseryTestGuard::new(1);
make_rooted_finreg();

{
let target = crate::object::js_object_alloc(0, 0);
let reg_v = f64::from_bits(js_shadow_slot_get(0));
let _ = crate::weakref::js_finreg_register(
reg_v,
f64::from_bits(object_bits(target)),
f64::from_bits(crate::value::TAG_TRUE),
f64::from_bits(crate::value::TAG_UNDEFINED),
);
}
let _ = gc_collect_minor();
assert_eq!(crate::weakref::pending_finalization_jobs_count(), 1);

let delivered = crate::weakref::drain_pending_finalization_jobs();
assert_eq!(delivered, 1, "drain must deliver the recorded job");
assert_eq!(
crate::weakref::pending_finalization_jobs_count(),
0,
"delivery consumes the pending-jobs vec"
);
assert_eq!(
crate::weakref::drain_pending_finalization_jobs(),
0,
"a second drain must be a no-op (no double-delivery)"
);
}

/// The unregister token slot is WEAK (spec [[UnregisterToken]]): the canonical
/// `registry.register(obj, held, obj)` must not pin `obj` immortal. Before the
/// fix, record field 1 (the token) was traced strongly, so the target survived
/// every collection and no cleanup job was ever recorded.
#[test]
fn test_finreg_unregister_token_held_weakly() {
let _guard = CopyingNurseryTestGuard::new(1);
make_rooted_finreg();

{
let target = crate::object::js_object_alloc(0, 0);
let target_v = f64::from_bits(object_bits(target));
let reg_v = f64::from_bits(js_shadow_slot_get(0));
// register(obj, held, obj): the target doubles as its own token.
let _ = crate::weakref::js_finreg_register(
reg_v,
target_v,
f64::from_bits(crate::value::TAG_TRUE),
target_v,
);
}

let _ = gc_collect_minor();
assert_eq!(
crate::weakref::pending_finalization_jobs_count(),
1,
"register(obj, held, obj): the token edge must not keep the target \
alive — the target must die and its cleanup job must be recorded"
);
}
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/gc/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ mod oldgen;
mod roots;
mod runtime_roots;
mod smoke;
mod support;
pub(super) mod support;
mod telemetry_verifier;
mod triggers;
10 changes: 9 additions & 1 deletion crates/perry-runtime/src/gc/tests/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,15 @@ impl Drop for IncrementalMarkBarrierTestGuard<'_> {

static COPYING_NURSERY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

pub(super) fn copying_nursery_isolation_lock() -> std::sync::MutexGuard<'static, ()> {
/// Serializes every test that mutates PROCESS-GLOBAL runtime side tables.
/// The guards' state reset clears global stores (e.g. `CLOSURE_PROPS` via
/// `test_clear_closure_side_tables`) from whatever test thread runs it, so a
/// test elsewhere in the crate that populates-then-asserts one of those
/// globals under only its own private lock races the reset (observed:
/// `closure::dynamic_props::tests_1802` losing its parked entry mid-test).
/// Such tests must take this lock too — reachable crate-wide as
/// `crate::gc::global_side_table_test_lock()`.
pub(crate) fn copying_nursery_isolation_lock() -> std::sync::MutexGuard<'static, ()> {
COPYING_NURSERY_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-runtime/src/promise/combinators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,41 @@ pub(super) fn scan_promise_all_states_mut(visitor: &mut crate::gc::RuntimeRootVi
});
}

/// GC death hook: input promise `promise` died in a sweep — no settle can ever
/// complete the combinator states keyed by it, so drop them and let the result
/// machinery (result promise, results/state arrays) become collectible.
/// 2026-07-09 GC audit, mirrors `PROMISE_CONTEXTS`.
pub(super) fn remove_all_states_for_dead_promise(promise: *mut Promise) {
if promise.is_null() {
return;
}
let key = promise as usize;
PROMISE_ALL_STATES.with(|states| {
let mut states = states.borrow_mut();
if !states.is_empty() {
states.retain(|(k, _)| *k != key);
}
});
}

/// Copied-minor from-space cleanup for `PROMISE_ALL_STATES` — see
/// `cleanup_copied_minor_settle_listeners_for_gc` (`reactions.rs`).
pub(super) fn cleanup_copied_minor_all_states_for_gc() {
use super::CopiedMinorPromiseKeyFate::*;
PROMISE_ALL_STATES.with(|states| {
states.borrow_mut().retain_mut(|(key, _)| {
match super::copied_minor_promise_key_fate(*key) {
Keep => true,
Rekey(new_key) => {
*key = new_key;
true
}
Drop => false,
}
});
});
}

/// Create a rejected promise with the given reason
#[no_mangle]
pub extern "C" fn js_promise_rejected(reason: f64) -> *mut Promise {
Expand Down
Loading
Loading