From 1218ba3f08443d02512c2427d1bcc8b9fc11d034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 16:51:28 +0200 Subject: [PATCH 1/2] fix(gc): clear the raw-handle debt regression and ratchet to 1003 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Layer 3 debt ratchet has been RED on main since #7424, which added two get_raw_mut_ptr reads to the proxy splice path: 1006 -> 1008, and the baseline may only move DOWN, so the gate could not be satisfied by re-pinning. Both #7424 sites are correct — they re-derive the receiver after a trap that can move it. The ratchet counts them because they spell that as a manual call/re-read pair rather than the combinator the RFC prescribes. One converts exactly; the other is a terminal read with no allocating call to wrap. Converted five call/re-read pairs to across_{mut,const}: array/push_pop.rs proxy splice: trap then re-derive promise/then.rs (x2) js_promise_new_with_parent then re-read object/async_generator_queue.rs js_promise_new then re-read string/append.rs js_string_from_bytes_with_capacity then re-read Each is semantically identical — across_* runs the call and returns the post-collection address — but the ordering becomes structural instead of conventional, which is the whole point of #7341: every fix in that family was an ordering bug, not a missing root. 1008 -> 1003; baseline locked at 1003. 1705 lib tests pass. --- crates/perry-runtime/src/array/push_pop.rs | 12 ++++++++---- .../src/object/async_generator_queue.rs | 6 ++++-- crates/perry-runtime/src/promise/then.rs | 12 ++++++++---- crates/perry-runtime/src/string/append.rs | 7 +++++-- scripts/raw_handle_debt_baseline.txt | 2 +- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 801294c40a..a7b4a9649f 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -443,10 +443,14 @@ pub(super) fn proxy_array_mutator( for k in 0..actual_delete_count { let from = (actual_start + k).to_string(); if proxy_has_str_key(p(), from.as_bytes()) { - let v = proxy_get_str_key(p(), from.as_bytes()); - // Re-derive `removed` from its handle: the traps above - // run arbitrary JS, which can move it. - let removed = removed_handle.get_raw_mut_ptr::(); + // The trap runs arbitrary JS and can move `removed`, so + // its address is only valid after the call. `across_mut` + // is that pattern as one combinator: it runs the call and + // hands back the post-collection address, so a stale + // pointer is never bound in between (#7341). + let (v, removed) = removed_handle.across_mut::(|| { + proxy_get_str_key(p(), from.as_bytes()) + }); let elems = (removed as *mut u8).add(std::mem::size_of::()) as *mut f64; // GC_STORE_AUDIT(BARRIERED): note_array_slot re-stores diff --git a/crates/perry-runtime/src/object/async_generator_queue.rs b/crates/perry-runtime/src/object/async_generator_queue.rs index 96aecce872..3e53ced400 100644 --- a/crates/perry-runtime/src/object/async_generator_queue.rs +++ b/crates/perry-runtime/src/object/async_generator_queue.rs @@ -268,8 +268,10 @@ fn async_generator_request(closure: *const ClosureHeader, arg: f64, kind: Reques let scope = crate::gc::RuntimeHandleScope::new(); let original_handle = scope.root_raw_const_ptr(original); let arg_handle = scope.root_nanbox_f64(arg); - let promise = js_promise_new(); - let original = original_handle.get_raw_const_ptr::(); + // `across_const` pairs the allocating call with the re-read, so the + // closure pointer cannot be bound stale in between (#7341). + let (promise, original) = + original_handle.across_const::(|| js_promise_new()); let arg = arg_handle.get_nanbox_f64(); STATES.with(|states| { if let Some(state) = states.borrow_mut().get_mut(state_id - 1) { diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index 4a345b832c..a2659a408b 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -488,8 +488,10 @@ pub extern "C" fn js_promise_then( let promise_handle = scope.root_raw_mut_ptr(promise); let on_fulfilled_handle = scope.root_raw_const_ptr(on_fulfilled); let on_rejected_handle = scope.root_raw_const_ptr(on_rejected); - let next = js_promise_new_with_parent(promise); - let promise = promise_handle.get_raw_mut_ptr::(); + // `across_mut` runs the allocating call and returns the post-collection + // address, so the receiver is never bound stale in between (#7341). + let (next, promise) = + promise_handle.across_mut::(|| js_promise_new_with_parent(promise)); let on_fulfilled = on_fulfilled_handle.get_raw_const_ptr::(); let on_rejected = on_rejected_handle.get_raw_const_ptr::(); @@ -764,8 +766,10 @@ pub extern "C" fn js_promise_finally( let scope = crate::gc::RuntimeHandleScope::new(); let promise_handle = scope.root_raw_mut_ptr(promise); let on_finally_handle = scope.root_raw_const_ptr(on_finally); - let next = js_promise_new_with_parent(promise); - let promise = promise_handle.get_raw_mut_ptr::(); + // See the sibling in `then`: the allocating call and the re-read are one + // combinator so the order cannot drift apart (#7341). + let (next, promise) = + promise_handle.across_mut::(|| js_promise_new_with_parent(promise)); let on_finally = on_finally_handle.get_raw_const_ptr::(); let next_i64 = next as i64; diff --git a/crates/perry-runtime/src/string/append.rs b/crates/perry-runtime/src/string/append.rs index a3c15de56d..c0cde49f23 100644 --- a/crates/perry-runtime/src/string/append.rs +++ b/crates/perry-runtime/src/string/append.rs @@ -39,8 +39,11 @@ pub extern "C" fn js_string_append( let scope = crate::gc::RuntimeHandleScope::new(); let src_handle = scope.root_string_ptr(src); let src_blen = unsafe { (*src).byte_len }; - let new_ptr = js_string_from_bytes_with_capacity(ptr::null(), 0, src_blen); - let src = src_handle.get_raw_const_ptr::(); + // `across_const` pairs the allocating call with the re-read, so the + // source pointer cannot be bound stale in between (#7341). + let (new_ptr, src) = src_handle.across_const::(|| { + js_string_from_bytes_with_capacity(ptr::null(), 0, src_blen) + }); if is_valid_string_ptr(src) { unsafe { let src_data = string_data(src); diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index 9540e56f97..baccd0398f 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -1006 +1003 From 101693adff570332e8f417b287b20d8ac1e712c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 16:51:44 +0200 Subject: [PATCH 2/2] docs: changelog fragment for #7455 --- changelog.d/7455-raw-handle-debt.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7455-raw-handle-debt.md diff --git a/changelog.d/7455-raw-handle-debt.md b/changelog.d/7455-raw-handle-debt.md new file mode 100644 index 0000000000..9bd24f25b7 --- /dev/null +++ b/changelog.d/7455-raw-handle-debt.md @@ -0,0 +1 @@ +- **The Layer 3 raw-handle debt ratchet is green again, at a lower number.** It had been red on `main` since #7424 took it 1006 → 1008, and its baseline may only move *down*, so re-pinning was not an option. Both #7424 sites are correct — they re-derive a receiver after a proxy trap that can move it — but they spell that as a manual call/re-read pair rather than the `RuntimeHandle::across_*` combinator the RFC prescribes. Five such pairs are now converted (`array/push_pop.rs`, `promise/then.rs` ×2, `object/async_generator_queue.rs`, `string/append.rs`); each is semantically identical, but the ordering becomes structural instead of conventional — which is the point of #7341, where every fix in the family was an ordering bug rather than a missing root. 1008 → 1003, baseline locked at 1003. (#7455)