From 8158f748ec44ce6ec1383d45d957a70f1ecf50d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 11:52:39 +0200 Subject: [PATCH] fix(worker): root the awaited handler Promise across perry_poll await_promise cached a raw Promise pointer and then called perry_poll in a loop. perry_poll runs JS, which reaches GC safepoints, and an evacuating minor MOVES the Promise -- after which the cached address is stale from-space memory and js_promise_state reads recycled bytes. Caught precisely with PERRY_GC_PROTECT_FROMSPACE: [gc-fromspace-protect] FAULT: signal 10 This address is RETIRED FROM-SPACE. The evacuating minor moved or freed the object here and the holder kept the pre-collection address. last-known object: obj_type=5 size=72 (5 = GC_TYPE_PROMISE) The faulting instruction IS the stale use. Re-deriving the pointer from the NaN-boxed value each turn does not help -- the box holds the same pre-collection address. Root it in Perry's FFI root scope and re-read the slot each turn, so the collector rewrites it. An RAII guard pops the scope on every exit path, including the timeout return. A/B on the same fixture and seed (PERRY_GC_SCHEDULE_SEED=12345 PERRY_GC_SCHEDULE_RATE=0.5), instrument armed both times: before: 116 copying minors -> FAULT (obj_type=5) after: 107 copying minors -> 0 faults, no TypeError Refs perryts/perry#8546. That issue is NOT fully closed by this: with two Next.js apps the remaining failures are now NAMED ("_onUserlandLoaded is not a function"), indicating further holders of the same class. --- crates/coop-worker/src/plugin_host.rs | 46 +++++++++++++++++++-- crates/coop-worker/src/runtime_libraries.rs | 15 +++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/coop-worker/src/plugin_host.rs b/crates/coop-worker/src/plugin_host.rs index 1a20fc9..fa9ae7f 100644 --- a/crates/coop-worker/src/plugin_host.rs +++ b/crates/coop-worker/src/plugin_host.rs @@ -316,8 +316,9 @@ impl LoadedPlugin { /// wake primitive until I/O or the next timer is ready. There is no fixed /// polling quantum on the request path. fn await_promise(&mut self, promise_value: f64) -> Result { - // Extract the raw Promise pointer from the NaN-boxed value. - let promise_ptr = { + // Validate the shape up front; the pointer itself is re-derived from + // the root on every loop turn (see below). + { let bits = promise_value.to_bits(); if (bits & !POINTER_MASK) != POINTER_TAG { return Err(anyhow!( @@ -325,16 +326,41 @@ impl LoadedPlugin { bits )); } - (bits & POINTER_MASK) as usize as *mut u8 - }; + } let api = crate::runtime_libraries::runtime_api(); + + // ROOT the Promise for the whole await. `perry_poll` runs JS, which + // reaches GC safepoints, and an evacuating minor MOVES the Promise -- + // after which the cached pointer is a stale from-space address and + // `js_promise_state` reads recycled memory. + // + // PERRY_GC_PROTECT_FROMSPACE faults precisely here: + // + // [gc-fromspace-protect] FAULT: signal 10 + // This address is RETIRED FROM-SPACE. The evacuating minor moved or + // freed the object here and the holder kept the pre-collection address. + // last-known object: obj_type=5 size=72 (5 = GC_TYPE_PROMISE) + // + // Re-deriving the pointer from `promise_value` each turn would NOT + // help: the NaN-box holds the same pre-collection address. Only a root + // the collector rewrites is stable, so re-read the slot every turn. + let root_base = unsafe { (api.js_ffi_root_scope_enter)() }; + let root_slot = unsafe { (api.js_ffi_root_push_nanbox)(promise_value.to_bits()) }; + let _root_scope = FfiRootScope { base: root_base }; + let start = Instant::now(); loop { unsafe { let _ = (api.perry_poll)(); } + // Re-read through the root: the collector rewrote it if it moved. + let promise_ptr = { + let bits = unsafe { (api.js_ffi_root_get_nanbox)(root_slot) }; + (bits & POINTER_MASK) as usize as *mut u8 + }; + let state = unsafe { (api.js_promise_state)(promise_ptr) }; match state { 1 => { @@ -633,6 +659,18 @@ fn make_perry_buffer(bytes: &[u8]) -> Result { Ok(value) } +/// Pops Perry's FFI root scope on every exit path, including the `?` and +/// timeout returns out of the await loop. +struct FfiRootScope { + base: usize, +} + +impl Drop for FfiRootScope { + fn drop(&mut self) { + unsafe { (crate::runtime_libraries::runtime_api().js_ffi_root_scope_exit)(self.base) }; + } +} + fn read_perry_string(value: f64) -> Option { // js_get_string_pointer_unified handles both heap-allocated strings // (STRING_TAG) and inline SSO strings (SHORT_STRING_TAG) by diff --git a/crates/coop-worker/src/runtime_libraries.rs b/crates/coop-worker/src/runtime_libraries.rs index cc31729..26c79e5 100644 --- a/crates/coop-worker/src/runtime_libraries.rs +++ b/crates/coop-worker/src/runtime_libraries.rs @@ -15,6 +15,13 @@ use std::time::Instant; pub(crate) type JsGcInit = unsafe extern "C" fn(); pub(crate) type JsPromiseState = unsafe extern "C" fn(*mut u8) -> i32; pub(crate) type JsPromiseValue = unsafe extern "C" fn(*mut u8) -> f64; +/// Perry's FFI root scope. A host that holds a JS value across anything that +/// can run JS must root it: an evacuating minor MOVES the object, and a cached +/// raw address becomes a stale from-space pointer. +pub(crate) type JsFfiRootScopeEnter = unsafe extern "C" fn() -> usize; +pub(crate) type JsFfiRootScopeExit = unsafe extern "C" fn(usize); +pub(crate) type JsFfiRootPushNanbox = unsafe extern "C" fn(u64) -> usize; +pub(crate) type JsFfiRootGetNanbox = unsafe extern "C" fn(usize) -> u64; pub(crate) type PerryPoll = unsafe extern "C" fn() -> i32; pub(crate) type JsWaitForEvent = unsafe extern "C" fn(); pub(crate) type JsValueIsPromise = unsafe extern "C" fn(f64) -> i32; @@ -37,6 +44,10 @@ pub(crate) struct RuntimeApi { pub js_promise_state: JsPromiseState, pub js_promise_value: JsPromiseValue, pub js_promise_reason: JsPromiseValue, + pub js_ffi_root_scope_enter: JsFfiRootScopeEnter, + pub js_ffi_root_scope_exit: JsFfiRootScopeExit, + pub js_ffi_root_push_nanbox: JsFfiRootPushNanbox, + pub js_ffi_root_get_nanbox: JsFfiRootGetNanbox, pub perry_poll: PerryPoll, pub js_wait_for_event: JsWaitForEvent, pub js_value_is_promise: JsValueIsPromise, @@ -181,6 +192,10 @@ pub fn initialize_runtime_libraries_with_verification( js_promise_state: load_symbol(runtime_handle, "js_promise_state")?, js_promise_value: load_symbol(runtime_handle, "js_promise_value")?, js_promise_reason: load_symbol(runtime_handle, "js_promise_reason")?, + js_ffi_root_scope_enter: load_symbol(runtime_handle, "js_ffi_root_scope_enter")?, + js_ffi_root_scope_exit: load_symbol(runtime_handle, "js_ffi_root_scope_exit")?, + js_ffi_root_push_nanbox: load_symbol(runtime_handle, "js_ffi_root_push_nanbox")?, + js_ffi_root_get_nanbox: load_symbol(runtime_handle, "js_ffi_root_get_nanbox")?, perry_poll: load_symbol(runtime_handle, "perry_poll")?, js_wait_for_event: load_symbol(runtime_handle, "js_wait_for_event")?, js_value_is_promise: load_symbol(runtime_handle, "js_value_is_promise")?,