diff --git a/changelog.d/10587-argument-list-roots.md b/changelog.d/10587-argument-list-roots.md new file mode 100644 index 0000000000..6e42628f45 --- /dev/null +++ b/changelog.d/10587-argument-list-roots.md @@ -0,0 +1,14 @@ +Fixed three GC rooting gaps in #10532's dynamic-call argument-list handling: +`Reflect.apply` held the callee, receiver and arguments in plain Rust locals +across the one closure-rebind shape that allocates (a concise/object-literal +method's `this` clone); `CreateListFromArrayLike`'s array-like path reused a +raw source-object pointer read once before its per-index allocating loop +instead of re-deriving it after each allocation; and a `(...fixed, ...rest)` +body that also synthesizes `arguments` could hand its callee the rest +array's pre-move address once the `arguments` array's allocation moved it. A +new `rebind_explicit_this_allocates` predicate lets `Reflect.apply`'s common +(non-cloning) path stay allocation-free and unrooted; only the one shape that +actually allocates pays for rooting, so the fix measures ~0% instead of the ++38.8% an earlier, unconditionally-rooted attempt cost. New regression tests +arm a named collection point at each fixed allocation and assert the callee +observes post-collection addresses. diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs index b9c829849a..34581b3eee 100644 --- a/crates/perry-runtime/src/closure/dispatch.rs +++ b/crates/perry-runtime/src/closure/dispatch.rs @@ -22,7 +22,7 @@ mod value_call; pub(crate) use bound::{ bound_function_lazy_name, bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, - reify_function_method_value, + rebind_explicit_this_allocates, reify_function_method_value, }; pub use bound::{dispatch_bound_function, dispatch_bound_method, js_function_bind}; diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 66ebb000ca..c73f17a5bb 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -412,6 +412,37 @@ pub(crate) fn coerce_call_this(target: f64, this_arg: f64) -> f64 { /// - bound functions and non-closure values /// (`clone_closure_rebind_this` no-ops on these — it only rewrites a /// `CAPTURES_THIS` slot). +/// Does [`rebind_explicit_this`] ALLOCATE for this target? +/// +/// Only the clone does, and it happens for one shape: a non-arrow closure that +/// captures `this` in a reserved slot and may be re-bound. Everything else — +/// a plain function, an arrow, a bound function, a non-closure value — comes +/// back unchanged, allocation-free. A caller that has GC values in Rust locals +/// can therefore skip rooting them entirely for the common callee, and pay for +/// handles only on the shape that can collect underneath it. +/// +/// Kept in step with `clone_closure_rebind_this`'s early-outs by +/// `rebind_predicate_tests` below, which asserts the two agree on every shape. +#[inline] +pub(crate) fn rebind_explicit_this_allocates(target: f64) -> bool { + let bits = target.to_bits(); + if bits & 0xFFFF_0000_0000_0000 != 0x7FFD_0000_0000_0000 { + return false; + } + let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if ptr < 0x100000 || !crate::closure::is_closure_ptr(ptr) { + return false; + } + let header = ptr as *const ClosureHeader; + if crate::closure::closure_is_arrow(header) { + return false; + } + let raw_count = unsafe { (*header).capture_count }; + raw_count & CAPTURES_THIS_FLAG != 0 + && raw_count & NO_THIS_REBIND_FLAG == 0 + && crate::closure::real_capture_count(raw_count) > 0 +} + #[inline] pub(crate) fn rebind_explicit_this(target: f64, this_arg: f64) -> f64 { let bits = target.to_bits(); @@ -761,3 +792,82 @@ pub(crate) unsafe fn reify_function_method_value(receiver: f64, method: &'static crate::gc::runtime_write_barrier_root_heap_word(closure as u64); f64::from_bits(crate::value::JSValue::pointer(closure as *mut u8).bits()) } + +#[cfg(test)] +mod rebind_predicate_tests { + use super::*; + + // Distinct bodies on purpose: arrow-ness is registered per func_ptr, and + // two `extern "C"` bodies with identical machine code get folded to one + // address by the linker — which silently makes every case in this test the + // same closure body. + extern "C" fn arrow_probe(_closure: *const ClosureHeader) -> f64 { + 1.0 + } + + extern "C" fn method_probe(_closure: *const ClosureHeader) -> f64 { + 2.0 + } + + fn closure_value(body: *const u8, capture_count: u32) -> f64 { + let closure = crate::closure::js_closure_alloc(body, capture_count); + f64::from_bits(crate::value::JSValue::pointer(closure as *mut u8).bits()) + } + + /// The predicate exists to let `Reflect.apply` skip rooting when nothing + /// can collect, so it has to answer exactly the question + /// `rebind_explicit_this` answers with its clone: saying "no" where the + /// rebind allocates is a rooting hole, and saying "yes" everywhere is the + /// per-call cost it was written to avoid. + #[test] + fn the_predicate_agrees_with_what_the_rebind_actually_does() { + let receiver = f64::from_bits(crate::value::TAG_UNDEFINED); + let method_body = method_probe as *const u8; + let arrow_body = arrow_probe as *const u8; + crate::closure::js_register_closure_arrow_function(arrow_body); + + // The one shape that clones, and the shapes that look like it but + // return the target untouched. + let method = closure_value(method_body, CAPTURES_THIS_FLAG | 1); + let cases = [ + ("concise method with a reserved `this`", method, true), + ( + "arrow with a captured this", + closure_value(arrow_body, CAPTURES_THIS_FLAG | 1), + false, + ), + ( + "generator step closure (no rebind)", + closure_value(method_body, CAPTURES_THIS_FLAG | NO_THIS_REBIND_FLAG | 1), + false, + ), + ( + "captures `this` but has no capture slots", + closure_value(method_body, CAPTURES_THIS_FLAG), + false, + ), + ("plain closure", closure_value(method_body, 0), false), + ("a plain number", 42.0, false), + ( + "undefined", + f64::from_bits(crate::value::TAG_UNDEFINED), + false, + ), + ]; + for (what, value, clones) in cases { + let rebound_differs = + rebind_explicit_this(value, receiver).to_bits() != value.to_bits(); + assert_eq!( + rebound_differs, + clones, + "premise: {what} must {} clone", + if clones { "" } else { "not" } + ); + assert_eq!( + rebind_explicit_this_allocates(value), + rebound_differs, + "{what}: the predicate and the rebind must agree" + ); + } + } +} diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index a7e4210220..57d7404170 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -40,23 +40,23 @@ pub(crate) fn closure_side_table_census() -> Vec Option { #[inline(always)] pub unsafe fn build_rest_array(values: &[f64], arguments_object: bool) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); - let value_handles: Vec<_> = values - .iter() - .map(|value| scope.root_nanbox_f64(*value)) - .collect(); + let value_handles = scope.root_nanbox_f64_slice(values); + build_rest_array_rooted(&value_handles, arguments_object) +} + +/// [`build_rest_array`] for a caller that already holds its values in handles. +/// The array allocation and every push can collect, so the values have to be +/// read out of the handles anyway — a caller that has them keeps one rooting +/// pass instead of two. +pub unsafe fn build_rest_array_rooted( + values: &[crate::gc::RuntimeHandle<'_>], + arguments_object: bool, +) -> f64 { let arr = crate::array::js_array_alloc(values.len() as u32); let mut cur = arr; - for handle in value_handles.iter() { + for handle in values.iter() { cur = crate::array::js_array_push_f64(cur, handle.get_nanbox_f64()); } if arguments_object { @@ -1021,19 +1029,30 @@ pub unsafe fn dispatch_rest_bundled( .map(|value| arg_scope.root_nanbox_f64(*value)) .collect(); - let rest_slice: &[f64] = if kind == RestDispatchKind::SyntheticArguments { - args - } else if provided > k { - &args[k..] - } else { - &[] - }; - let rest_double = build_rest_array(rest_slice, kind == RestDispatchKind::SyntheticArguments); - let all_arguments_double = if kind == RestDispatchKind::UserRestAndArguments { - Some(build_rest_array(args, true)) + // Both arrays are built from the arguments this scope already roots, so + // they read current values however many times the builder collects — and + // the second array's allocation can move the first, which the body is + // about to receive, so the first takes a handle too (#10532 review). + let rest_handles: &[crate::gc::RuntimeHandle<'_>] = + if kind == RestDispatchKind::SyntheticArguments { + &arg_handles + } else if provided > k { + &arg_handles[k..] + } else { + &[] + }; + let rest_handle = arg_scope.root_nanbox_f64(build_rest_array_rooted( + rest_handles, + kind == RestDispatchKind::SyntheticArguments, + )); + crate::gc::collection_point("closure.rest_bundle.between_arrays"); + let all_arguments_handle = if kind == RestDispatchKind::UserRestAndArguments { + Some(arg_scope.root_nanbox_f64(build_rest_array_rooted(&arg_handles, true))) } else { None }; + let rest_double = rest_handle.get_nanbox_f64(); + let all_arguments_double = all_arguments_handle.map(|handle| handle.get_nanbox_f64()); // Read fixed args, padding with undefined when caller under-supplied. macro_rules! a { diff --git a/crates/perry-runtime/src/gc/collection_points.rs b/crates/perry-runtime/src/gc/collection_points.rs new file mode 100644 index 0000000000..a732113b57 --- /dev/null +++ b/crates/perry-runtime/src/gc/collection_points.rs @@ -0,0 +1,56 @@ +//! Named collection points for rooting regression tests. +//! +//! A runtime helper that holds a GC value in a Rust local across an allocation +//! only goes wrong when a moving collection lands in that exact window, which +//! an allocation-trigger test cannot aim at a specific allocation inside one +//! call. A test arms a site by name; the next time the helper passes that site +//! it runs a copying minor, so the test observes what the helper holds after a +//! collection at precisely that point. The same one-shot shape as +//! `set.rs`'s `test_force_next_set_helper_gc`, shared instead of repeated. +//! +//! Outside `cfg(test)` a collection point is an empty inline function. + +#[cfg(test)] +crate::perry_thread_local! { + static ARMED_SITE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run one copying minor the next time `collection_point(site)` is reached on +/// this thread. +#[cfg(test)] +pub(crate) fn arm_collection_point(site: &'static str) { + arm_collection_point_after(site, 0); +} + +/// Like [`arm_collection_point`], but skips the first `skip` hits on `site` +/// before firing on hit number `skip + 1`. A helper called once per loop +/// iteration only exposes ONE named site, so this is how a test puts the +/// forced collection on a LATER iteration than the first -- e.g. to check +/// that a value already read (and copied into a plain, unrooted local) on an +/// earlier iteration survives a collection triggered by a later one. +#[cfg(test)] +pub(crate) fn arm_collection_point_after(site: &'static str, skip: u32) { + ARMED_SITE.with(|armed| armed.set(Some((site, skip)))); +} + +#[cfg(test)] +pub(crate) fn collection_point(site: &'static str) { + let hit = ARMED_SITE.with(|armed| match armed.get() { + Some((armed_site, 0)) if armed_site == site => { + armed.set(None); + true + } + Some((armed_site, remaining)) if armed_site == site => { + armed.set(Some((armed_site, remaining - 1))); + false + } + _ => false, + }); + if hit { + let _ = super::gc_collect_minor(); + } +} + +#[cfg(not(test))] +#[inline(always)] +pub(crate) fn collection_point(_site: &'static str) {} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index f897b34fb4..c062e634f4 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -49,6 +49,10 @@ pub(crate) use policy::young_generation_holds_a_nursery; pub use policy::*; mod progress; pub use progress::*; +mod collection_points; +pub(crate) use collection_points::collection_point; +#[cfg(test)] +pub(crate) use collection_points::{arm_collection_point, arm_collection_point_after}; mod heap_budget; pub(crate) use heap_budget::*; mod pressure; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 0a4f3e5294..26e7063608 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -3,6 +3,7 @@ use super::support::*; use std::cell::Cell; mod arraylike_callbacks; mod bound_method_builder; +mod call_argument_lists; mod callback_scanners; mod fs_options_object; mod generator_attach_prototype; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs new file mode 100644 index 0000000000..13f79d52f9 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs @@ -0,0 +1,406 @@ +//! Argument lists a runtime dispatcher holds across an allocation. +//! +//! `Reflect.apply` and the rest/`arguments` bundler both read a call's +//! arguments into Rust locals and then allocate — a rebound closure, an index +//! key, the `arguments` array — before the callee ever sees them. A local is +//! not a GC root, so a moving collection in that window leaves the callee with +//! from-space addresses while the values themselves live on somewhere else. +//! +//! An allocation-trigger test cannot aim at one allocation inside one call, so +//! each test arms the named collection point that stands for it +//! (`gc::collection_points`) and then asserts the callee received each value's +//! POST-collection location. Every test also asserts its premise — a copying +//! minor ran and the value really moved — because "the callee saw the right +//! address" passes vacuously if nothing moved. + +use super::*; +use crate::ObjectHeader; +use std::cell::RefCell; + +crate::perry_thread_local! { + static SEEN: RefCell> = RefCell::new(Vec::new()); +} + +fn record(values: &[u64]) { + SEEN.with(|seen| seen.borrow_mut().extend_from_slice(values)); +} + +fn seen() -> Vec { + SEEN.with(|seen| std::mem::take(&mut *seen.borrow_mut())) +} + +extern "C" fn record_this_and_six_args( + _closure: *const crate::closure::ClosureHeader, + a0: f64, + a1: f64, + _a2: f64, + _a3: f64, + _a4: f64, + a5: f64, +) -> f64 { + record(&[ + crate::object::js_implicit_this_get().to_bits(), + a0.to_bits(), + a1.to_bits(), + a5.to_bits(), + ]); + 0.0 +} + +extern "C" fn record_two_args( + _closure: *const crate::closure::ClosureHeader, + a0: f64, + a1: f64, +) -> f64 { + record(&[a0.to_bits(), a1.to_bits()]); + 0.0 +} + +#[allow(clippy::too_many_arguments)] +extern "C" fn record_rest_and_arguments_after_16( + _closure: *const crate::closure::ClosureHeader, + _a0: f64, + _a1: f64, + _a2: f64, + _a3: f64, + _a4: f64, + _a5: f64, + _a6: f64, + _a7: f64, + _a8: f64, + _a9: f64, + _a10: f64, + _a11: f64, + _a12: f64, + _a13: f64, + _a14: f64, + _a15: f64, + rest: f64, + arguments: f64, +) -> f64 { + let rest_ptr = (rest.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + let arguments_ptr = (arguments.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + let rest_len = crate::array::js_array_length(rest_ptr); + record(&[u64::from(rest_len)]); + // A from-space read under `PoisonOnly` returns the poison word, so the + // length above is the discriminating observation; only read elements when + // it is the length this call really has. + if rest_len == 2 { + record(&[ + crate::array::js_array_get(rest_ptr, 0).bits(), + u64::from(crate::array::js_array_length(arguments_ptr)), + crate::array::js_array_get(arguments_ptr, 16).bits(), + ]); + } + 0.0 +} + +/// A closure the armed minor must NOT move: the callee identity is not what +/// these tests are about, and a from-space callee pointer would throw +/// "value is not a function" out of a unit test instead of failing an +/// assertion. Two minors under a pinned promotion age tenure it into the +/// non-moving old generation. +fn tenured_closure(body: *const u8, capture_count: u32) -> *mut crate::closure::ClosureHeader { + let closure = crate::closure::js_closure_alloc(body, capture_count); + let scope = RuntimeHandleScope::new(); + let handle = scope.root_raw_mut_ptr(closure); + { + let _tenuring = crate::gc::tenuring::set_survivals_for_test(1); + let _ = gc_collect_minor(); + let _ = gc_collect_minor(); + } + // #7341: nothing allocates after this read within the function (the two + // minors above already ran), so it is the "final read in a scope with + // nothing after it" the ratchet's own docstring carves out — but + // `with_mut_ptr` keeps it out of the raw-handle debt count without + // changing behavior: the closure runs immediately and hands the pointer + // straight back. + let tenured = handle.with_mut_ptr::(|ptr| ptr); + assert!( + !crate::arena::pointer_in_nursery(tenured as usize), + "premise: the callee must be out of the nursery so only the arguments move" + ); + tenured +} + +fn array_value(values: &[f64]) -> f64 { + let mut arr = crate::array::js_array_alloc(values.len() as u32); + for value in values { + arr = crate::array::js_array_push_f64(arr, *value); + } + f64::from_bits(ptr_bits(arr as usize)) +} + +fn array_element(value: f64, index: u32) -> u64 { + let arr = (value.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + crate::array::js_array_get(arr, index).bits() +} + +/// #10532 review finding: `Reflect.apply` held the callee, the receiver and +/// every argument in plain Rust locals while `rebind_explicit_this` allocated a +/// rebound closure. A collection there left the callee reading from-space +/// addresses for all three. +#[test] +fn reflect_apply_roots_receiver_and_arguments_across_the_rebind_allocation() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + // A concise/object-literal method — a non-arrow closure with a reserved + // `this` capture — is the shape whose rebind CLONES, and that clone is the + // allocation this test collects inside. + let closure = tenured_closure( + record_this_and_six_args as *const u8, + crate::closure::CAPTURES_THIS_FLAG | 1, + ); + let scope = RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f64::from_bits(ptr_bits(closure as usize))); + let receiver = scope.root_nanbox_f64(f64::from_bits(ptr_bits(crate::object::js_object_alloc( + 0, 0, + ) as usize))); + let receiver_original = (receiver.get_nanbox_f64().to_bits() & POINTER_MASK) as usize; + let list = scope.root_nanbox_f64(array_value(&[ + test_string_value(b"first"), + test_string_value(b"second"), + 2.0, + 3.0, + 4.0, + test_string_value(b"sixth"), + ])); + let first_original = (array_element(list.get_nanbox_f64(), 0) & POINTER_MASK) as usize; + + crate::gc::arm_collection_point("reflect.apply.rebind"); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee.get_nanbox_f64(), + receiver.get_nanbox_f64(), + list.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + let receiver_now = receiver.get_nanbox_f64(); + let list_now = list.get_nanbox_f64(); + assert_ne!( + (receiver_now.to_bits() & POINTER_MASK) as usize, + receiver_original, + "premise: the receiver moved" + ); + assert_ne!( + (array_element(list_now, 0) & POINTER_MASK) as usize, + first_original, + "premise: the first argument moved" + ); + assert_eq!( + seen(), + vec![ + receiver_now.to_bits(), + array_element(list_now, 0), + array_element(list_now, 1), + array_element(list_now, 5), + ], + "Reflect.apply must hand the callee the post-collection receiver and \ + arguments, not the addresses they had before the rebind allocated" + ); +} + +/// #10532 review finding: `CreateListFromArrayLike`'s array-like path allocated +/// an index key per element while the source object and the elements collected +/// so far sat in Rust locals. +#[test] +fn array_like_argument_lists_root_the_source_and_the_collected_elements() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let closure = tenured_closure(record_two_args as *const u8, 0); + let scope = RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f64::from_bits(ptr_bits(closure as usize))); + let source = crate::object::js_object_alloc(0, 3); + let source_value = scope.root_nanbox_f64(f64::from_bits(ptr_bits(source as usize))); + for (name, value) in [ + (&b"length"[..], 2.0), + (&b"0"[..], test_string_value(b"zero")), + (&b"1"[..], test_string_value(b"one")), + ] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source_value.get_nanbox_f64().to_bits() & POINTER_MASK) as *mut ObjectHeader; + crate::object::js_object_set_field_by_name(obj, key, value); + } + let zero_original = (element_by_name(&source_value, b"0") & POINTER_MASK) as usize; + + crate::gc::arm_collection_point("reflect.list_from_array_like.index_key"); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + source_value.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + assert_ne!( + (element_by_name(&source_value, b"0") & POINTER_MASK) as usize, + zero_original, + "premise: the element moved" + ); + assert_eq!( + seen(), + vec![ + element_by_name(&source_value, b"0"), + element_by_name(&source_value, b"1"), + ], + "an array-like argument list must be read out of the post-collection \ + source object" + ); +} + +fn element_by_name(source: &RuntimeHandle<'_>, name: &[u8]) -> u64 { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source.get_nanbox_f64().to_bits() & POINTER_MASK) as *const ObjectHeader; + crate::object::js_object_get_field_by_name_f64(obj, key).to_bits() +} + +/// #10532 review finding: a `(…fixed, ...rest)` body that also takes a +/// synthesized `arguments` builds TWO arrays. The second allocation could move +/// the first, and the bundler passed the callee the address the first array had +/// before it moved. From-space is poisoned here so a stale read is visible as a +/// wrong `rest.length` instead of intact bytes that happen to still be there. +#[test] +fn rest_bundling_roots_the_rest_array_across_the_arguments_array() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + let _protection = + crate::arena::ProtectionModeGuard::set(crate::arena::FromSpaceProtection::PoisonOnly); + register_runtime_handle_root_scanner_for_tests(); + + let body = record_rest_and_arguments_after_16 as *const u8; + crate::closure::js_register_closure_rest_and_arguments(body, 16); + let closure = crate::closure::js_closure_alloc(body, 0); + let scope = RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_mut_ptr(closure); + let tail = scope.root_nanbox_f64(test_string_value(b"rest-tail")); + let tail_original = (tail.get_nanbox_f64().to_bits() & POINTER_MASK) as usize; + + let mut args: Vec = (0..16).map(f64::from).collect(); + args.push(tail.get_nanbox_f64()); + args.push(99.0); + + crate::gc::arm_collection_point("closure.rest_bundle.between_arrays"); + let before = crate::gc::copying_minor_cycles(); + let retired_before = crate::arena::quarantine_stats().sets_retired; + // #7341: `js_closure_call_array` is itself the self-rooting entry point + // under test here, so the closure pointer is a scoped argument to it — + // `with_mut_ptr` is the blessed shape for that instead of a bare read. + closure_handle.with_mut_ptr::(|ptr| unsafe { + crate::closure::js_closure_call_array(ptr as i64, args.as_ptr(), args.len() as i64); + }); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + assert!( + crate::arena::quarantine_stats().sets_retired > retired_before, + "premise: the minor retired from-space, so a stale read finds poison" + ); + assert_ne!( + (tail.get_nanbox_f64().to_bits() & POINTER_MASK) as usize, + tail_original, + "premise: the trailing argument moved" + ); + assert_eq!( + seen(), + vec![ + 2, + tail.get_nanbox_f64().to_bits(), + 18, + tail.get_nanbox_f64().to_bits() + ], + "the rest array and the arguments object must both be the post-collection \ + arrays, holding the post-collection argument values" + ); +} + +/// #10532 review (round 2): a raw, untagged heap-pointer bit pattern (the +/// Promise executor's resolve/reject shape, `top16 == 0`) stored as an +/// array-like element is exactly as movable as a NaN-boxed pointer, but +/// `JSValue::is_pointer()` does not recognize it, and `root_nanbox_f64`'s +/// `Nanbox` scanner only rewrites POINTER_TAG/STRING_TAG/BIGINT_TAG bit +/// patterns -- it would silently do nothing for a raw one. +#[test] +fn array_like_argument_lists_root_raw_untagged_heap_pointer_elements() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let callee = tenured_closure(record_two_args as *const u8, 0); + let scope = RuntimeHandleScope::new(); + let callee_handle = scope.root_nanbox_f64(f64::from_bits(ptr_bits(callee as usize))); + + // A closure left in the nursery, referenced ONLY by its raw (unboxed) + // address -- the exact shape `js_promise_new_with_executor` hands a + // user's executor for `resolve`/`reject` (see proxy.rs's + // `ValueMoveKind::RawHeapWord` doc comment). + let raw_closure = crate::closure::js_closure_alloc(record_two_args as *const u8, 0); + let observer = scope.root_raw_mut_ptr(raw_closure); + let raw_bits_before = raw_closure as usize as u64; + + // Exactly 2 elements to match `record_two_args`'s declared arity -- an + // under-applied raw extern "C" test body has no registered arity to pad + // against, so this keeps the call itself unremarkable and isolates the + // one thing under test: whether element 0 survives as a raw heap word. + let source = crate::object::js_object_alloc(0, 3); + let source_value = scope.root_nanbox_f64(f64::from_bits(ptr_bits(source as usize))); + for (name, value) in [ + (&b"length"[..], 2.0), + (&b"0"[..], f64::from_bits(raw_bits_before)), + (&b"1"[..], 7.0), + ] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source_value.get_nanbox_f64().to_bits() & POINTER_MASK) as *mut ObjectHeader; + crate::object::js_object_set_field_by_name(obj, key, value); + } + + // Fire the forced collection on the SECOND loop iteration (reading + // element "1"), not the first: element "0"'s raw pointer is read on the + // first iteration and, without this fix, copied bare into `out[0]` with + // nothing rooting it. Firing the collection a step later is what puts + // that already-read copy at risk, instead of the collection landing + // before element "0" is ever read (which every read would trivially + // survive, fix or no fix). + crate::gc::arm_collection_point_after("reflect.list_from_array_like.index_key", 1); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee_handle.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + source_value.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + // #7341: nothing allocates after this read; `with_mut_ptr` keeps it out + // of the raw-handle debt count (see `tenured_closure` above). + let raw_bits_after = + observer.with_mut_ptr::(|ptr| ptr as usize as u64); + assert_ne!( + raw_bits_after, raw_bits_before, + "premise: the raw-bit closure moved" + ); + assert_eq!( + seen(), + vec![raw_bits_after, 7.0_f64.to_bits()], + "element \"0\" must be the post-collection raw address, not the \ + pre-collection one read before the later collection at element \"1\"" + ); +} diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f90c9d087c..08acf01a06 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -884,17 +884,83 @@ fn create_list_from_array_like(value: f64) -> Vec { } else { 0 }; + // Every index key allocates, and a property read can run a getter: the + // source object and the elements read so far must survive both, so the + // list handed back holds their post-collection addresses (#10532 review). + // Only values a collection can relocate take a handle — an all-primitive + // argument list pays one scope and a tag test per element. + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_nanbox_f64(value); let mut out = Vec::with_capacity(len); - let obj_ptr = extract_pointer(value.to_bits()) as *const crate::ObjectHeader; + let mut moved: Vec<(usize, MovedElement<'_>)> = Vec::new(); for i in 0..len { let idx_str = i.to_string(); + crate::gc::collection_point("reflect.list_from_array_like.index_key"); let key = crate::string::js_string_from_bytes(idx_str.as_ptr(), idx_str.len() as u32); + let obj_ptr = + extract_pointer(source.get_nanbox_f64().to_bits()) as *const crate::ObjectHeader; let v = crate::object::js_object_get_field_by_name_f64(obj_ptr, key); + match value_move_kind(v) { + ValueMoveKind::Tagged => { + moved.push((i, MovedElement::Tagged(scope.root_nanbox_f64(v)))) + } + ValueMoveKind::RawHeapWord => moved.push(( + i, + MovedElement::RawHeapWord(scope.root_heap_word_u64(v.to_bits())), + )), + ValueMoveKind::Immediate => {} + } out.push(v); } + for (index, handle) in moved { + out[index] = match handle { + MovedElement::Tagged(h) => h.get_nanbox_f64(), + MovedElement::RawHeapWord(h) => f64::from_bits(h.get_heap_word_u64()), + }; + } out } +/// Which rooting a `create_list_from_array_like` element needs, if any. +enum ValueMoveKind { + /// No handle needed: an immediate value no collection can touch. + Immediate, + /// A NaN-boxed pointer/string/BigInt -- `root_nanbox_f64`'s `Nanbox` slot + /// rewrites these. + Tagged, + /// A raw, untagged heap-pointer bit pattern (`top16 == 0`) -- e.g. the + /// Promise executor's resolve/reject closures from + /// `js_promise_new_with_executor`, or a TypedArray/Buffer pointer handed + /// through as `bitcast i64 → double` on some platforms (see + /// `object/native_call_method.rs` and `value/dynamic_object.rs` for the + /// same representation). `root_nanbox_f64`'s scanner only rewrites + /// POINTER_TAG/STRING_TAG/BIGINT_TAG bit patterns and would silently do + /// nothing for one of these, so it needs the raw-aware `HeapWord` slot + /// instead (#10532 review). + RawHeapWord, +} + +/// A value a moving collection can relocate, and therefore the only kind that +/// needs a handle when a runtime helper holds it across an allocation. Numbers, +/// booleans, `undefined`/`null`, int32s, short strings and class refs are +/// immediate values that no collection can touch. +#[inline] +fn value_move_kind(value: f64) -> ValueMoveKind { + let jsvalue = crate::value::JSValue::from_bits(value.to_bits()); + if jsvalue.is_pointer() || jsvalue.is_string() || jsvalue.is_bigint() { + return ValueMoveKind::Tagged; + } + if crate::value::addr_class::is_plausible_heap_addr(value.to_bits() as usize) { + return ValueMoveKind::RawHeapWord; + } + ValueMoveKind::Immediate +} + +enum MovedElement<'a> { + Tagged(crate::gc::RuntimeHandle<'a>), + RawHeapWord(crate::gc::RuntimeHandle<'a>), +} + /// Invoke a callable `f64` value with the supplied positional args and an /// explicit `thisArg` binding, throwing `TypeError` if `f` is not callable. /// Used by `Reflect.apply`. `thisArg` flows through `IMPLICIT_THIS` so free @@ -903,7 +969,40 @@ fn call_with_this_and_args(f: f64, this_arg: f64, args: &[f64]) -> f64 { // A concise/object-literal method reads `this` from a baked capture slot, // not IMPLICIT_THIS; rebind to the explicit `Reflect.apply` receiver so it // is honored (no-op for arrows / plain fns / bound fns). - let f = crate::closure::rebind_explicit_this(f, this_arg); + // + // That rebind is also the one thing on this path that ALLOCATES, and the + // callee, the receiver and the whole argument list are live across it in + // plain Rust locals — not GC roots (#10532 review). The clone happens for + // exactly one callee shape, so ask first and hand that shape to the rooted + // path below; every other callee keeps the allocation-free dispatch. + if crate::closure::rebind_explicit_this_allocates(f) { + return call_rooted_across_rebind(f, this_arg, args); + } + dispatch_with_explicit_this(f, this_arg, args) +} + +/// The `Reflect.apply` slow path: the rebind will clone, so root what the call +/// still needs and re-read it from the handles below the allocation. +#[cold] +#[inline(never)] +fn call_rooted_across_rebind(f: f64, this_arg: f64, args: &[f64]) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(this_arg); + let arg_handles: Vec<_> = args + .iter() + .map(|value| scope.root_nanbox_f64(*value)) + .collect(); + crate::gc::collection_point("reflect.apply.rebind"); + // `rebind_explicit_this` roots the callee and the receiver it is given + // (`clone_closure_rebind_this`), so its result is already current. + let rebound = crate::closure::rebind_explicit_this(f, receiver.get_nanbox_f64()); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + dispatch_with_explicit_this(rebound, receiver.get_nanbox_f64(), &args) +} + +/// Invoke an already-rebound callable with an explicit `this`. Nothing here +/// allocates before the callee runs, so the arguments need no protection. +fn dispatch_with_explicit_this(f: f64, this_arg: f64, args: &[f64]) -> f64 { let closure = closure_from(f); if closure.is_null() { return throw_type_error("Reflect.apply target is not a function"); diff --git a/crates/perry-runtime/src/proxy/reflect_misc.rs b/crates/perry-runtime/src/proxy/reflect_misc.rs index a79c6ec259..2ae7545d1f 100644 --- a/crates/perry-runtime/src/proxy/reflect_misc.rs +++ b/crates/perry-runtime/src/proxy/reflect_misc.rs @@ -106,8 +106,13 @@ pub extern "C" fn js_reflect_apply(f: f64, this_arg: f64, args_array: f64) -> f6 if !is_callable(f) { return throw_type_error("Reflect.apply target is not a function"); } + // Building the list allocates (index keys) and can run user getters, so the + // callee and the receiver are rooted across it (#10532 review). + let scope = crate::gc::RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f); + let receiver = scope.root_nanbox_f64(this_arg); let args = create_list_from_array_like(args_array); - call_with_this_and_args(f, this_arg, &args) + call_with_this_and_args(callee.get_nanbox_f64(), receiver.get_nanbox_f64(), &args) } /// `Reflect.defineProperty(obj, key, descriptor)` — returns `false` when the diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 70d46c0e73..f847eba1ea 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -330,7 +330,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", - "crates/perry-runtime/src/gc/mod.rs": "d401b22ffd6b7423bc4709153e888f79b88ac1c33aa776edee04bc7d3f5aca84", + "crates/perry-runtime/src/gc/mod.rs": "0243f1b1b1fae870983df500898abc353086473bbde3f478162e2826867762fe", "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -2529,6 +2529,12 @@ "name": "WINDOW_ROOTS", "verdict": "not_a_gc_pointer", "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." + }, + { + "file": "crates/perry-runtime/src/gc/collection_points.rs", + "name": "ARMED_SITE", + "verdict": "test_only", + "why": "Cell>: a static string-literal site NAME an armed rooting-regression test compares against, never a GC heap pointer. Declared under #[cfg(test)] only (collection_points.rs:15); the whole module compiles to an empty inline no-op outside cfg(test), so this storage never exists in a shipped binary." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.",