diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index 93adc4b02a..c2240bb0c8 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -191,12 +191,31 @@ pub fn collect_pointer_typed_locals( matches!( ty, Type::String + // A string-LITERAL type (`"foo"`, or a `"a" | "b"` discriminant + // union member) is a heap String at runtime — it needs a root + // slot exactly like `Type::String`, or the moving-GC precise scan + // reaps a live string → silent corruption. + | Type::StringLiteral(_) | Type::Array(_) | Type::Tuple(_) | Type::Object(_) | Type::Named(_) + // An unresolved generic type parameter (`T`) can bind to any + // heap type; treat it as a pointer (fail-safe). + | Type::TypeVar(_) | Type::Promise(_) | Type::Function(_) + // A generic instantiation (`Map`, `Set`, `WeakMap`, + // `Box`, `Array`, a user generic class, …) is always a + // heap-reference type. Without this, a `Map`/`Set`-typed local + // got NO shadow-stack slot, so the PRECISE moving-GC root scan + // never saw it — the object was reaped as dead while still live + // (crash: "grown Map must retain its side-allocation owner + // record"). The non-moving default GC hid this via its + // conservative C-stack scan. Treating a rare non-pointer generic + // value as a root is harmless: the GC decode rejects any slot + // value that isn't a live heap pointer. + | Type::Generic { .. } | Type::BigInt | Type::Any | Type::Unknown diff --git a/crates/perry-codegen/src/loop_purity.rs b/crates/perry-codegen/src/loop_purity.rs index a7ce97f8f7..aeace93d48 100644 --- a/crates/perry-codegen/src/loop_purity.rs +++ b/crates/perry-codegen/src/loop_purity.rs @@ -52,6 +52,115 @@ pub(crate) fn body_needs_asm_barrier(body: &[Stmt]) -> bool { !body_writes_outside(body, &body_locals) } +/// True when the loop body may allocate (or otherwise call into the runtime and +/// trip a GC), so a moving-GC back-edge poll (`js_gc_loop_safepoint`) must be +/// emitted to drain any deferred minor. Reuses the LLVM-purity whitelist: a body +/// that is fully LLVM-pure performs no call / allocation / heap mutation, so it +/// can never cross a nursery trigger and defer a collection — the poll would be +/// a guaranteed no-op that only defeats vectorization. Conservative in the SAFE +/// direction: anything not provably pure is treated as "may allocate" and gets +/// the poll. A spurious poll costs a little vectorization; a missing one only +/// delays a deferred minor to the next safepoint (bounded by the moving-GC hard +/// cap) — never a correctness or UAF hazard. +pub(crate) fn body_may_allocate(body: &[Stmt]) -> bool { + !body.iter().all(stmt_alloc_free) +} + +/// Like `stmt_is_pure`, but the question is narrower — "can this allocate (or +/// call into the runtime and trip a GC)?" — so it additionally accepts element +/// ACCESS that never allocates: array/typed-array element READS and in-place +/// numeric updates never allocate, and typed-array element WRITES store into a +/// fixed-size backing buffer that never grows. Generic `IndexSet` is NOT +/// accepted: a plain JS-array index write can grow (reallocate) the backing +/// store. This lets a `for (…) acc += arr[i]` reduction stay poll-free (LLVM can +/// vectorize) while `keep.push({…})` (a Call) still gets its poll. +fn stmt_alloc_free(s: &Stmt) -> bool { + match s { + Stmt::Expr(e) => expr_alloc_free(e), + Stmt::Let { init, .. } => init.as_ref().is_none_or(expr_alloc_free), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_alloc_free(condition) + && then_branch.iter().all(stmt_alloc_free) + && else_branch + .as_ref() + .is_none_or(|b| b.iter().all(stmt_alloc_free)) + } + Stmt::While { condition, body } => { + expr_alloc_free(condition) && body.iter().all(stmt_alloc_free) + } + Stmt::DoWhile { body, condition } => { + expr_alloc_free(condition) && body.iter().all(stmt_alloc_free) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_none_or(stmt_alloc_free) + && condition.as_ref().is_none_or(expr_alloc_free) + && update.as_ref().is_none_or(expr_alloc_free) + && body.iter().all(stmt_alloc_free) + } + Stmt::Labeled { body, .. } => stmt_alloc_free(body), + Stmt::Break | Stmt::Continue | Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => true, + _ => false, + } +} + +fn expr_alloc_free(e: &Expr) -> bool { + // Everything LLVM-pure is allocation-free (literals, reads, arithmetic). + if expr_is_pure(e) { + return true; + } + match e { + // Element READS never allocate — they return an existing element / a + // number. Recurse so the object and index are themselves alloc-free. + Expr::IndexGet { object, index } => expr_alloc_free(object) && expr_alloc_free(index), + Expr::BufferIndexGet { buffer, index } => { + expr_alloc_free(buffer) && expr_alloc_free(index) + } + Expr::Uint8ArrayGet { array, index } => expr_alloc_free(array) && expr_alloc_free(index), + // `arr[i]++` / `--`: read-modify-write of an existing numeric slot, no + // growth, no allocation. + Expr::IndexUpdate { object, index, .. } => { + expr_alloc_free(object) && expr_alloc_free(index) + } + // Typed-array element WRITES store into a fixed-size backing buffer that + // never grows/reallocates. (Generic `IndexSet` is deliberately absent — + // a plain JS-array write can grow the array and allocate.) + Expr::BufferIndexSet { + buffer, + index, + value, + } => expr_alloc_free(buffer) && expr_alloc_free(index) && expr_alloc_free(value), + Expr::Uint8ArraySet { + array, + index, + value, + } => expr_alloc_free(array) && expr_alloc_free(index) && expr_alloc_free(value), + // Re-handle the composite arithmetic/assign forms so an alloc-free (but + // not LLVM-pure) operand — e.g. a `BufferIndexGet` — propagates through. + Expr::LocalSet(_, val) => expr_alloc_free(val), + Expr::Binary { left, right, .. } + | Expr::Compare { left, right, .. } + | Expr::Logical { left, right, .. } => expr_alloc_free(left) && expr_alloc_free(right), + Expr::Unary { operand, .. } | Expr::TypeOf(operand) | Expr::Void(operand) => { + expr_alloc_free(operand) + } + Expr::Conditional { + condition, + then_expr, + else_expr, + } => expr_alloc_free(condition) && expr_alloc_free(then_expr) && expr_alloc_free(else_expr), + _ => false, + } +} + fn stmt_is_pure(s: &Stmt) -> bool { match s { Stmt::Expr(e) => expr_is_pure(e), diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index d79d29016b..39f13d6fe9 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5141,7 +5141,7 @@ fn lower_for_after_init_with_i32_bound( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx); + emit_gc_loop_safepoint(ctx, body); ctx.block().br(&update_label); } @@ -5225,10 +5225,15 @@ fn lower_for_after_init_with_i32_bound( fn moving_safepoint_polls_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); + // DEFAULT ON (moving-nursery flip): emit the back-edge poll, but ONLY for + // allocating loop bodies (see the `body_may_allocate` gate in + // `emit_gc_loop_safepoint`) so numeric/vectorizable loops stay call-free. + // Kill switch: PERRY_GC_MOVING_LOOP_POLLS=0/off/false. Must match the runtime + // `gc_moving_loop_polls_enabled` (same env) so deferrals always have a drain. *CACHED.get_or_init(|| { - matches!( + !matches!( std::env::var("PERRY_GC_MOVING_LOOP_POLLS").as_deref(), - Ok("1") | Ok("on") | Ok("true") + Ok("0") | Ok("off") | Ok("false") ) }) } @@ -5246,10 +5251,17 @@ fn moving_safepoint_polls_enabled() -> bool { /// loop that takes one of those paths won't drain a deferred moving minor until /// the next event-loop safepoint. Adding the poll to every back-edge across /// those paths is the remaining Phase 2 codegen work. -pub(crate) fn emit_gc_loop_safepoint(ctx: &mut FnCtx<'_>) { +pub(crate) fn emit_gc_loop_safepoint(ctx: &mut FnCtx<'_>, body: &[Stmt]) { if !moving_safepoint_polls_enabled() || ctx.block().is_terminated() { return; } + // Only an ALLOCATING loop body can defer a collection to this poll; skip the + // poll for pure (non-allocating) bodies so numeric/vectorizable loops stay + // call-free (a poll defeats LLVM auto-vectorization — measured ~2x on a tight + // scalar reduction). See `body_may_allocate` for the safe-direction rationale. + if !crate::loop_purity::body_may_allocate(body) { + return; + } ctx.block().call_void("js_gc_loop_safepoint", &[]); } @@ -7030,7 +7042,7 @@ pub(crate) fn lower_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx); + emit_gc_loop_safepoint(ctx, body); ctx.block().br(&cond_label); } ctx.active_region_id = previous_region_id; @@ -7088,7 +7100,7 @@ pub(crate) fn lower_do_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { - emit_gc_loop_safepoint(ctx); + emit_gc_loop_safepoint(ctx, body); ctx.block().br(&cond_label); } diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index f3e58cf8c5..cebb8e5c74 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -26,7 +26,19 @@ pub(crate) struct CopyingPointerSet { impl CopyingPointerSet { pub(super) fn new() -> Self { let (malloc_registry_available, malloc_registry_empty_at_start) = MALLOC_STATE.with(|s| { - let s = s.borrow(); + let mut s = s.borrow_mut(); + // Moving-nursery mode (`PERRY_GC_MOVING_LOOP_POLLS`): eagerly build the + // malloc registry so this copying minor can CLASSIFY malloc-tracked + // objects and evacuate, instead of hitting + // `MallocRegistryUnavailable` and falling back to a non-moving minor + // (which reclaims ~nothing on reallocation-heavy async/Map/generator + // code — measured: broad3 192 MiB / 100 fallbacks). The + // O(malloc-objects) rebuild is paid back by the RSS win. Default + // (non-moving) copied minors keep the lazy behavior — see + // `ensure_set_built`'s "keep copied-minor from rebuilding" note. + if super::gc_moving_loop_polls_enabled() && !s.objects.is_empty() { + super::malloc::ensure_set_built(&mut s); + } (s.malloc_registry_available(), s.objects.is_empty()) }); let malloc_registry_rebuild_count_start = MALLOC_REGISTRY_REBUILD_COUNT.with(|c| c.get()); @@ -446,14 +458,62 @@ impl CopyingNurseryCollector { }) } + /// Follow the forwarding chain for a raw metadata key/address the SAME + /// way the evacuation verifier does (`verify::try_rewrite_raw_addr`), so + /// the post-copy rewrite pass and the verifier never DISAGREE about a + /// moved address (#scavenge-cause). + /// + /// The old body classified `addr` via `self.ptrs.classify()` and bailed to + /// `None` whenever that returned `None`. But the verifier follows the + /// forwarding pointer gated only by its live census, so any from-space key + /// the classifier rejected stayed *un-rekeyed* in a runtime mutable + /// metadata table (e.g. `shapes.entries`, keyed by keys-array heap address) + /// — and the verifier then aborted on that still-stale forwarded key + /// (`slot=0x0 ... in runtime mutable root scanner`). Because + /// `rewrite_raw_addr` is the single shared path for every metadata scanner + /// (shapes, map/set, symbol, proxy, weakref, descriptor/class registries, + /// …), the disagreement is fixed for all of them at once. + /// + /// Gate on a heap-region check instead of `classify`: `GC_FLAG_FORWARDED` + /// is set ONLY by `set_forwarding_address`, and during this rewrite pass + /// the from-space is still intact and page-registered + /// (`copying_reset_from_spaces_and_flip` runs strictly later — after both + /// this rewrite pass and the verify pass), so any address in a known heap + /// region carrying that flag IS genuinely forwarded. Mirrors + /// `try_rewrite_raw_addr`'s 64-hop cap and `next == 0 || next == current` + /// stops, returning `rewrote.then_some(current)` (Some only when the + /// address actually moved). pub(super) fn rewrite_raw_addr(&self, addr: usize) -> Option { - let ptr = self.ptrs.classify(addr)?; - unsafe { - if (*ptr.header).gc_flags & GC_FLAG_FORWARDED == 0 { - return None; + if addr < GC_HEADER_SIZE { + return None; + } + let mut current = addr; + let mut rewrote = false; + for _ in 0..64 { + if current < GC_HEADER_SIZE { + return rewrote.then_some(current); + } + let header_addr = current - GC_HEADER_SIZE; + if matches!( + crate::arena::classify_heap_space(header_addr), + crate::arena::HeapSpace::Unknown + ) { + return rewrote.then_some(current); + } + let header = header_addr as *mut GcHeader; + unsafe { + if (*header).gc_flags & GC_FLAG_FORWARDED == 0 { + return rewrote.then_some(current); + } + let next = forwarding_address(header) as usize; + if next == 0 || next == current { + return rewrote.then_some(current); + } + current = next; + rewrote = true; } - Some(forwarding_address(ptr.header) as usize) } + rewrote.then_some(current) } pub(super) fn mark_addr(&mut self, addr: usize) -> Option { diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 1b9dcf7315..da29daef69 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -247,6 +247,29 @@ fn gc_verify_evacuation_enabled() -> bool { ) } +/// Phase-1 de-risking flag (OFF by default). When set, the alloc-point +/// nursery-churn arm (`gc_check_trigger`) runs its direct minor with the +/// PRECISE shadow-stack roots instead of forcing the conservative native +/// scan. The conservative scan makes the copying fast path ineligible +/// (`CopiedMinorFallbackReason::ConservativeStack`), pinning the minor to the +/// non-moving in-place sweep that cannot reclaim array-growth stubs; skipping +/// it lets the evacuating scavenge run and reset the whole young arena in +/// O(live). NOT sound as a production default yet — the alloc point can be +/// register-imprecise — so it stays behind this flag for measurement + +/// `PERRY_GC_VERIFY_EVACUATION` probing only. Pairs with +/// `PERRY_GC_MAJOR_PACING_FLOOR_MB=0` so the #6939 pacing doesn't escalate the +/// minor to a full before the copying path is reached. +pub(super) fn gc_scavenge_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_SCAVENGE").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + #[cfg(test)] fn gc_collect_inner() -> u64 { if defer_gc_request(DeferredGcRequest::Collect(GcTriggerKind::Direct)) { diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 961ed576e8..5d52450038 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -78,15 +78,45 @@ pub(super) const GC_TRIGGER_ABSOLUTE_CEILING: usize = 128 * 1024 * 1024; /// 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()) { + let base = 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()) + }; + // PERRY_GC_SCAVENGE (Phase-1 de-risking, OFF by default): with the + // evacuating young-gen scavenge, a minor is O(live) — copying ~1k live + // objects out of millions allocated — so the 128 MB-and-doubling adaptive + // trigger (tuned for the OLD world where a minor was an expensive O(heap) + // sweep, hence "collect rarely") is exactly backwards. Cap the nursery + // small so scavenges fire often and the young arena's high-water mark + // stays near the cap instead of ballooning to 128-260 MB between the ~8 + // collections the adaptive trigger otherwise allows. Env-tunable via + // PERRY_GC_SCAVENGE_NURSERY_MB for measurement. + if super::gc_scavenge_enabled() || gc_moving_loop_polls_enabled() { + base.min(gc_scavenge_nursery_cap_bytes()) + } else { + base } } +/// Nursery high-water cap used only when `PERRY_GC_SCAVENGE` is on (default +/// 16 MB; override with `PERRY_GC_SCAVENGE_NURSERY_MB`). See +/// `effective_next_arena_trigger`. +pub(super) fn gc_scavenge_nursery_cap_bytes() -> usize { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + std::env::var("PERRY_GC_SCAVENGE_NURSERY_MB") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&mb| mb > 0) + .unwrap_or(16) + .saturating_mul(1024 * 1024) + }) +} + thread_local! { /// Lower bound for the next GC trigger. Bumped after each /// `gc_collect_inner` based on collection effectiveness (see the @@ -317,19 +347,22 @@ pub(crate) fn gc_incremental_enabled() -> bool { }) } -/// Phase 2/3 (opt-in, default OFF): also make the moving minor PRIMARY inside -/// loops — defer the alloc-point nursery collection to a codegen loop back-edge -/// poll (`js_gc_loop_safepoint`) instead of collecting non-moving mid-expression. -/// Off by default because the poll emits a call in every loop, defeating -/// vectorization; when it's emitted only for allocating loops this can flip on. -/// Must match the codegen `moving_safepoint_polls_enabled` (same env) so the -/// deferral and the polls that drain it stay coherent. +/// Make the moving minor PRIMARY inside loops: defer the alloc-point nursery +/// collection to a codegen loop back-edge poll (`js_gc_loop_safepoint`) instead +/// of collecting non-moving mid-expression, so reallocation-heavy loops evacuate +/// (bounded RSS) instead of leaking. **DEFAULT ON** as of the moving-nursery flip +/// — the poll is now emitted only for ALLOCATING loop bodies (`body_may_allocate` +/// in codegen), so numeric/vectorizable loops stay call-free. Kill switch is an +/// explicit `PERRY_GC_MOVING_LOOP_POLLS=0`/`off`/`false` (bisection / max-throughput +/// batch). MUST match codegen `moving_safepoint_polls_enabled` (same env) so the +/// deferral and the polls that drain it stay coherent — a runtime default-on with +/// a codegen default-off (or vice versa) would defer collections that never drain. pub(crate) fn gc_moving_loop_polls_enabled() -> bool { static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { - matches!( + !matches!( std::env::var("PERRY_GC_MOVING_LOOP_POLLS").as_deref(), - Ok("1") | Ok("on") | Ok("true") + Ok("0") | Ok("off") | Ok("false") ) }) } @@ -1189,7 +1222,29 @@ pub fn gc_check_trigger() { // live only in registers, so the conservative native scan retains it — // which also makes copied-minor ineligible for THIS cycle, so the // non-moving minor runs (no relocation hazards at alloc points). - if !gc_budgeted_cycle_active() && super::roots::registered_root_scanners_block_budgeted_gc() { + // PERRY_GC_SCAVENGE (Phase-1 de-risking, OFF by default): when the budgeted + // stepper is NOT blocked (all scanners budgeted), the nursery-churn triggers + // fall through to the budgeted mutator-assist step below, which is + // deliberately non-moving (`low_pause_non_moving = is_budgeted()`), so a + // reallocation-heavy loop's minors free nothing. Route those triggers to the + // direct (non-budgeted, atomic) minor here instead so the copying/evacuating + // fast path can run (see the `force_full_scan` skip below). + // `gc_moving_loop_polls_enabled()`: the SOUND moving-nursery path. When loop + // polls are on, entering this block routes nursery pressure AWAY from the + // budgeted non-moving stepper (which would otherwise own it and free nothing + // on reallocation loops) and into the defer arm below, which sets + // GC_SAFEPOINT_PENDING and returns — the collection then runs as an + // evacuating MOVING minor at the next precise loop back-edge safepoint + // (`js_gc_loop_safepoint` → `gc_safepoint_moving_minor`), NOT here at the + // register-imprecise alloc point. Unlike `gc_scavenge_enabled()` (which skips + // the conservative scan HERE — sound only if the alloc point is precise), the + // loop-polls path never reaches the skip: it always defers to a real + // safepoint, so it is sound by construction. + if !gc_budgeted_cycle_active() + && (super::gc_scavenge_enabled() + || gc_moving_loop_polls_enabled() + || super::roots::registered_root_scanners_block_budgeted_gc()) + { let direct_kind = match gc_budgeted_due_trigger() { Some(BudgetedGcTrigger::ArenaBytes) => Some(GcTriggerKind::ArenaBytes), Some(BudgetedGcTrigger::MallocCount) => Some(GcTriggerKind::MallocCount), @@ -1211,7 +1266,16 @@ pub fn gc_check_trigger() { } let pre_in_use = crate::arena::arena_in_use_bytes(); let pre_malloc_count = malloc_object_count(); - let _scan = super::roots::ManualGcScanGuard::force_full_scan(); + // PERRY_GC_SCAVENGE (Phase-1 de-risking, OFF by default): skip the + // conservative native-stack scan so this direct minor runs with the + // PRECISE shadow-stack roots and the copying fast path becomes + // eligible (an evacuating scavenge that resets the whole young arena + // in O(live)). The default path keeps `force_full_scan` — at an + // arbitrary alloc point a value mid-construction may live only in + // registers, which the conservative scan retains (and which makes + // copied-minor ineligible, so the non-moving minor runs). + let _scan = (!super::gc_scavenge_enabled()) + .then(super::roots::ManualGcScanGuard::force_full_scan); let outcome = super::gc_collect_minor_with_trigger(GcTriggerSnapshot::capture(kind)); // Re-baseline the arming trigger after the direct minor, mirroring // `gc_finish_budgeted_cycle`. This arm is taken whenever @@ -1398,11 +1462,12 @@ pub(crate) fn gc_safepoint_moving_minor() { // Same start guards the budgeted collector uses, minus the (here // irrelevant) scanner block: never collect mid-allocation, inside a // runtime handle scope, in an unsafe FFI zone, or during a budgeted cycle. - if GC_FLAGS.with(|f| f.get()) & (GC_FLAG_IN_ALLOC | GC_FLAG_SUPPRESSED) != 0 - || gc_blocked_by_unsafe_zone() - || GC_ROOT_LOCK_DEPTH.with(|depth| depth.get() != 0) - || gc_budgeted_cycle_active() - { + let flags = GC_FLAGS.with(|f| f.get()); + let in_alloc = flags & (GC_FLAG_IN_ALLOC | GC_FLAG_SUPPRESSED) != 0; + let unsafe_zone = gc_blocked_by_unsafe_zone(); + let root_lock = GC_ROOT_LOCK_DEPTH.with(|depth| depth.get() != 0); + let budgeted = gc_budgeted_cycle_active(); + if in_alloc || unsafe_zone || root_lock || budgeted { // Blocked right now — leave GC_SAFEPOINT_PENDING set so the next poll // retries; do not clear it here. return; @@ -1415,7 +1480,10 @@ pub(crate) fn gc_safepoint_moving_minor() { let kind = match gc_budgeted_due_trigger() { Some(BudgetedGcTrigger::ArenaBytes) => GcTriggerKind::ArenaBytes, Some(BudgetedGcTrigger::MallocCount) => GcTriggerKind::MallocCount, - _ => return, + _ => { + // No nursery-pressure trigger is due — nothing to collect here. + return; + } }; let pre_in_use = crate::arena::arena_in_use_bytes(); let pre_malloc_count = malloc_object_count(); diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 397359299e..dde9f27660 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -472,6 +472,8 @@ pub(crate) fn map_header_moved_for_gc(old_addr: usize, new_addr: usize) { MAP_REGISTRY.with(|r| { let mut registry = r.borrow_mut(); let Some(allocation) = registry.remove(&old_addr) else { + // Old address had no side-allocation record (e.g. an inline-only + // Map) — nothing to re-key. return; }; if registry.contains_key(&new_addr) { @@ -546,8 +548,9 @@ fn is_dead_copied_minor_from_space_map(addr: usize) -> bool { return false; } let flags = (*header).gc_flags; - flags & crate::gc::GC_FLAG_ARENA != 0 - && flags & (crate::gc::GC_FLAG_MARKED | crate::gc::GC_FLAG_FORWARDED) == 0 + let dead = flags & crate::gc::GC_FLAG_ARENA != 0 + && flags & (crate::gc::GC_FLAG_MARKED | crate::gc::GC_FLAG_FORWARDED) == 0; + dead } } @@ -1204,9 +1207,14 @@ unsafe fn ensure_capacity(map: *mut MapHeader) -> bool { (*map).capacity = new_capacity; MAP_REGISTRY.with(|registry| { let mut registry = registry.borrow_mut(); - let allocation = registry - .get_mut(&(map as usize)) - .expect("grown Map must retain its side-allocation owner record"); + let allocation = match registry.get_mut(&(map as usize)) { + Some(a) => a, + None => { + // Invariant: every side-allocating Map is registered at alloc + // (js_map_alloc → register_map), so a grown Map must be present. + panic!("grown Map must retain its side-allocation owner record"); + } + }; allocation.entries = new_entries; allocation.capacity = new_capacity as usize; });