From 28f63bf4fc6225485bbf68afe4fe5750805c9eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 03:46:33 +0200 Subject: [PATCH 1/8] fix(runtime): root the generic array-like callback loops across their collection points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forced-moving production gate faulted inside js_arraylike_map with from-space protection armed: the loop derived the result array's element pointer once, the callback's allocation ran a copying minor that moved the array, and the next mapped element was written through the pre-collection pointer into mprotect-poisoned retired from-space (obj_type=1, the result array). Every callback-iteration helper in array/generic.rs shared the shape: receiver, callback, result under construction, and (in find/filter) the current element were all held in raw locals across js_closure_call3/4 — and al_has/al_get, whose getter and proxy paths run arbitrary JS, are collection points too. Root all of them in a RuntimeHandleScope and re-read from the handles at every use: forEach, map, filter, some, every, find, findIndex, findLast, findLastIndex, reduce, reduceRight. The closure pointer is re-derived from its rooted nanbox adjacent to each call instead of being cached across iterations. The regression test plants the gate's exact collection point — a callback that runs a copying minor on every invocation — and asserts the relocated receiver is observed and the mapped values land in the relocated result. Sabotage-verified: re-hoisting the element pointer makes it fail. --- crates/perry-runtime/src/array/generic.rs | 287 ++++++++++++------ .../src/gc/tests/runtime_roots.rs | 1 + .../runtime_roots/arraylike_callbacks.rs | 76 +++++ 3 files changed, 277 insertions(+), 87 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/arraylike_callbacks.rs diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 9ad6adf10c..a0d401c05a 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -641,6 +641,15 @@ impl Drop for ThisGuard { // --------------------------------------------------------------------------- // Callback iteration methods. The callback receives `(value, index, O)` with // `O` the *original* receiver value; `this_arg` binds the callback's `this`. +// +// GC discipline (#8082): every `js_closure_call*` — and `al_has`/`al_get`, +// whose getter/proxy paths run arbitrary JS — is a collection point that can +// MOVE the receiver, the callback, and any result under construction. The +// forced-moving Next production gate caught `js_arraylike_map` writing +// through a pre-collection element pointer into retired from-space. Each +// loop therefore roots those values in a `RuntimeHandleScope` and re-reads +// them from their handles at every use; nothing heap-derived may be held in +// a local across an iteration. // --------------------------------------------------------------------------- #[no_mangle] @@ -652,92 +661,134 @@ pub extern "C" fn js_arraylike_forEach(recv: f64, cb: f64, this_arg: f64) -> f64 if super::generic_mutators::arraylike_collection_foreach(recv, cb, this_arg) { return undef(); } - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); for k in 0..len { - if !al_has(recv, k) { + if !al_has(recv_h.get_nanbox_f64(), k) { continue; } - let v = al_get(recv, k); - js_closure_call3(cb, v, k as f64, recv); + let v = al_get(recv_h.get_nanbox_f64(), k); + js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); } undef() } #[no_mangle] pub extern "C" fn js_arraylike_map(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); // ArraySpeciesCreate → ArrayCreate throws RangeError for len ≥ 2^32 // (test262 map/create-non-array-invalid-len) — BEFORE any callback runs. if len > u32::MAX as i64 { crate::array::array_length_range_error(); } - let result = js_array_alloc_with_length(len.max(0) as u32); - let elems = unsafe { (result as *mut u8).add(std::mem::size_of::()) as *mut f64 }; - let _g = ThisGuard::new(this_arg); + let result_h = scope.root_raw_mut_ptr(js_array_alloc_with_length(len.max(0) as u32)); for k in 0..len { - if !al_has(recv, k) { + if !al_has(recv_h.get_nanbox_f64(), k) { continue; // preserve holes } - let v = al_get(recv, k); - let mapped = js_closure_call3(cb, v, k as f64, recv); + let v = al_get(recv_h.get_nanbox_f64(), k); + let mapped = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); + // Re-derive the element pointer AFTER the callback: the collection it + // may have triggered moves the result array (#8082 — this exact write + // landed in mprotect-poisoned from-space under the forced gate). + let result = result_h.get_raw_mut_ptr::(); + let elems = + unsafe { (result as *mut u8).add(std::mem::size_of::()) as *mut f64 }; unsafe { // GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier. ptr::write(elems.add(k as usize), mapped); note_array_slot(result, k as usize, mapped.to_bits()); } } - nanbox_arr(result) + nanbox_arr(result_h.get_raw_mut_ptr::()) } #[no_mangle] pub extern "C" fn js_arraylike_filter(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let mut result = js_array_alloc(0); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); + let result_h = scope.root_raw_mut_ptr(js_array_alloc(0)); + // One reusable handle keeps `v` (read BEFORE the callback, pushed AFTER + // it — the spec's value, not a re-read) alive across the call without + // growing the handle stack per element. + let v_h = scope.root_nanbox_f64(undef()); for k in 0..len { - if !al_has(recv, k) { + if !al_has(recv_h.get_nanbox_f64(), k) { continue; } - let v = al_get(recv, k); - let keep = js_closure_call3(cb, v, k as f64, recv); + v_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); + let keep = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v_h.get_nanbox_f64(), + k as f64, + recv_h.get_nanbox_f64(), + ); if crate::value::js_is_truthy(keep) != 0 { - result = js_array_push_f64(result, v); + let grown = js_array_push_f64( + result_h.get_raw_mut_ptr::(), + v_h.get_nanbox_f64(), + ); + result_h.set_raw_mut_ptr(grown); } } - nanbox_arr(result) + nanbox_arr(result_h.get_raw_mut_ptr::()) } #[no_mangle] pub extern "C" fn js_arraylike_some(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); for k in 0..len { - if !al_has(recv, k) { + if !al_has(recv_h.get_nanbox_f64(), k) { continue; } - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) != 0 { + let v = al_get(recv_h.get_nanbox_f64(), k); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) != 0 { return boxed_bool(true); } } @@ -746,19 +797,27 @@ pub extern "C" fn js_arraylike_some(recv: f64, cb: f64, this_arg: f64) -> f64 { #[no_mangle] pub extern "C" fn js_arraylike_every(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); for k in 0..len { - if !al_has(recv, k) { + if !al_has(recv_h.get_nanbox_f64(), k) { continue; } - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) == 0 { + let v = al_get(recv_h.get_nanbox_f64(), k); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) == 0 { return boxed_bool(false); } } @@ -770,17 +829,27 @@ pub extern "C" fn js_arraylike_every(recv: f64, cb: f64, this_arg: f64) -> f64 { #[no_mangle] pub extern "C" fn js_arraylike_find(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); + // `v` is read before the callback and returned after it — root it. + let v_h = scope.root_nanbox_f64(undef()); for k in 0..len { - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) != 0 { - return v; + v_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v_h.get_nanbox_f64(), + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) != 0 { + return v_h.get_nanbox_f64(); } } undef() @@ -788,16 +857,24 @@ pub extern "C" fn js_arraylike_find(recv: f64, cb: f64, this_arg: f64) -> f64 { #[no_mangle] pub extern "C" fn js_arraylike_findIndex(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); for k in 0..len { - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) != 0 { + let v = al_get(recv_h.get_nanbox_f64(), k); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) != 0 { return k as f64; } } @@ -806,18 +883,28 @@ pub extern "C" fn js_arraylike_findIndex(recv: f64, cb: f64, this_arg: f64) -> f #[no_mangle] pub extern "C" fn js_arraylike_findLast(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); + // `v` is read before the callback and returned after it — root it. + let v_h = scope.root_nanbox_f64(undef()); let mut k = len - 1; while k >= 0 { - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) != 0 { - return v; + v_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v_h.get_nanbox_f64(), + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) != 0 { + return v_h.get_nanbox_f64(); } k -= 1; } @@ -826,17 +913,25 @@ pub extern "C" fn js_arraylike_findLast(recv: f64, cb: f64, this_arg: f64) -> f6 #[no_mangle] pub extern "C" fn js_arraylike_findLastIndex(recv: f64, cb: f64, this_arg: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let _g = ThisGuard::new(this_arg); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let _g = ThisGuard::new(this_arg); + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); let mut k = len - 1; while k >= 0 { - let v = al_get(recv, k); - if crate::value::js_is_truthy(js_closure_call3(cb, v, k as f64, recv)) != 0 { + let v = al_get(recv_h.get_nanbox_f64(), k); + let hit = js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ); + if crate::value::js_is_truthy(hit) != 0 { return k as f64; } k -= 1; @@ -850,13 +945,16 @@ pub extern "C" fn js_arraylike_findLastIndex(recv: f64, cb: f64, this_arg: f64) #[no_mangle] pub extern "C" fn js_arraylike_reduce(recv: f64, cb: f64, has_init: i32, init: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let mut acc = init; + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); + // The accumulator crosses every callback — keep it in a rooted handle. + let acc_h = scope.root_nanbox_f64(init); let mut k = 0i64; if has_init == 0 { // Seed from the first present element. @@ -864,8 +962,8 @@ pub extern "C" fn js_arraylike_reduce(recv: f64, cb: f64, has_init: i32, init: f if k >= len { super::generic_mutators::throw_reduce_empty(); } - if al_has(recv, k) { - acc = al_get(recv, k); + if al_has(recv_h.get_nanbox_f64(), k) { + acc_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); k += 1; break; } @@ -873,32 +971,41 @@ pub extern "C" fn js_arraylike_reduce(recv: f64, cb: f64, has_init: i32, init: f } } while k < len { - if al_has(recv, k) { - let v = al_get(recv, k); - acc = crate::closure::js_closure_call4(cb, acc, v, k as f64, recv); + if al_has(recv_h.get_nanbox_f64(), k) { + let v = al_get(recv_h.get_nanbox_f64(), k); + acc_h.set_nanbox_f64(crate::closure::js_closure_call4( + callable(cb_h.get_nanbox_f64()), + acc_h.get_nanbox_f64(), + v, + k as f64, + recv_h.get_nanbox_f64(), + )); } k += 1; } - acc + acc_h.get_nanbox_f64() } #[no_mangle] pub extern "C" fn js_arraylike_reduceRight(recv: f64, cb: f64, has_init: i32, init: f64) -> f64 { - let recv = to_object(recv); + let scope = crate::gc::RuntimeHandleScope::new(); + let cb_h = scope.root_nanbox_f64(cb); + let recv_h = scope.root_nanbox_f64(to_object(recv)); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the // callback is missing/non-callable. Read `len` first, then validate `cb`. - let len = al_length(recv); - let cb = callable(cb); - let mut acc = init; + let len = al_length(recv_h.get_nanbox_f64()); + callable(cb_h.get_nanbox_f64()); + // The accumulator crosses every callback — keep it in a rooted handle. + let acc_h = scope.root_nanbox_f64(init); let mut k = len - 1; if has_init == 0 { loop { if k < 0 { super::generic_mutators::throw_reduce_empty(); } - if al_has(recv, k) { - acc = al_get(recv, k); + if al_has(recv_h.get_nanbox_f64(), k) { + acc_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); k -= 1; break; } @@ -906,13 +1013,19 @@ pub extern "C" fn js_arraylike_reduceRight(recv: f64, cb: f64, has_init: i32, in } } while k >= 0 { - if al_has(recv, k) { - let v = al_get(recv, k); - acc = crate::closure::js_closure_call4(cb, acc, v, k as f64, recv); + if al_has(recv_h.get_nanbox_f64(), k) { + let v = al_get(recv_h.get_nanbox_f64(), k); + acc_h.set_nanbox_f64(crate::closure::js_closure_call4( + callable(cb_h.get_nanbox_f64()), + acc_h.get_nanbox_f64(), + v, + k as f64, + recv_h.get_nanbox_f64(), + )); } k -= 1; } - acc + acc_h.get_nanbox_f64() } // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 36d46f77f5..cbd323dee9 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -1,6 +1,7 @@ use super::super::*; use super::support::*; use std::cell::Cell; +mod arraylike_callbacks; mod callback_scanners; mod fs_options_object; mod generator_attach_prototype; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/arraylike_callbacks.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/arraylike_callbacks.rs new file mode 100644 index 0000000000..c642cd13e8 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/arraylike_callbacks.rs @@ -0,0 +1,76 @@ +//! Moving-GC regression for the generic array-like callback loops (#8082). +//! +//! The forced-moving Next production gate caught `js_arraylike_map` writing a +//! mapped element through a pre-collection pointer into mprotect-poisoned +//! retired from-space: the callback allocated, a copying minor moved the +//! result array, and the loop kept the element pointer it had derived before +//! the call. This test plants that exact collection point — a callback that +//! runs a copying minor on every invocation — and asserts the loop observes +//! the relocated receiver and still assembles the correct result. + +use super::super::super::*; +use super::super::support::*; + +extern "C" fn collect_then_double( + _closure: *const crate::closure::ClosureHeader, + value: f64, + _index: f64, + _recv: f64, +) -> f64 { + crate::gc::gc_collect_minor(); + value * 2.0 +} + +#[test] +fn arraylike_map_survives_a_moving_minor_inside_every_callback() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let scope = crate::gc::RuntimeHandleScope::new(); + let mut arr = crate::array::js_array_alloc(0); + for v in [1.0f64, 2.0, 3.0, 4.0] { + arr = crate::array::js_array_push_f64(arr, v); + } + let recv_h = scope.root_nanbox_f64(f64::from_bits( + crate::JSValue::pointer(arr as *const u8).bits(), + )); + let recv_before = arr as usize; + + let cb = crate::closure::js_closure_alloc_singleton(collect_then_double as *const u8); + let cb_value = f64::from_bits(crate::JSValue::pointer(cb as *const u8).bits()); + + let cycles_before = copying_minor_cycles(); + let mapped = crate::array::js_arraylike_map( + recv_h.get_nanbox_f64(), + cb_value, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + let cycles_after = copying_minor_cycles(); + assert!( + cycles_after >= cycles_before + 4, + "each of the four callbacks must run a copying minor \ + (before={cycles_before}, after={cycles_after})" + ); + let recv_after = (recv_h.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as usize; + assert_ne!( + recv_before, recv_after, + "the collections must actually move the receiver array" + ); + + // The discriminating check: pre-fix, every `ptr::write` of a mapped + // element went through the PRE-collection element pointer, so the + // relocated result never received the values. + let result = + (mapped.to_bits() & crate::value::POINTER_MASK) as *const crate::array::ArrayHeader; + assert_eq!(crate::array::js_array_length(result), 4); + for (index, expected) in [2.0f64, 4.0, 6.0, 8.0].into_iter().enumerate() { + let got = crate::array::js_array_get_f64(result, index as u32); + assert_eq!( + got, expected, + "mapped element {index} must land in the relocated result array" + ); + } +} From 0aeb25c0152e60a170f988617b16fea56d5317eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:23:49 +0200 Subject: [PATCH 2/8] fix(runtime): root call/apply and put-value locals across their JS invocations The forced-moving gate faulted twice more in the same class: the Function.prototype.call/.apply arms held the callee closure, the explicit this, and the saved implicit-this bits in raw locals across js_native_call_value, then handed the stale callee to maybe_alias_explicit_this_construction; and js_put_value_set held the receiver and property key across ordinary_set_with_receiver (which runs user setters) before the array-subclass length note read the stale receiver's header. Root all of them in RuntimeHandleScopes and re-read from the handles after the calls. --- .../native_call_method/common_methods.rs | 42 +++++++++++++++---- crates/perry-runtime/src/proxy/put_value.rs | 13 +++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index f43833aef7..868e98960b 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -528,7 +528,16 @@ pub(super) unsafe fn dispatch_common( std::ptr::null() }; let rest_len = args_len.saturating_sub(1); - let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); + // The callee, the explicit `this`, and the saved previous + // implicit-`this` all cross the invocation — a moving + // collection inside the callee relocates them (#8082: the + // forced gate faulted reading the stale callee closure in + // `maybe_alias_explicit_this_construction` after the call). + let scope = crate::gc::RuntimeHandleScope::new(); + let callee_h = scope.root_nanbox_f64(object); + let this_h = scope.root_nanbox_f64(this_arg); + let prev_this_h = + scope.root_nanbox_u64(IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits()))); // Static bound-method value (`C.m.call(x)`): arm the one-shot // static-`this` override so the method body sees `x` instead // of the lexical class-ref (static private brand checks). @@ -539,17 +548,22 @@ pub(super) unsafe fn dispatch_common( // A concise/object-literal method reads `this` from a baked // capture slot, not IMPLICIT_THIS; rebind to the explicit // `.call(thisArg)` receiver (no-op for arrows / plain fns). - let call_target = crate::closure::rebind_explicit_this(object, this_arg); + let call_target = crate::closure::rebind_explicit_this( + callee_h.get_nanbox_f64(), + this_h.get_nanbox_f64(), + ); let result = crate::closure::js_native_call_value(call_target, rest_ptr, rest_len); if static_target { super::static_this_disarm(); } - IMPLICIT_THIS.with(|c| c.set(prev_this)); + IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); // #4973: `http.Server.call(this, handler)` — the inherits // pattern. Alias the explicit `this` object to the handle the // native class export constructed. super::native_this_alias::maybe_alias_explicit_this_construction( - object, this_arg, result, + callee_h.get_nanbox_f64(), + this_h.get_nanbox_f64(), + result, ); return Some(result); } @@ -664,7 +678,14 @@ pub(super) unsafe fn dispatch_common( } else { (buf.as_ptr(), buf.len()) }; - let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); + // Same rooting discipline as the `call` arm (#8082): callee, + // explicit `this`, and the saved implicit-`this` cross the + // invocation and must survive a moving collection inside it. + let scope = crate::gc::RuntimeHandleScope::new(); + let callee_h = scope.root_nanbox_f64(object); + let this_h = scope.root_nanbox_f64(this_arg); + let prev_this_h = + scope.root_nanbox_u64(IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits()))); // Static bound-method value — see the matching `call` arm. let static_target = super::native_module::is_static_bound_method_value(object); if static_target { @@ -673,7 +694,10 @@ pub(super) unsafe fn dispatch_common( // Rebind a concise/object-literal method's baked `this` slot to // the explicit `.apply(thisArg)` receiver (no-op for arrows / // plain fns) — see the matching `call` arm. - let apply_target = crate::closure::rebind_explicit_this(object, this_arg); + let apply_target = crate::closure::rebind_explicit_this( + callee_h.get_nanbox_f64(), + this_h.get_nanbox_f64(), + ); let result = crate::closure::js_native_call_value( apply_target, call_args_ptr, @@ -682,11 +706,13 @@ pub(super) unsafe fn dispatch_common( if static_target { super::static_this_disarm(); } - IMPLICIT_THIS.with(|c| c.set(prev_this)); + IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); // #4973: `http.Server.apply(this, args)` — same inherits // pattern as the `call` arm above. super::native_this_alias::maybe_alias_explicit_this_construction( - object, this_arg, result, + callee_h.get_nanbox_f64(), + this_h.get_nanbox_f64(), + result, ); return Some(result); } diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 1ca7f2e9c7..ca3b039ea0 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -273,6 +273,14 @@ pub extern "C" fn js_put_value_set( let ok = if lookup(target).is_some() { js_proxy_set(target, property_key, value).to_bits() == TAG_TRUE } else { + // #8082: `ordinary_set_with_receiver` can run user setters (and + // allocates), so it is a collection point — the raw `receiver` and + // `property_key` locals go stale across it. The forced-moving gate + // faulted on exactly this pair inside the subclass-length note's + // header read. Root both and re-read after the set. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + let key_h = scope.root_nanbox_f64(property_key); let stored = ordinary_set_with_receiver(target, property_key, value, receiver); // #7574: `sub[3] = v` on a `class X extends Array` instance is an // Array-exotic `[[DefineOwnProperty]]` and must leave `length == 4`. @@ -283,7 +291,10 @@ pub extern "C" fn js_put_value_set( // through `js_put_value_set_ic_miss`). Cheap no-op for every other // receiver: an object literal short-circuits on `class_id == 0`. if stored { - crate::array::note_array_subclass_index_write(receiver, property_key); + crate::array::note_array_subclass_index_write( + receiver_h.get_nanbox_f64(), + key_h.get_nanbox_f64(), + ); } stored }; From 5ad41b23fcc0bb0807e5f65c8c55600d0e263dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:23:49 +0200 Subject: [PATCH 3/8] fix(ffi): transient GC roots for ext-crate callback snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ext crates keep user closures in handle-struct side tables that registered scanners rewrite on a moving collection — but a SNAPSHOT of those tables in a Rust local (a cloned listener Vec, a pending-request struct parked in an mpsc channel between the hyper task and the pump tick) is a copy no scanner can see. The forced gate faulted on both shapes: a drained listener vec went stale after the first callback's collection, and channel-parked handler/listener addresses went stale across the microtask-pump safepoint minors that run while requests wait. Add an extern transient-root surface over the runtime-handle stack (js_ffi_root_scope_enter/push/get/exit) plus a safe perry_ffi::TransientRootScope wrapper, and convert perry-ext-http's emit helpers, deferred-listen drain, close callback, and both process_pending dispatchers. The HTTP/HTTPS dispatchers additionally re-read handler and listener lists from the scanner-maintained server handle at dispatch time instead of trusting the channel-parked snapshot (the arrival-time is_check_continue routing decision is kept). --- .../perry-ext-http/src/server/https_server.rs | 40 ++++++-- crates/perry-ext-http/src/server/request.rs | 32 +++++-- crates/perry-ext-http/src/server/server.rs | 63 ++++++++++--- crates/perry-ffi/src/lib.rs | 2 + crates/perry-ffi/src/transient_roots.rs | 92 +++++++++++++++++++ .../src/gc/roots/runtime_handles.rs | 68 ++++++++++++++ 6 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 crates/perry-ffi/src/transient_roots.rs diff --git a/crates/perry-ext-http/src/server/https_server.rs b/crates/perry-ext-http/src/server/https_server.rs index ae3706442f..059a706250 100644 --- a/crates/perry-ext-http/src/server/https_server.rs +++ b/crates/perry-ext-http/src/server/https_server.rs @@ -459,15 +459,38 @@ pub(crate) fn process_pending_https(pending: HttpPendingRequest) { // #4903 — Node invokes `'request'` listeners (and the `createServer` // handler, which is one) with `this` bound to the server. let server_this = handle_to_pointer_f64(pending.server_handle); + // #8082 (same as the HTTP path): the channel-parked snapshot's closure + // addresses are copies no scanner rewrites — re-read them from the + // scanner-maintained server handle at dispatch, then root the refreshed + // values across the callbacks (each can run a moving collection). The + // routing decision keeps the arrival-time `is_check_continue` snapshot. + let (fresh_request_listeners, fresh_check_continue_listeners, fresh_handler) = + match get_handle::(pending.server_handle) { + Some(s) => ( + s.base.listeners.get("request").cloned().unwrap_or_default(), + s.base + .listeners + .get("checkContinue") + .cloned() + .unwrap_or_default(), + s.base.handler, + ), + None => (Vec::new(), Vec::new(), 0), + }; + let scope = perry_ffi::TransientRootScope::enter(); + let check_continue_rooted = scope.root_addrs(&fresh_check_continue_listeners); + let request_rooted = scope.root_addrs(&fresh_request_listeners); + let handler_rooted = scope.root_addr(fresh_handler); // #5080 — an `Expect: 100-continue` request with a `'checkContinue'` // listener fires that listener instead of the `'request'` path. if pending.is_check_continue { - for cb in &pending.check_continue_listeners { - if *cb == 0 { + for cb in &check_continue_rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { with_implicit_this(server_this, || { @@ -480,12 +503,13 @@ pub(crate) fn process_pending_https(pending: HttpPendingRequest) { crate::server::server::finalize_or_park_request(&pending); return; } - for cb in &pending.request_listeners { - if *cb == 0 { + for cb in &request_rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { with_implicit_this(server_this, || { @@ -495,9 +519,9 @@ pub(crate) fn process_pending_https(pending: HttpPendingRequest) { js_promise_run_microtasks(); } } - if pending.handler != 0 { + if handler_rooted.get() != 0 { unsafe { - let raw = pending.handler as *const RawClosureHeader; + let raw = handler_rooted.get() as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { with_implicit_this(server_this, || { diff --git a/crates/perry-ext-http/src/server/request.rs b/crates/perry-ext-http/src/server/request.rs index d9261c3910..a257f06582 100644 --- a/crates/perry-ext-http/src/server/request.rs +++ b/crates/perry-ext-http/src/server/request.rs @@ -761,29 +761,37 @@ pub(crate) fn emit_data_to_listeners(listeners: &[i64], body: &[u8], encoding: O if listeners.is_empty() || body.is_empty() { return; } - let chunk_f64 = match encoding { + // #8082: the listener snapshot AND the chunk cross every callback, and a + // callback can trigger a moving collection — park both in transient + // roots and re-read per use. + let scope = perry_ffi::TransientRootScope::enter(); + let rooted = scope.root_addrs(listeners); + let chunk = match encoding { Some(_) => { let s = String::from_utf8_lossy(body).into_owned(); let header = alloc_string(&s); - f64::from_bits(STRING_TAG | (header.as_raw() as u64 & PTR_MASK)) + scope.root_nanbox(f64::from_bits( + STRING_TAG | (header.as_raw() as u64 & PTR_MASK), + )) } None => { let buf = alloc_buffer(body); if buf.is_null() { return; } - f64::from_bits(POINTER_TAG | (buf as u64 & PTR_MASK)) + scope.root_nanbox(f64::from_bits(POINTER_TAG | (buf as u64 & PTR_MASK))) } }; - for cb in listeners { - if *cb == 0 { + for cb in &rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { - let _ = closure.call1(chunk_f64); + let _ = closure.call1(chunk.get()); } } } @@ -795,12 +803,16 @@ pub(crate) fn emit_end_to_listeners(listeners: &[i64]) { } pub(crate) fn emit_no_arg_to_listeners(listeners: &[i64]) { - for cb in listeners { - if *cb == 0 { + // #8082: the snapshot crosses every callback — root it, re-read per use. + let scope = perry_ffi::TransientRootScope::enter(); + let rooted = scope.root_addrs(listeners); + for cb in &rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { let _ = closure.call0(); diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 1159a4186e..0c5e97d942 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -983,9 +983,12 @@ pub unsafe extern "C" fn js_node_http_server_close(server_handle: i64, callback: // Node 19+: `server.close()` destroys idle keep-alive connections // (active requests are allowed to finish) (#4905). signal_connections_close(server_handle, true); + // #8082: `callback` crosses the close-listener emits, which run JS. + let scope = perry_ffi::TransientRootScope::enter(); + let callback_rooted = scope.root_addr(callback); emit_no_arg_to_listeners(&close_listeners); - if callback != 0 { - let raw = callback as *const RawClosureHeader; + if callback_rooted.get() != 0 { + let raw = callback_rooted.get() as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { let _ = closure.call0(); @@ -1527,11 +1530,15 @@ where }; let this_val = handle_to_pointer_f64(server_handle); let mut fired = 0i32; - for cb in cbs { - if cb == 0 { + // #8082: the drained snapshot crosses each callback — root it. + let scope = perry_ffi::TransientRootScope::enter(); + let rooted = scope.root_addrs(&cbs); + for cb in &rooted { + let addr = cb.get(); + if addr == 0 { continue; } - let raw = cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = unsafe { JsClosure::from_raw(raw) }; if !closure.is_null() { with_implicit_this(this_val, || { @@ -1782,13 +1789,42 @@ fn process_pending(pending: HttpPendingRequest) { // listener fires that listener *instead of* `'request'` + the handler // (Node's dispatch). The listener calls `res.writeContinue()` and then // drives the exchange itself. + // #8082: `pending` is a snapshot built at REQUEST time on the hyper task + // and parked in an mpsc channel until this tick — the handler and + // listener addresses inside it are copies no scanner rewrites, so any + // moving collection between arrival and dispatch leaves them stale (the + // forced gate faulted on them at the microtask-pump safepoint minors). + // Re-read them from the server handle, whose side tables the registered + // scanner DOES rewrite; the routing decision (`is_check_continue`) keeps + // the arrival-time snapshot semantics. Then root the refreshed values, + // because each callback below can itself run a moving collection. + // (`req_f64`/`res_f64`/`server_this` are small handle ids — no move.) + let (fresh_request_listeners, fresh_check_continue_listeners, fresh_handler) = + match get_handle::(pending.server_handle) { + Some(s) => ( + s.listeners.get("request").cloned().unwrap_or_default(), + s.listeners + .get("checkContinue") + .cloned() + .unwrap_or_default(), + s.handler, + ), + // Server gone: nothing safe to dispatch to. + None => (Vec::new(), Vec::new(), 0), + }; + let scope = perry_ffi::TransientRootScope::enter(); + let check_continue_rooted = scope.root_addrs(&fresh_check_continue_listeners); + let request_rooted = scope.root_addrs(&fresh_request_listeners); + let handler_rooted = scope.root_addr(fresh_handler); + if pending.is_check_continue { - for cb in &pending.check_continue_listeners { - if *cb == 0 { + for cb in &check_continue_rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { with_implicit_this(server_this, || { @@ -1802,12 +1838,13 @@ fn process_pending(pending: HttpPendingRequest) { return; } - for cb in &pending.request_listeners { - if *cb == 0 { + for cb in &request_rooted { + let addr = cb.get(); + if addr == 0 { continue; } unsafe { - let raw = *cb as *const RawClosureHeader; + let raw = addr as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { with_implicit_this(server_this, || { @@ -1827,9 +1864,9 @@ fn process_pending(pending: HttpPendingRequest) { // tick of the codegen-emitted main loop. The // `synthesize_default_response_if_needed` safety net below // catches the case where neither path completed in time. - if pending.handler != 0 { + if handler_rooted.get() != 0 { unsafe { - let raw = pending.handler as *const RawClosureHeader; + let raw = handler_rooted.get() as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { // `createServer(handler)` registers `handler` as a diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 959d6d5d97..678b4807a8 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -78,10 +78,12 @@ pub use jsvalue::{ }; mod closure; +mod transient_roots; pub use closure::{ alloc_closure, closure_capture_f64, register_closure_arity, set_closure_capture_f64, JsClosure, RawClosureHeader, }; +pub use transient_roots::{TransientRootScope, TransientRootedAddr, TransientRootedNanbox}; mod bigint; pub use bigint::{alloc_bigint_from_str, read_bigint_limbs}; diff --git a/crates/perry-ffi/src/transient_roots.rs b/crates/perry-ffi/src/transient_roots.rs new file mode 100644 index 0000000000..314a858c2f --- /dev/null +++ b/crates/perry-ffi/src/transient_roots.rs @@ -0,0 +1,92 @@ +//! Transient GC roots for FFI code (#8082). +//! +//! Ext crates keep user closures and other heap values in handle-struct side +//! tables that registered mutable-root scanners mark AND rewrite on a moving +//! collection. A SNAPSHOT of those tables in a Rust local — a cloned listener +//! `Vec`, a pending-request struct drained from a channel — is a copy +//! the collector cannot see: after the first callback triggers a collection, +//! every remaining copied pointer is stale. The #8082 forced-moving gate +//! faulted on exactly that shape in the http server's pending-request pump. +//! +//! `TransientRootScope` parks such copies in the runtime's transient-handle +//! stack (the same slots `RuntimeHandleScope` uses, marked and rewritten by +//! the registered scanner) and re-reads the post-collection value at each +//! use. Scopes must strictly nest — the `Drop` truncates back to the entry +//! depth, and the runtime's JS-exception savepoints restore the stack across +//! throws that skip Rust frames. + +extern "C" { + fn js_ffi_root_scope_enter() -> usize; + fn js_ffi_root_push_heap_addr(addr: u64) -> usize; + fn js_ffi_root_get_heap_addr(index: usize) -> u64; + fn js_ffi_root_push_nanbox(bits: u64) -> usize; + fn js_ffi_root_get_nanbox(index: usize) -> u64; + fn js_ffi_root_scope_exit(base: usize); +} + +/// RAII scope over the runtime's transient-handle stack; see module docs. +pub struct TransientRootScope { + base: usize, +} + +impl TransientRootScope { + /// Snapshot the current stack depth; `Drop` truncates back to it. + pub fn enter() -> Self { + Self { + base: unsafe { js_ffi_root_scope_enter() }, + } + } + + /// Root a raw heap address (an ext table's `i64` closure pointer). A zero + /// address is accepted and reads back as zero. + pub fn root_addr(&self, addr: i64) -> TransientRootedAddr { + TransientRootedAddr { + index: unsafe { js_ffi_root_push_heap_addr(addr as u64) }, + } + } + + /// Root every address in `addrs`, preserving order. + pub fn root_addrs(&self, addrs: &[i64]) -> Vec { + addrs.iter().map(|addr| self.root_addr(*addr)).collect() + } + + /// Root a NaN-boxed value handed to callbacks (string/buffer/object). + pub fn root_nanbox(&self, value: f64) -> TransientRootedNanbox { + TransientRootedNanbox { + index: unsafe { js_ffi_root_push_nanbox(value.to_bits()) }, + } + } +} + +impl Drop for TransientRootScope { + fn drop(&mut self) { + unsafe { js_ffi_root_scope_exit(self.base) } + } +} + +/// A rooted raw heap address; `get()` returns the post-collection value. +#[derive(Clone, Copy)] +pub struct TransientRootedAddr { + index: usize, +} + +impl TransientRootedAddr { + /// The post-collection address. Re-read this at every use; never hold the + /// returned value across another call that can run JS. + pub fn get(&self) -> i64 { + unsafe { js_ffi_root_get_heap_addr(self.index) as i64 } + } +} + +/// A rooted NaN-boxed value; `get()` returns the post-collection value. +#[derive(Clone, Copy)] +pub struct TransientRootedNanbox { + index: usize, +} + +impl TransientRootedNanbox { + /// The post-collection value. Re-read at every use. + pub fn get(&self) -> f64 { + f64::from_bits(unsafe { js_ffi_root_get_nanbox(self.index) }) + } +} diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles.rs b/crates/perry-runtime/src/gc/roots/runtime_handles.rs index 1137cb3228..9d535a6166 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles.rs @@ -426,3 +426,71 @@ pub(crate) fn scan_runtime_handle_roots_mut_step( state.cursor >= stack.len() }) } + +// --------------------------------------------------------------------------- +// FFI transient roots (#8082). +// --------------------------------------------------------------------------- +// +// Extern surface over the transient-handle stack for ext crates (perry-ffi +// consumers). Their handle-struct side tables are rewritten by registered +// mutable-root scanners, but a SNAPSHOT of those tables held in a Rust local +// across a JS callback is a copy the collector cannot see — the #8082 forced +// gate faulted on exactly that shape in the http server's pending-request +// pump. These entry points let FFI code park such copies in slots the +// existing runtime-handle scanner marks AND rewrites, then re-read the +// post-collection values. Scopes must strictly nest: `enter` snapshots the +// depth, `exit` truncates back to it (the JS-exception savepoint machinery +// above already restores this stack across throws). + +#[no_mangle] +pub extern "C" fn js_ffi_root_scope_enter() -> usize { + RUNTIME_HANDLE_STACK.with(|stack| stack.borrow().len()) +} + +/// Root a raw heap ADDRESS (e.g. an `i64` closure pointer from an ext +/// listener table). Returns the slot index for [`js_ffi_root_get_heap_addr`]. +#[no_mangle] +pub extern "C" fn js_ffi_root_push_heap_addr(addr: u64) -> usize { + let slot = RuntimeHandleSlot::HeapWord(addr); + runtime_handle_slot_write_barrier(slot); + RUNTIME_HANDLE_STACK.with(|stack| { + let mut stack = stack.borrow_mut(); + let index = stack.len(); + stack.push(slot); + index + }) +} + +#[no_mangle] +pub extern "C" fn js_ffi_root_get_heap_addr(index: usize) -> u64 { + RUNTIME_HANDLE_STACK.with(|stack| match stack.borrow().get(index) { + Some(RuntimeHandleSlot::HeapWord(bits)) => *bits, + _ => 0, + }) +} + +/// Root a NaN-boxed VALUE (string/buffer/object handed to callbacks). +#[no_mangle] +pub extern "C" fn js_ffi_root_push_nanbox(bits: u64) -> usize { + let slot = RuntimeHandleSlot::Nanbox(bits); + runtime_handle_slot_write_barrier(slot); + RUNTIME_HANDLE_STACK.with(|stack| { + let mut stack = stack.borrow_mut(); + let index = stack.len(); + stack.push(slot); + index + }) +} + +#[no_mangle] +pub extern "C" fn js_ffi_root_get_nanbox(index: usize) -> u64 { + RUNTIME_HANDLE_STACK.with(|stack| match stack.borrow().get(index) { + Some(RuntimeHandleSlot::Nanbox(bits)) => *bits, + _ => 0, + }) +} + +#[no_mangle] +pub extern "C" fn js_ffi_root_scope_exit(base: usize) { + RUNTIME_HANDLE_STACK.with(|stack| stack.borrow_mut().truncate(base)); +} From 86d034be6cdd2ed2a0f97c487e7bdcc6aef8b687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:23:49 +0200 Subject: [PATCH 4/8] feat(gc): sharpen the from-space scan and stack-map walk instruments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound the from-space scan's array walk by the LIVE length: capacity slack holds whatever bytes the allocator or a verbatim minor copy left there, and decoding it produced false MISSING-REWRITE aborts on the #8036 gate (a length-8/capacity-16 array whose slack held a dead method-table fragment). - Append a payload preview to each offender report (classified words around the stale slot) so the owner identifies itself. - PERRY_GC_STACKMAP_TRACE=1 prints each frame the native stack-map walk visits (ip + dladdr name); it is how the '7-frame truncated walk' hypothesis was falsified — those are complete walks at the microtask-pump boundary with no JS frames on the stack. --- crates/perry-runtime/src/gc/fromspace_scan.rs | 34 ++++++++++++++++++- .../perry-runtime/src/gc/roots/stack_maps.rs | 20 +++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/gc/fromspace_scan.rs b/crates/perry-runtime/src/gc/fromspace_scan.rs index 348e07c694..146015067e 100644 --- a/crates/perry-runtime/src/gc/fromspace_scan.rs +++ b/crates/perry-runtime/src/gc/fromspace_scan.rs @@ -340,6 +340,38 @@ pub(crate) fn scan_heap_for_fromspace_refs() -> FromSpaceScanReport { report } +/// Best-effort payload dump around the stale slot so the offending owner +/// identifies ITSELF (which array/object shape, what tags surround the slot). +/// The heap is intact when this runs (pre-abort, post-scan), so the read is +/// safe; classification only, no dereference of the classified words. +fn payload_preview(r: &FromSpaceRef) -> String { + let payload = (r.owner_header + GC_HEADER_SIZE) as *const u64; + let stale_word = r.slot_offset / 8; + let words = stale_word.saturating_add(3).min(24); + let mut out = String::from("\n payload:"); + for i in 0..words { + let w = unsafe { payload.add(i).read() }; + let kind = match w >> 48 { + 0x7ffc => "tag", + 0x7ffd => "ptr", + 0x7ffe => "i32", + 0x7fff => "str", + 0x7ffa => "big", + 0 => { + if crate::value::addr_class::is_plausible_heap_addr(w as usize) { + "BARE-ADDR" + } else { + "small" + } + } + _ => "f64", + }; + let marker = if i == stale_word { ">>" } else { "" }; + out.push_str(&format!(" {marker}[{i}]{w:#x}({kind})")); + } + out +} + fn describe(r: &FromSpaceRef) -> String { format!( " owner={:#x} type={} space={:?} +{} {} -> {:#x} (type={} {:?}) {} [slot dirty_now={} ever_dirty={} owner_flags={:#x} marked={}]", @@ -360,7 +392,7 @@ fn describe(r: &FromSpaceRef) -> String { r.slot_ever_dirty, r.owner_flags, r.owner_flags & GC_FLAG_MARKED != 0 - ) + ) + payload_preview(r).as_str() } /// #7803 identification dump: the offender line names the owner's GC type diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index add295816d..8d7c0b75f8 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -1112,6 +1112,11 @@ mod unwind { state.stats } + fn walk_trace_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("PERRY_GC_STACKMAP_TRACE").is_some()) + } + unsafe extern "C" fn walk_frame( context: *mut UnwindContext, argument: *mut c_void, @@ -1119,6 +1124,21 @@ mod unwind { let state = &mut *argument.cast::>(); state.stats.frames_visited = state.stats.frames_visited.saturating_add(1); let ip = _Unwind_GetIP(context); + if walk_trace_enabled() { + let mut info: libc::Dl_info = std::mem::zeroed(); + let name = + if libc::dladdr(ip as *const c_void, &mut info) != 0 && !info.dli_sname.is_null() { + std::ffi::CStr::from_ptr(info.dli_sname) + .to_string_lossy() + .into_owned() + } else { + String::from("?") + }; + eprintln!( + "[gc-stackmap-walk] frame {} ip={ip:#x} ({name})", + state.stats.frames_visited + ); + } let matched = state.index.match_records(ip); if matched.is_empty() { return 0; From 1891d32c4ce5ad7a261200cdafe37af2bebde527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 07:37:04 +0200 Subject: [PATCH 5/8] test(eh): pin that an action-zero landing pad is still Perry's catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the default native-roots build every JS catch pad is `landingpad token cleanup` (#7982's statepoint retype of catch-alls whose payload is unused), and LLVM emits a ZERO call-site action for a cleanup clause. Nothing pinned that, so reading the action as 'handler vs cleanup' looks reasonable in review while silently skipping every statepoint-built catch — a plain `try { throw } catch` then aborts FATAL 'no landing pad'. Exactly that regression was written and reviewed on #8082 and only caught end-to-end. Also add PERRY_EH_TRACE=1: one line per personality invocation (phase, owning function via dladdr, ip offset, decoded pad), the instrument that hunt lacked. Cached OnceLock probe, no verdict change. --- crates/perry-runtime/src/eh.rs | 69 +++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs index 45460fb134..97c198cc44 100644 --- a/crates/perry-runtime/src/eh.rs +++ b/crates/perry-runtime/src/eh.rs @@ -199,6 +199,17 @@ pub(crate) fn raise_perry_exception() -> UnwindReasonCode { /// the deliberate semantic for throws escaping a frame with no enclosing /// `try`; the C++ personality would `terminate` here instead). /// +/// `PERRY_EH_TRACE=1` prints one line per personality invocation (phase, +/// owning function, ip offset, decoded pad). Diagnostic only: it changes no +/// verdict, and the env probe is a cached `OnceLock` so the throw path pays +/// one branch. This is the instrument a "transport failed / no landing pad" +/// hunt needs — it names the frame the walk gave up on, which no other +/// output does. +fn eh_trace_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("PERRY_EH_TRACE").is_some()) +} + /// # Safety /// Called by the system unwinder with a live unwind context. #[no_mangle] @@ -214,8 +225,39 @@ pub unsafe extern "C" fn perry_eh_personality( } let lpad = match find_landing_pad(context) { Ok(l) => l, - Err(()) => return _URC_FATAL_PHASE1_ERROR, + Err(()) => { + if eh_trace_enabled() { + eprintln!( + "[perry-eh] personality actions={:#x} region={:#x}: LSDA parse FAILED", + actions, + _Unwind_GetRegionStart(context), + ); + } + return _URC_FATAL_PHASE1_ERROR; + } }; + if eh_trace_enabled() { + let mut before: c_int = 0; + let ip = _Unwind_GetIPInfo(context, &mut before); + let region = _Unwind_GetRegionStart(context); + let mut info: libc::Dl_info = std::mem::zeroed(); + let name = if libc::dladdr(region as *const libc::c_void, &mut info) != 0 + && !info.dli_sname.is_null() + { + std::ffi::CStr::from_ptr(info.dli_sname) + .to_string_lossy() + .into_owned() + } else { + String::from("?") + }; + eprintln!( + "[perry-eh] personality actions={:#x} region={:#x} ({name}) ip=+{:#x} lpad={:?}", + actions, + region, + ip.wrapping_sub(region), + lpad, + ); + } if actions & _UA_SEARCH_PHASE != 0 { match lpad { Some(_) => _URC_HANDLER_FOUND, @@ -499,6 +541,31 @@ mod tests { assert_eq!(got.unwrap(), None); } + /// A NON-ZERO landing-pad offset with a ZERO call-site ACTION is still + /// Perry's catch, and the walk must claim it. + /// + /// This is the shape every JS `try` has under the default native-roots + /// build: `retype_landing_pads_for_statepoints` (#7982) rewrites each + /// catch-all pad whose `{ptr, i32}` payload is unused — which is all of + /// them — into `landingpad token cleanup`, and LLVM emits a zero action + /// for a cleanup clause. Reading the action as "handler vs cleanup" here + /// therefore skips every statepoint-built catch, and a plain + /// `try { throw } catch` aborts with a FATAL "no landing pad" instead of + /// running its handler. That regression was written, reviewed and only + /// caught end-to-end (#8082) because nothing pinned this invariant; the + /// zero action in the fixture below is the whole point of the test. + #[test] + fn action_zero_pad_is_still_a_handler() { + let lsda = synth_lsda(&[(0x10, 0x8, 0x40, 0)]); + let base = 0x2000usize; + let got = unsafe { find_landing_pad_in_lsda(lsda.as_ptr(), base + 0x12, base) }; + assert_eq!( + got.unwrap(), + Some(base + 0x40), + "an action-zero pad is #7982's statepoint-retyped catch, not a skip" + ); + } + #[test] fn empty_call_site_table_is_no_handler() { let lsda = synth_lsda(&[]); From 00d6b3681f968eca1016124c2f3ae8ae6e19ed5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 07:49:48 +0200 Subject: [PATCH 6/8] fix(ext-http): make the GC scanner tests force evacuation, and drop the now-dead listener snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing failures on main (not introduced here): the scanner tests assert a root was REWRITTEN, which is only observable if the collection actually MOVED the object — a C4b policy decision that legitimately declines under unit-test conditions, at which point the assertions fail with nothing wrong in the code under test. The guards now force evacuation for their (mutex-serialized) lifetime, so the subject is guaranteed live. perry-runtime's own ForcedEvacuationTestGuard is #[cfg(test)]-internal and unreachable from this crate's test binary; the env knob is read fresh per query. Also drop HttpPendingRequest::check_continue_listeners, which the dispatch rooting fix orphaned: the addresses are now re-read from the scanner-maintained server handle, and only the routing bit is carried across the channel. --- .../src/server/http2_server/pump.rs | 1 - .../perry-ext-http/src/server/https_server.rs | 1 - crates/perry-ext-http/src/server/mod.rs | 12 ++++++++++++ crates/perry-ext-http/src/server/server.rs | 12 ++++++------ crates/perry-ext-http/src/tests.rs | 17 +++++++++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/perry-ext-http/src/server/http2_server/pump.rs b/crates/perry-ext-http/src/server/http2_server/pump.rs index 12871a667e..a4380f5e92 100644 --- a/crates/perry-ext-http/src/server/http2_server/pump.rs +++ b/crates/perry-ext-http/src/server/http2_server/pump.rs @@ -123,7 +123,6 @@ pub(crate) async fn handle_h2_request( h2_stream_headers, request_listeners, handler, - check_continue_listeners: Vec::new(), is_check_continue: false, }; if request_tx.send(pending).await.is_err() { diff --git a/crates/perry-ext-http/src/server/https_server.rs b/crates/perry-ext-http/src/server/https_server.rs index 059a706250..88d18a8714 100644 --- a/crates/perry-ext-http/src/server/https_server.rs +++ b/crates/perry-ext-http/src/server/https_server.rs @@ -405,7 +405,6 @@ async fn handle_https_request( h2_stream_headers: Vec::new(), request_listeners, handler, - check_continue_listeners, is_check_continue, }; if request_tx.send(pending).await.is_err() { diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index b41c0b3ed2..ca914f11b7 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -194,6 +194,16 @@ mod tests { let lock = GC_TEST_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Force evacuation for the guard's lifetime: these tests assert a + // root was REWRITTEN, which is only observable if the object + // actually moved, and whether a minor evacuates is a C4b policy + // decision that legitimately declines under unit-test conditions + // (at which point the assertions fail with nothing wrong in the + // code under test). See the twin guard in `crate::tests`. + // + // SAFETY: `GC_TEST_LOCK` is held for the guard's whole lifetime, + // so no other GC test in this binary observes the mutation window. + unsafe { std::env::set_var("PERRY_GC_FORCE_EVACUATE", "1") }; perry_runtime::gc::js_gc_write_barriers_emitted(1); let frame = perry_runtime::gc::js_shadow_frame_push(slot_count); Self { frame, _lock: lock } @@ -204,6 +214,8 @@ mod tests { fn drop(&mut self) { perry_runtime::gc::js_shadow_frame_pop(self.frame); perry_runtime::gc::js_gc_write_barriers_emitted(0); + // SAFETY: still under `GC_TEST_LOCK` (dropped after this body). + unsafe { std::env::remove_var("PERRY_GC_FORCE_EVACUATE") }; } } diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 0c5e97d942..3e04df0f55 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -196,12 +196,13 @@ pub struct HttpPendingRequest { /// dispatch loop doesn't need to re-borrow the server handle. pub request_listeners: Vec, pub handler: i64, - /// #5080 — `'checkContinue'` listeners snapshotted at request time. - /// When `is_check_continue` is set these fire *instead of* the - /// `'request'` listeners + handler (Node dispatches an + /// #5080 — routing only: when set, the `'checkContinue'` listeners fire + /// *instead of* the `'request'` listeners + handler (Node dispatches an /// `Expect: 100-continue` request to `'checkContinue'` when a listener - /// exists, and only emits `'request'` otherwise). - pub check_continue_listeners: Vec, + /// exists, and only emits `'request'` otherwise). The listener ADDRESSES + /// are deliberately not carried here: a snapshot parked in the channel + /// goes stale across a moving collection, so the dispatcher re-reads them + /// from the server handle (#8082). /// #5080 — route this request to `'checkContinue'` rather than the /// normal `'request'` path. pub is_check_continue: bool, @@ -1274,7 +1275,6 @@ async fn handle_request( h2_stream_headers: Vec::new(), request_listeners, handler, - check_continue_listeners, is_check_continue, }; diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index 3a7493a76b..7f77540e0e 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -15,6 +15,21 @@ impl GcTestGuard { let lock = GC_TEST_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + // These tests assert that a scanner REWROTE a root, which only has an + // observable answer if the collection actually MOVED the object. That + // is a policy decision (C4b weighs nursery/RSS pressure against + // measured movable candidates), and under unit-test conditions the + // policy legitimately declines — at which point the assertions read + // "the address did not change" and fail, with nothing wrong in the + // code under test. Force evacuation for the duration of the guard so + // the subject is guaranteed live; `perry-runtime`'s own + // `ForcedEvacuationTestGuard` is `#[cfg(test)]`-internal and cannot be + // reached from this crate's test binary, but the env knob is read + // fresh on every query and the guard already serializes these tests. + // + // SAFETY: `GC_TEST_LOCK` is held for the guard's whole lifetime, so no + // other GC test in this binary observes the mutation window. + unsafe { std::env::set_var("PERRY_GC_FORCE_EVACUATE", "1") }; perry_runtime::gc::js_gc_write_barriers_emitted(1); let frame = perry_runtime::gc::js_shadow_frame_push(0); Self { frame, _lock: lock } @@ -25,6 +40,8 @@ impl Drop for GcTestGuard { fn drop(&mut self) { perry_runtime::gc::js_shadow_frame_pop(self.frame); perry_runtime::gc::js_gc_write_barriers_emitted(0); + // SAFETY: still under `GC_TEST_LOCK` (dropped after this body). + unsafe { std::env::remove_var("PERRY_GC_FORCE_EVACUATE") }; } } From 444c60f06f055861410932f8bd83971a9e9cc043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 07:52:48 +0200 Subject: [PATCH 7/8] docs: changeset for the moving-GC rooting sweep --- changelog.d/8131-moving-gc-rooting-sweep.md | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 changelog.d/8131-moving-gc-rooting-sweep.md diff --git a/changelog.d/8131-moving-gc-rooting-sweep.md b/changelog.d/8131-moving-gc-rooting-sweep.md new file mode 100644 index 0000000000..7c4704b02a --- /dev/null +++ b/changelog.d/8131-moving-gc-rooting-sweep.md @@ -0,0 +1,57 @@ +### Fixed + +- Root heap values held in Rust locals across calls that can run JS and + therefore collect. Each was caught by a fault at the exact instruction + under `PERRY_GC_PROTECT_FROMSPACE=1`, not by inspection: + - the generic array-like callback helpers (`forEach`, `map`, `filter`, + `some`, `every`, `find`/`findIndex`/`findLast`/`findLastIndex`, + `reduce`/`reduceRight`) held the receiver, callback, result under + construction, and current element across `js_closure_call*`; `map` + wrote every mapped element through a pre-collection element pointer, + landing in retired from-space; + - `Function.prototype.call`/`.apply` held the callee, the explicit + `this`, and the saved implicit-`this` across the invocation, then read + the stale callee's header in the native-this alias check; + - `js_put_value_set` held the receiver and property key across + `ordinary_set_with_receiver` (which runs user setters) before the + array-subclass `length` note dereferenced them. + +- Keep perry-ext-http's listener dispatch on values the collector can see. + Ext handle-struct side tables are rewritten by registered scanners, but a + SNAPSHOT of one in a Rust local is a copy no scanner reaches: a drained + listener vec went stale after the first callback's collection, and the + pending-request struct parked in an mpsc channel went stale across the + microtask-pump safepoint minors that run while the request waits. The + emit helpers, deferred-listen drain and close callback now root their + snapshots, and both request dispatchers re-read handler and listener + lists from the scanner-maintained server handle at dispatch time. The + orphaned `HttpPendingRequest::check_continue_listeners` field is removed; + only the routing bit crosses the channel. + +- Repair two GC scanner tests that were failing on `main`: they assert a + root was rewritten, which is only observable if the collection actually + moved the object, and evacuation is a C4b policy decision that + legitimately declines under unit-test conditions. Their guards now force + evacuation for their mutex-serialized lifetime. + +### Added + +- `perry_ffi::TransientRootScope` — a safe wrapper over a new extern + surface onto the runtime's transient-handle stack, so ext crates can root + the table snapshots they hold across JS callbacks. + +- Instruments: the whole-heap from-space scan bounds array walks by live + length (capacity slack was producing false MISSING-REWRITE aborts) and + appends a classified payload preview to each offender so the owner + identifies itself; `PERRY_GC_STACKMAP_TRACE=1` prints every frame the + native stack-map walk visits; `PERRY_EH_TRACE=1` prints per-frame + personality decisions. + +### Testing + +- A deterministic moving-GC regression for `js_arraylike_map` (a callback + that runs a copying minor on every invocation), sabotage-verified. +- `action_zero_pad_is_still_a_handler` pins that a zero call-site action is + still Perry's catch — the shape of every JS `try` under native roots + (#7982) — so reading the action as "handler vs cleanup" can no longer + silently disable every statepoint-built catch. Sabotage-verified. From 695d44b30cfb2a55ff6b2c796b4fd3cc4f8136ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 14:53:05 +0200 Subject: [PATCH 8/8] refactor(gc): use the sanctioned handle accessors in the arraylike loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-handle ratchet locks array/generic.rs at zero bare get_raw_*_ptr reads, and #7341's direction is conversion rather than a raised ceiling. Convert by shape: - js_arraylike_map's post-callback reload becomes across_mut, which is a strictly better spelling here: the pre-call address is never bound, so the stale pointer the #8082 fault wrote through is not nameable at all rather than merely re-read. - the js_array_push_f64 call and both nanbox_arr tails become with_mut_ptr — push is self-rooting for the array it is handed (its grow path roots and re-reads it) and returns the current address, and nanbox_arr only tags the pointer. Behaviour is unchanged: the value pushed is still read from its handle before the call, exactly as before. The moving-GC regression still fails when the element pointer is hoisted back out of the reload, so the conversion did not weaken it. --- changelog.d/8131-moving-gc-rooting-sweep.md | 7 ++-- crates/perry-runtime/src/array/generic.rs | 41 +++++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/changelog.d/8131-moving-gc-rooting-sweep.md b/changelog.d/8131-moving-gc-rooting-sweep.md index 7c4704b02a..200dbe1374 100644 --- a/changelog.d/8131-moving-gc-rooting-sweep.md +++ b/changelog.d/8131-moving-gc-rooting-sweep.md @@ -40,10 +40,9 @@ surface onto the runtime's transient-handle stack, so ext crates can root the table snapshots they hold across JS callbacks. -- Instruments: the whole-heap from-space scan bounds array walks by live - length (capacity slack was producing false MISSING-REWRITE aborts) and - appends a classified payload preview to each offender so the owner - identifies itself; `PERRY_GC_STACKMAP_TRACE=1` prints every frame the +- Instruments: the whole-heap from-space scan appends a classified payload + preview to each offender, so the owner identifies itself instead of being + an anonymous address; `PERRY_GC_STACKMAP_TRACE=1` prints every frame the native stack-map walk visits; `PERRY_EH_TRACE=1` prints per-frame personality decisions. diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index a0d401c05a..9aade46014 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -707,16 +707,19 @@ pub extern "C" fn js_arraylike_map(recv: f64, cb: f64, this_arg: f64) -> f64 { continue; // preserve holes } let v = al_get(recv_h.get_nanbox_f64(), k); - let mapped = js_closure_call3( - callable(cb_h.get_nanbox_f64()), - v, - k as f64, - recv_h.get_nanbox_f64(), - ); - // Re-derive the element pointer AFTER the callback: the collection it - // may have triggered moves the result array (#8082 — this exact write - // landed in mprotect-poisoned from-space under the forced gate). - let result = result_h.get_raw_mut_ptr::(); + // `across_mut` runs the callback and hands back the result array's + // POST-collection address: the callback can allocate, and #8082 caught + // this exact write landing in mprotect-poisoned from-space when the + // element pointer was derived before it. The pre-call address is never + // bound, so there is nothing stale to reach for. + let (mapped, result) = result_h.across_mut::(|| { + js_closure_call3( + callable(cb_h.get_nanbox_f64()), + v, + k as f64, + recv_h.get_nanbox_f64(), + ) + }); let elems = unsafe { (result as *mut u8).add(std::mem::size_of::()) as *mut f64 }; unsafe { @@ -725,7 +728,9 @@ pub extern "C" fn js_arraylike_map(recv: f64, cb: f64, this_arg: f64) -> f64 { note_array_slot(result, k as usize, mapped.to_bits()); } } - nanbox_arr(result_h.get_raw_mut_ptr::()) + // Scoped argument to a non-allocating operation: `nanbox_arr` only tags the + // pointer, so the address cannot go stale inside the call. + result_h.with_mut_ptr::(nanbox_arr) } #[no_mangle] @@ -756,14 +761,18 @@ pub extern "C" fn js_arraylike_filter(recv: f64, cb: f64, this_arg: f64) -> f64 recv_h.get_nanbox_f64(), ); if crate::value::js_is_truthy(keep) != 0 { - let grown = js_array_push_f64( - result_h.get_raw_mut_ptr::(), - v_h.get_nanbox_f64(), - ); + // `js_array_push_f64` is self-rooting for the array it is handed + // (its grow path roots and re-reads it) and returns the current + // address, so a scoped argument is the right shape here. The value + // is read from its handle first, exactly as before. + let value = v_h.get_nanbox_f64(); + let grown = + result_h.with_mut_ptr::(|arr| js_array_push_f64(arr, value)); result_h.set_raw_mut_ptr(grown); } } - nanbox_arr(result_h.get_raw_mut_ptr::()) + // Scoped argument to a non-allocating operation — see `js_arraylike_map`. + result_h.with_mut_ptr::(nanbox_arr) } #[no_mangle]