diff --git a/crates/perry-runtime/src/gc/heap_snapshot.rs b/crates/perry-runtime/src/gc/heap_snapshot.rs
index e6f69d7ae9..b9405d178b 100644
--- a/crates/perry-runtime/src/gc/heap_snapshot.rs
+++ b/crates/perry-runtime/src/gc/heap_snapshot.rs
@@ -229,17 +229,10 @@ pub fn gc_build_v8_heap_snapshot_json() -> String {
// Approximate the live set: collect first so unreachable malloc
// objects are freed and fully-dead nursery blocks are reset before
// the walk picks the population (Node's writeHeapSnapshot also
- // forces a full GC). Force the conservative native-stack scan for
- // this collection: in the default `auto` mode a full collect can
- // miss top-level locals held only on the native stack and reclaim
- // live objects (pre-existing explicit-`gc()` bug, see #4977 —
- // repro: `const k = {nested:{deep:"s"}}; gc();` corrupts
- // `k.nested.deep`). A diagnostics API must never make that worse.
- let prev = super::roots::set_conservative_stack_scan_override(Some(
- super::roots::ConservativeStackScanMode::Full,
- ));
+ // forces a full GC). `js_gc_collect` forces the conservative
+ // native-stack scan when no per-thread override is pinned (#4977),
+ // so top-level locals held only on the native stack survive.
js_gc_collect();
- super::roots::set_conservative_stack_scan_override(prev);
// Free-list slots are dead-but-unreclaimed space inside live
// blocks; exclude them from the dump.
diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs
index cfcd427492..687283e3bd 100644
--- a/crates/perry-runtime/src/gc/policy.rs
+++ b/crates/perry-runtime/src/gc/policy.rs
@@ -552,10 +552,7 @@ pub(super) fn flush_deferred_gc_request() {
if manual_gc_blocked_by_unsafe_zone() {
return;
}
- crate::weakref::clear_pending_finalization_jobs();
- gc_collect_inner_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual))
- .emit_after_current();
- crate::weakref::queue_pending_finalization_callbacks_after_gc();
+ manual_gc_collect_now();
}
DeferredGcRequest::Collect(kind) => {
if gc_blocked_by_unsafe_zone() {
@@ -1551,6 +1548,14 @@ pub extern "C" fn js_gc_collect() {
if defer_gc_request(DeferredGcRequest::Collect(GcTriggerKind::Manual)) {
return;
}
+ manual_gc_collect_now();
+}
+
+/// Run an explicit (`gc()`) full collection. The `gc()` callsite may hold live
+/// module-init/top-level locals only on the native stack, so the collection
+/// 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();
gc_collect_inner_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual))
.emit_after_current();
diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs
index f5e2e4e496..7d1bcd538a 100644
--- a/crates/perry-runtime/src/gc/roots.rs
+++ b/crates/perry-runtime/src/gc/roots.rs
@@ -178,7 +178,7 @@ thread_local! {
/// set this to `Auto` (skip) inside their controlled-root scopes so a forced
/// collection still reclaims the objects they hold only as native-stack
/// locals; see `ScopedRootScannerRegistryGuard`.
- static CONSERVATIVE_STACK_SCAN_OVERRIDE: std::cell::Cell