From b5fea8838acf11a369962ef400c6609a33cde616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 21:30:19 +0200 Subject: [PATCH 1/4] fix(runtime): pin cross-thread promises in their constructor until they settle (#9552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A promise minted for a cross-thread settlement — every stdlib fetch/db/ws request, `spawn`, `Atomics.waitAsync` — leaves the runtime as a bare usize inside a worker future and is invisible to every root scanner until its completion is queued back. Nothing on the JS side points AT it either: the awaiting continuation hangs OFF it (`P.on_fulfilled`) and `P.next` is an edge out. The pin was the caller's job; `spawn` and `waitAsync` took it, ~110 stdlib sites (fetch among them) never did. A full collection landing in the in-flight window freed the promise, and the completion then resolved whatever the allocator had reused the slot for — in the report, a RegExp header read as the promise's `next` inside the microtask pump. `js_promise_new_cross_thread` now takes the pin itself (one flag bit on a malloc-resident object it is already writing; the young-pin latch is not consulted) and the settlement paths release it (one byte test on the promise's own cache line). A token dropped without a settlement releases it too. The caller-side pins in `spawn` and `waitAsync` are gone; the stdlib bridge helper is now exactly the constructor. Every place a raw promise address re-enters the runtime from native code (the stdlib pump, the native-async token pump, the thread-result drain) classifies the address first and aborts naming the site and the slot's occupant, so a future rooting hole fails at the boundary instead of as heap corruption cycles later. `scripts/check_cross_thread_promise_provenance.py` (lint) finds arena promises handed to a native settlement sink or captured by a spawn, self-tested with three planted shapes and three clean ones. Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up --- .github/workflows/test.yml | 10 + crates/perry-runtime/src/atomics.rs | 6 +- crates/perry-runtime/src/gc/mod.rs | 2 +- crates/perry-runtime/src/gc/pin.rs | 25 +- .../src/promise/cross_thread_pin_tests.rs | 128 +++++++++ crates/perry-runtime/src/promise/mod.rs | 72 +++++ .../perry-runtime/src/promise/native_async.rs | 13 +- crates/perry-runtime/src/promise/then.rs | 58 +++- crates/perry-runtime/src/thread.rs | 29 +- .../perry-stdlib/src/common/async_bridge.rs | 48 ++-- .../check_cross_thread_promise_provenance.py | 264 ++++++++++++++++++ ...p_9552_cross_thread_promise_survives_gc.ts | 44 +++ 12 files changed, 638 insertions(+), 61 deletions(-) create mode 100644 crates/perry-runtime/src/promise/cross_thread_pin_tests.rs create mode 100755 scripts/check_cross_thread_promise_provenance.py create mode 100644 test-files/test_gap_9552_cross_thread_promise_survives_gc.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dde59c69f6..949f1cb142 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -391,6 +391,16 @@ jobs: python3 scripts/gc_pin_sites.py --self-test python3 scripts/gc_pin_sites.py + # #9552. A promise handed to native code as a bare address is invisible + # to every root scanner until its completion is queued back. The + # cross-thread constructor pins it for that window; the arena constructor + # cannot. This finds arena promises reaching a native settlement sink. + - name: Cross-thread promise provenance (#9552) + if: ${{ !cancelled() }} + run: | + python3 scripts/check_cross_thread_promise_provenance.py --self-test + python3 scripts/check_cross_thread_promise_provenance.py + # #7231. A runtime-side table holding a GC pointer IS a root, and nothing # static could see that class before: gc_root_dominance_check.py reads # emitted LLVM IR and a thread_local is not in it. #7226, #7239, #7268 and diff --git a/crates/perry-runtime/src/atomics.rs b/crates/perry-runtime/src/atomics.rs index 2b35827d6c..d2abd66ba1 100644 --- a/crates/perry-runtime/src/atomics.rs +++ b/crates/perry-runtime/src/atomics.rs @@ -768,11 +768,9 @@ pub extern "C" fn js_atomics_wait_async( // Cross-thread variant: referenced only by a raw usize in the pending // results queue until drained — must not live in the copying nursery // (the from-space flip ignores pins that no scanner reaches). + // #9552: the cross-thread constructor pins the promise until it settles; + // this only has to keep the event loop alive until the result lands. let promise = crate::promise::js_promise_new_cross_thread(); - // Pin the promise + keep the event loop alive until the async result lands. - unsafe { - crate::thread::pin_promise(promise); - } crate::thread::thread_job_begin(); let promise_usize = promise as usize; // #6185: the promise belongs to the agent calling `waitAsync`. The futex diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index ea794f7e55..655d8d4162 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -127,7 +127,7 @@ mod pin; pub(crate) use pin::test_reset_young_pin_latch; pub use pin::{ copied_minor_preflight_skips, copied_minor_preflight_walks, pin_object, pin_object_non_young, - unpin_object, + pin_user_ptr_non_young, unpin_object, unpin_user_ptr, }; use pin::{note_preflight_skipped, note_preflight_walked, young_pin_latch_armed}; /// Software prefetch helpers for the collector's pointer-chasing loops diff --git a/crates/perry-runtime/src/gc/pin.rs b/crates/perry-runtime/src/gc/pin.rs index 4b24e5232b..a64b53cc95 100644 --- a/crates/perry-runtime/src/gc/pin.rs +++ b/crates/perry-runtime/src/gc/pin.rs @@ -75,7 +75,7 @@ use std::cell::Cell; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use super::types::{GcHeader, GC_FLAG_ARENA, GC_FLAG_PINNED}; +use super::types::{GcHeader, GC_FLAG_ARENA, GC_FLAG_PINNED, GC_HEADER_SIZE}; crate::perry_thread_local! { static COPYING_WALK_PHASE: Cell> = @@ -266,6 +266,29 @@ pub(crate) unsafe fn pin_constrains_copying_minor_for_tests(header: *mut GcHeade /// # Safety /// /// As [`pin_object`]. +/// Pin the non-young object whose USER pointer is `user_ptr` (#9552). +/// +/// The address arithmetic lives here, next to the flag it serves, so callers +/// that hold a `*mut Promise` (or any other user pointer) do not each grow a +/// bare `GcHeader` cast. Malloc-resident and old-arena objects only: the +/// young-pin latch is deliberately not consulted (see `pin_object_non_young`). +#[inline] +pub unsafe fn pin_user_ptr_non_young(user_ptr: *mut u8) { + if user_ptr.is_null() { + return; + } + pin_object_non_young(user_ptr.sub(GC_HEADER_SIZE) as *mut GcHeader); +} + +/// Release the pin on the object whose USER pointer is `user_ptr` (#9552). +#[inline] +pub unsafe fn unpin_user_ptr(user_ptr: *mut u8) { + if user_ptr.is_null() { + return; + } + unpin_object(user_ptr.sub(GC_HEADER_SIZE) as *mut GcHeader); +} + #[inline] pub unsafe fn unpin_object(header: *mut GcHeader) { if header.is_null() { diff --git a/crates/perry-runtime/src/promise/cross_thread_pin_tests.rs b/crates/perry-runtime/src/promise/cross_thread_pin_tests.rs new file mode 100644 index 0000000000..5541d67bc4 --- /dev/null +++ b/crates/perry-runtime/src/promise/cross_thread_pin_tests.rs @@ -0,0 +1,128 @@ +//! #9552 — a promise whose address leaves the runtime as a bare `usize` (a +//! worker future, a pending-result queue, a native async token) is pinned by +//! `js_promise_new_cross_thread` and released by its settlement. These pin the +//! constructor/settlement contract and the trust boundary that classifies a +//! returning address. + +use super::native_async::{ + js_native_async_completion_new, js_native_async_completion_promise, + js_native_async_drop_promise_token, native_async_promise_has_token, test_native_async_lock, + test_reset_native_async_registry, +}; +use super::{ + classify_native_promise_addr, js_promise_new, js_promise_new_cross_thread, js_promise_reject, + js_promise_resolve, NativePromiseAddr, Promise, +}; +use crate::value::addr_class::try_read_gc_header; + +fn gc_flags(promise: *mut Promise) -> u8 { + unsafe { try_read_gc_header(promise as usize) } + .expect("a freshly minted promise is a tracked heap object") + .gc_flags +} + +fn pinned(promise: *mut Promise) -> bool { + gc_flags(promise) & crate::gc::GC_FLAG_PINNED != 0 +} + +#[test] +fn cross_thread_promise_is_pinned_at_creation_and_released_by_fulfilment() { + let _guard = test_native_async_lock(); + let promise = js_promise_new_cross_thread(); + assert_eq!( + gc_flags(promise) & crate::gc::GC_FLAG_ARENA, + 0, + "cross-thread promises are malloc-resident" + ); + assert!(pinned(promise), "#9552: the constructor takes the pin"); + assert_eq!(unsafe { (*promise).native_pinned }, 1); + + js_promise_resolve(promise, 1.0); + assert!(!pinned(promise), "settlement releases the pin"); + assert_eq!(unsafe { (*promise).native_pinned }, 0); + + // A second settlement is a no-op on an already-settled promise and must + // not touch the pin state. + js_promise_reject(promise, 2.0); + assert!(!pinned(promise)); +} + +#[test] +fn rejection_releases_the_pin_too() { + let _guard = test_native_async_lock(); + let promise = js_promise_new_cross_thread(); + assert!(pinned(promise)); + js_promise_reject(promise, 2.0); + assert!(!pinned(promise)); + assert_eq!(unsafe { (*promise).native_pinned }, 0); +} + +#[test] +fn cross_thread_promise_survives_a_full_collection_while_only_native_code_holds_it() { + let _guard = test_native_async_lock(); + // Hold the address the way a worker future does: as a bare integer no + // root scanner visits. XOR-hide it so a conservative stack scan (if one + // were to run) cannot keep the object alive by accident and make the + // assertion vacuous. + const MASK: usize = 0x5555_5555_5555_5555; + let hidden = (js_promise_new_cross_thread() as usize) ^ MASK; + crate::gc::js_gc_collect(); + let raw = hidden ^ MASK; + match classify_native_promise_addr(raw) { + NativePromiseAddr::Live(promise) => { + assert!(pinned(promise), "still pinned while in flight"); + js_promise_resolve(promise, 3.0); + assert!(!pinned(promise)); + } + other => panic!("#9552: in-flight promise did not survive the collection: {other:?}"), + } +} + +#[test] +fn arena_promises_carry_no_pin() { + let _guard = test_native_async_lock(); + let promise = js_promise_new(); + assert!(!pinned(promise)); + assert_eq!(unsafe { (*promise).native_pinned }, 0); + js_promise_resolve(promise, 1.0); + assert!(!pinned(promise)); +} + +#[test] +fn dropping_a_token_without_settling_releases_the_pin() { + let _guard = test_native_async_lock(); + test_reset_native_async_registry(); + let token = js_native_async_completion_new(0); + let promise = js_native_async_completion_promise(token); + assert!(pinned(promise), "token promises are cross-thread promises"); + assert!(native_async_promise_has_token(promise)); + // The token was the promise's root; once it is gone the pin must not keep + // a never-settling promise alive forever. + js_native_async_drop_promise_token(promise); + assert!(!native_async_promise_has_token(promise)); + assert!(!pinned(promise)); + assert_eq!(unsafe { (*promise).native_pinned }, 0); +} + +#[test] +fn classify_native_promise_addr_names_null_live_and_reused_slots() { + let _guard = test_native_async_lock(); + assert_eq!(classify_native_promise_addr(0), NativePromiseAddr::Null); + let promise = js_promise_new_cross_thread(); + assert_eq!( + classify_native_promise_addr(promise as usize), + NativePromiseAddr::Live(promise) + ); + // A malloc-resident object of another type where a promise used to be. + let occupant = crate::gc::gc_malloc(64, crate::gc::GC_TYPE_STRING) as usize; + assert_eq!( + classify_native_promise_addr(occupant), + NativePromiseAddr::WrongType(crate::gc::GC_TYPE_STRING) + ); + // Not a heap object at all. + assert_eq!( + classify_native_promise_addr(0x10), + NativePromiseAddr::NotAHeapObject + ); + js_promise_resolve(promise, 0.0); +} diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index 8635855fe5..51254b37d3 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -22,6 +22,8 @@ pub mod assimilate; pub mod async_step; pub mod checked_dispatch; pub mod combinators; +#[cfg(test)] +mod cross_thread_pin_tests; pub(crate) mod keyed_table; pub mod microtasks; pub mod native_async; @@ -532,6 +534,12 @@ pub type ClosurePtr = *const crate::closure::ClosureHeader; pub struct Promise { /// Current state of the promise pub(crate) state: PromiseState, + /// #9552 — non-zero while this promise holds the cross-thread pin taken by + /// `js_promise_new_cross_thread`. Lives in the padding after `state`, so no + /// other field moves. Cleared, and the pin released, by the settlement + /// paths (`js_promise_resolve` / `js_promise_reject`) and by + /// `remove_token_from_registry` for a token dropped without settling. + pub(crate) native_pinned: u8, /// The resolved value (if fulfilled) pub(crate) value: f64, /// The rejection reason (if rejected) @@ -565,6 +573,7 @@ impl Promise { pub(crate) fn new() -> Self { Promise { state: PromiseState::Pending, + native_pinned: 0, value: 0.0, reason: 0.0, on_fulfilled: ptr::null(), @@ -1264,3 +1273,66 @@ pub extern "C" fn js_microtasks_pending() -> i32 { } TASK_QUEUE.with(|q| if q.borrow().is_empty() { 0 } else { 1 }) } + +/// #9552 — what a raw promise address handed back by native code names. +#[derive(Debug, PartialEq, Eq)] +pub enum NativePromiseAddr { + /// A null hand-off (a caller that never minted a promise). + Null, + /// A live promise. + Live(*mut Promise), + /// Not a tracked heap object at all (freed and unmapped, or never one). + NotAHeapObject, + /// A heap object of another type: the promise was freed and its slot + /// reused. The payload is the occupant's `obj_type`. + WrongType(u8), +} + +/// Classify `addr` without dereferencing anything the heap does not vouch +/// for. Pure, so the abort policy in [`native_promise_from_raw`] is testable. +pub fn classify_native_promise_addr(addr: usize) -> NativePromiseAddr { + if addr == 0 { + return NativePromiseAddr::Null; + } + match unsafe { crate::value::addr_class::try_read_gc_header(addr) } { + None => NativePromiseAddr::NotAHeapObject, + Some(header) if header.obj_type == crate::gc::GC_TYPE_PROMISE => { + NativePromiseAddr::Live(addr as *mut Promise) + } + Some(header) => NativePromiseAddr::WrongType(header.obj_type), + } +} + +/// The trust boundary for a promise address that left the runtime as a bare +/// `usize` (a worker future, a pending-result queue, a native async token) and +/// is now coming back to be settled (#9552). +/// +/// A stale address here is a use-after-free in the making: `js_promise_resolve` +/// would write a state byte and a value into whatever the allocator has since +/// put in the slot, and the corruption surfaces cycles later in an unrelated +/// object (the #9552 report was a RegExp header read as a promise's `next`). +/// Aborting at the boundary names the site and the occupant instead. This runs +/// once per native completion — never per `await` — so it is not on any hot +/// path. +pub fn native_promise_from_raw(addr: usize, site: &str) -> *mut Promise { + match classify_native_promise_addr(addr) { + NativePromiseAddr::Null => ptr::null_mut(), + NativePromiseAddr::Live(promise) => promise, + NativePromiseAddr::NotAHeapObject => { + eprintln!( + "[perry] FATAL (#9552): {site} handed back promise address {addr:#x}, which is \ + not a tracked heap object — the promise was freed while native code still \ + held its address. It was not rooted across its in-flight window." + ); + std::process::abort() + } + NativePromiseAddr::WrongType(obj_type) => { + eprintln!( + "[perry] FATAL (#9552): {site} handed back promise address {addr:#x}, but the \ + object there now has obj_type={obj_type} — the promise was freed while native \ + code still held its address and the slot was reused." + ); + std::process::abort() + } + } +} diff --git a/crates/perry-runtime/src/promise/native_async.rs b/crates/perry-runtime/src/promise/native_async.rs index 0801037cce..98264fbc76 100644 --- a/crates/perry-runtime/src/promise/native_async.rs +++ b/crates/perry-runtime/src/promise/native_async.rs @@ -231,6 +231,12 @@ fn payload_to_settlement(payload: PendingPayload) -> (bool, u64, u32) { } fn remove_token_from_registry(token_ptr: usize, promise: usize) { + // #9552: the registry entry was the token promise's root; the constructor + // pin must not outlive it, or a cancelled token (no settlement, so no + // `js_promise_resolve` to release it) would leak its promise forever. + if promise != 0 { + unsafe { super::then::release_native_pin(promise as *mut Promise) }; + } let mut registry = crate::gc::lock_gc_root_registry(registry()); registry.tokens.retain(|&candidate| candidate != token_ptr); registry.pending.retain(|&candidate| candidate != token_ptr); @@ -506,7 +512,12 @@ pub extern "C" fn js_native_async_process_pending() -> i32 { }; let scope = crate::gc::RuntimeHandleScope::new(); - let promise_handle = scope.root_raw_mut_ptr(promise as *mut Promise); + // #9552: the token carried the address as a bare usize; verify it + // still names a promise before rooting and settling it. + let promise_handle = scope.root_raw_mut_ptr(super::native_promise_from_raw( + promise, + "native async token pump", + )); let handle_roots: Vec<_> = handles .iter() .map(|handle| scope.root_nanbox_u64(handle.value_bits)) diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index c2590921d9..c1a550b085 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -53,13 +53,33 @@ pub(crate) fn js_promise_new_with_parent(parent: *mut Promise) -> *mut Promise { } /// Allocate a Promise that will cross a thread boundary as a raw address -/// (`spawn`, `Atomics.waitAsync`): pinned by the caller and referenced only -/// by a `usize` in the global PENDING_THREAD_RESULTS queue, which no root -/// scanner visits. A nursery resident in that situation is destroyed by the -/// copied-minor from-space flip regardless of its PIN flag (the flip resets -/// eden/survivor blocks wholesale; only root-reachable pins force the -/// fallback). Malloc space is non-moving and both sweep paths honor -/// GC_FLAG_PINNED, so these promises are allocated there unconditionally. +/// (`spawn`, `Atomics.waitAsync`, every stdlib `fetch`/db/ws request that +/// settles through the stdlib pump): referenced only by a `usize` inside a +/// worker future or a pending-result queue, which no root scanner visits. +/// +/// Two properties follow, and this constructor owns both (#9552): +/// +/// * **Non-moving.** A nursery resident is destroyed by the copied-minor +/// from-space flip regardless of its PIN flag (the flip resets +/// eden/survivor blocks wholesale; only root-reachable pins force the +/// fallback). Malloc space is non-moving, so these promises are +/// allocated there unconditionally. +/// * **Rooted until it settles.** Nothing on the JS side points AT a +/// pending promise whose only consumer is an `await` continuation — +/// `P.on_fulfilled = step` and `P.next = N` are edges OUT of `P`, and the +/// worker's `usize` is invisible to the collector. Both sweep paths honor +/// `GC_FLAG_PINNED`, so the constructor pins here and the settlement +/// paths (`js_promise_resolve` / `js_promise_reject`) release the pin the +/// moment the native side is done with the address. Before this lived +/// here, the pin was the CALLER's job, and ~110 stdlib call sites +/// (`fetch` among them) never took it: a full collection landing while a +/// request was in flight freed the promise, and the completion then +/// resolved whatever the allocator had put in its place. +/// +/// The pin is one flag bit on an object this function is already writing, +/// and the release is one byte test on the settlement path; neither touches +/// the young-pin latch that pessimises copying minors (malloc residents are +/// never young-arena). #[no_mangle] pub extern "C" fn js_promise_new_cross_thread() -> *mut Promise { js_promise_new_with_parent_impl(ptr::null_mut(), true) @@ -89,6 +109,14 @@ fn js_promise_new_with_parent_impl(parent: *mut Promise, force_malloc: bool) -> unsafe { // GC_STORE_AUDIT(INIT): initializes freshly allocated Promise storage before the promise is published. ptr::write(promise, Promise::new()); + if force_malloc { + // #9552: see `js_promise_new_cross_thread`. The object is + // malloc-resident (never young-arena), so the non-young pin is + // the right one — it must not arm the copying minor's young-pin + // latch. + crate::gc::pin_user_ptr_non_young(promise as *mut u8); + (*promise).native_pinned = 1; + } let trigger_async_id = parent_handle.with_mut_ptr::(|parent| { if parent.is_null() { crate::async_hooks::execution_async_id_u64() @@ -203,6 +231,20 @@ pub extern "C" fn js_promise_result(promise: *mut Promise) -> f64 { } /// Resolve a promise with a value +/// #9552 — release the cross-thread pin `js_promise_new_cross_thread` took, +/// if this promise holds one. Called from every state transition out of +/// `Pending` and from the token registry when a token is dropped without a +/// settlement. Idempotent: the byte is cleared with the pin, so a second call +/// is one load and a not-taken branch. Ordinary (arena) promises pay exactly +/// that load; the byte shares `state`'s cache line. +#[inline] +pub(crate) unsafe fn release_native_pin(promise: *mut Promise) { + if (*promise).native_pinned != 0 { + (*promise).native_pinned = 0; + crate::gc::unpin_user_ptr(promise as *mut u8); + } +} + #[no_mangle] pub extern "C" fn js_promise_resolve(promise: *mut Promise, value: f64) { if promise.is_null() { @@ -214,6 +256,7 @@ pub extern "C" fn js_promise_resolve(promise: *mut Promise, value: f64) { } super::async_step::trace_async_settle(promise, "fulfill"); (*promise).state = PromiseState::Fulfilled; + release_native_pin(promise); store_promise_jsvalue_slot(promise, std::ptr::addr_of_mut!((*promise).value), value); crate::async_hooks::promise_resolve((*promise).async_id); crate::v8::promise_hook_settled(promise); @@ -422,6 +465,7 @@ pub extern "C" fn js_promise_reject(promise: *mut Promise, reason: f64) { } super::async_step::trace_async_settle(promise, "reject"); (*promise).state = PromiseState::Rejected; + release_native_pin(promise); store_promise_jsvalue_slot(promise, std::ptr::addr_of_mut!((*promise).reason), reason); crate::async_hooks::promise_resolve((*promise).async_id); crate::v8::promise_hook_settled(promise); diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index d33dfbd224..389cf4c2a6 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -1541,13 +1541,10 @@ unsafe fn spawn_impl(closure_val: f64) -> *mut crate::promise::Promise { // in PENDING_THREAD_RESULTS (no scanner) until drain — a nursery // resident would be destroyed by the copied-minor from-space flip even // while pinned. Malloc space is non-moving and sweeps honor the pin. + // #9552: the cross-thread constructor pins the promise; the settlement in + // `js_thread_process_pending` releases it. let promise = crate::promise::js_promise_new_cross_thread(); - // Pin the promise so GC doesn't collect it while the thread is running. - // Malloc-resident (see above), so this does not arm the young-pin latch. - let promise_header = (promise as *mut u8).sub(gc::GC_HEADER_SIZE) as *mut gc::GcHeader; - gc::pin_object_non_young(promise_header); - let promise_usize = promise as usize; // #6185: the promise lives in the SPAWNING agent's heap, so that is the // agent allowed to settle it. Captured here, on the spawning thread — @@ -1681,16 +1678,6 @@ pub fn thread_job_begin() { ACTIVE_THREAD_JOBS.fetch_add(1, Ordering::SeqCst); } -/// Pin `promise` so GC keeps it alive while a background job runs; the matching -/// unpin happens in [`js_thread_process_pending`] when the result resolves. -/// -/// # Safety -/// `promise` must be a live promise allocation preceded by an 8-byte GcHeader. -pub unsafe fn pin_promise(promise: *mut crate::promise::Promise) { - let header = (promise as *mut u8).sub(gc::GC_HEADER_SIZE) as *mut gc::GcHeader; - gc::pin_object_non_young(header); -} - /// Resolve the promise at `promise_usize` with a UTF-8 string on the agent that /// owns it. Routes through the same pending-result path `spawn` uses (which /// unpins the promise, deserializes the value into that agent's arena, @@ -1787,11 +1774,13 @@ pub extern "C" fn js_thread_process_pending() -> i32 { // `queue_thread_result` (deadlock on a re-entrant lock of the same Mutex). for item in mine { unsafe { - let promise = item.promise_ptr as *mut crate::promise::Promise; - - // Unpin the promise now that we're settling it. - let promise_header = (promise as *mut u8).sub(gc::GC_HEADER_SIZE) as *mut gc::GcHeader; - gc::unpin_object(promise_header); + // #9552: the address crossed the thread boundary as a bare usize; + // verify it still names a promise. The constructor's pin is + // released by the settlement below, not here. + let promise = crate::promise::native_promise_from_raw( + item.promise_ptr, + "perry/thread result drain", + ); // #6185: a worker that returned a non-transferable value (e.g. // `spawn(() => new Map())`) can't throw on its own thread (no diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 86e1e42157..ca75e7dd40 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -87,34 +87,22 @@ fn release_native_async_token(promise: *mut perry_runtime::Promise) { perry_runtime::promise::js_native_async_drop_promise_token(promise); } -/// Allocate a fresh Promise and pin it for cross-thread resolution. -/// Convenience wrapper for direct callers of [`queue_promise_resolution`] -/// / [`queue_deferred_resolution`] (fetch, zlib, bcrypt, ioredis, ws, -/// etc.) — modules that bypass `spawn_for_promise[_deferred]` because -/// their own future setup is custom. Equivalent to -/// `js_promise_new()` followed by [`pin_promise_for_native_resolution`]. +/// Allocate a fresh Promise for cross-thread resolution. Convenience wrapper +/// for direct callers of [`queue_promise_resolution`] / +/// [`queue_deferred_resolution`] — modules that bypass +/// `spawn_for_promise[_deferred]` because their own future setup is custom. +/// +/// #9552: the pin is taken by `js_promise_new_cross_thread` itself and +/// released when the promise settles, so this is now exactly that +/// constructor. Callers that reach for the bare constructor get the same +/// guarantee; this name survives for the modules that spell the intent. /// /// # Safety -/// Same as `js_promise_new()`; the pinning has no preconditions of -/// its own. The matching unpin runs automatically in -/// `js_stdlib_process_pending`. +/// Same as `js_promise_new()`. #[inline] pub unsafe fn js_promise_new_for_native_resolution() -> *mut perry_runtime::Promise { ensure_gc_scanner_registered(); - // #8770: allocate in MALLOC space (non-moving), not the nursery arena. A - // native-resolution promise is handed to a tokio worker as a raw `usize` and, - // until its resolution is queued into PENDING_RESOLUTIONS (which the root - // scanner visits), it is reachable only through that worker-thread capture — - // invisible to the main-thread copying minor. A nursery resident in that - // window is wiped by the from-space flip REGARDLESS of its PIN flag (the flip - // resets eden/survivor blocks wholesale; only root-reachable pins force the - // fallback — see `js_promise_new_cross_thread`). Then `js_stdlib_process_ - // pending` unpins/resolves through the stale pointer and faults on the - // reclaimed header. Malloc space is non-moving and both sweep paths honor - // GC_FLAG_PINNED, so the pin actually protects it there. - let p = perry_runtime::js_promise_new_cross_thread(); - pin_promise_for_native_resolution(p as usize); - p + perry_runtime::js_promise_new_cross_thread() } /// Count of in-flight `perry_ffi_spawn_blocking[_with_reactor]` tasks @@ -550,8 +538,11 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { for resolution in simple_resolutions { let scope = perry_runtime::gc::RuntimeHandleScope::new(); let promise_ptr_usize = resolution.promise_ptr; - let promise_handle = - scope.root_raw_mut_ptr(promise_ptr_usize as *mut perry_runtime::Promise); + // #9552: the address spent its in-flight window as a bare usize in a + // worker future; verify it still names a promise before touching it. + let promise_handle = scope.root_raw_mut_ptr( + perry_runtime::promise::native_promise_from_raw(promise_ptr_usize, "stdlib pump"), + ); let result_handle = scope.root_nanbox_u64(resolution.result_bits); // Issue #859: unpin BEFORE resolve so the just-settled promise // can be reclaimed by the next GC. Resolve doesn't trigger GC @@ -584,8 +575,11 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { for resolution in deferred_resolutions { let scope = perry_runtime::gc::RuntimeHandleScope::new(); let promise_ptr_usize = resolution.promise_ptr; - let promise_handle = - scope.root_raw_mut_ptr(promise_ptr_usize as *mut perry_runtime::Promise); + // #9552: the address spent its in-flight window as a bare usize in a + // worker future; verify it still names a promise before touching it. + let promise_handle = scope.root_raw_mut_ptr( + perry_runtime::promise::native_promise_from_raw(promise_ptr_usize, "stdlib pump"), + ); // Run the converter on the main thread to create JSValues safely let result_bits = (resolution.converter)(); let result_handle = scope.root_nanbox_u64(result_bits); diff --git a/scripts/check_cross_thread_promise_provenance.py b/scripts/check_cross_thread_promise_provenance.py new file mode 100755 index 0000000000..c8e0d98c47 --- /dev/null +++ b/scripts/check_cross_thread_promise_provenance.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""#9552 — arena promises must not be handed to native settlement paths. + +A promise whose address leaves the runtime as a bare integer (a tokio future, +`std::thread::spawn`, a pending-result queue) is invisible to every root +scanner until its completion is queued back. Two constructors exist: + + * `js_promise_new_cross_thread()` — malloc-resident (non-moving) AND pinned + by the constructor until the promise settles (#9552). Safe to hand off. + * `js_promise_new()` / `js_promise_new_with_parent(..)` — nursery-resident. + A copying minor relocates it behind the worker's back, and a full + collection frees it. NEVER safe to hand off. + +This gate finds functions that mint a promise with an ARENA constructor and +pass it (directly, via `as usize`, or via a `let p = promise as usize` alias) +into a native settlement sink, or capture it in a spawned closure/future. +Sinks are matched by name, so a new hand-off API must be added to `SINKS`. + +Exit 1 on any hit. `--self-test` proves the detector can still fail: it plants +the bad shape (and its alias/spawn variants) and asserts each is reported, and +plants the good shape and asserts it is not. +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SCAN_DIRS = ["crates/perry-runtime/src", "crates/perry-stdlib/src"] + [ + str(p.relative_to(ROOT)) for p in sorted(ROOT.glob("crates/perry-ext-*/src")) +] + +PROMISE_BINDING = re.compile( + r"\blet\s+(?:mut\s+)?(?P\w+)\s*(?::\s*[^=]+)?=\s*(?:[\w:]+::)?" + r"(?Pjs_promise_new(?:_with_parent|_cross_thread|_for_native_resolution)?)\s*\(" +) +ARENA_CTORS = {"js_promise_new", "js_promise_new_with_parent"} +ALIAS = re.compile(r"\blet\s+(?:mut\s+)?(?P\w+)\s*(?::\s*usize)?\s*=\s*(?P\w+)\s+as\s+usize\s*;") +SINKS = [ + "queue_promise_resolution", + "queue_deferred_resolution", + "queue_promise_string_result", + "queue_thread_result", + "spawn_for_promise", + "spawn_for_promise_deferred", + "perry_ffi_spawn_blocking", + "perry_ffi_spawn_async", + "spawn_blocking", + "spawn", +] +SINK_CALL = re.compile(r"\b(?:[\w:]+::)?(?P" + "|".join(map(re.escape, SINKS)) + r")\s*\(") +FN_HEADER = re.compile(r"\bfn\s+(?P\w+)\s*(?:<[^>]*>)?\s*\(") + + +def strip_comments(src: str) -> str: + """Blank out `//` comments (keeping line structure) so doc prose cannot + trip the matchers.""" + out = [] + for line in src.split("\n"): + cut = line.find("//") + if cut != -1 and line.count('"', 0, cut) % 2 == 0: + line = line[:cut] + out.append(line) + return "\n".join(out) + + +def match_brace(src: str, open_idx: int) -> int: + """Index just past the `}` matching the `{` at `open_idx` (or the `)` for + `(`), ignoring string literals.""" + opener = src[open_idx] + closer = {"{": "}", "(": ")"}[opener] + depth = 0 + i = open_idx + in_str = False + while i < len(src): + c = src[i] + if in_str: + if c == "\\": + i += 2 + continue + if c == '"': + in_str = False + elif c == '"': + in_str = True + elif c == opener: + depth += 1 + elif c == closer: + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return len(src) + + +def function_bodies(src: str): + """Yield (fn_name, body_start, body_text) for every fn in `src`.""" + for m in FN_HEADER.finditer(src): + sig_end = match_brace(src, m.end() - 1) + brace = src.find("{", sig_end) + semi = src.find(";", sig_end) + if brace == -1 or (semi != -1 and semi < brace): + continue # trait method without a body + end = match_brace(src, brace) + yield m.group("fn"), brace, src[brace:end] + + +def latest_before(entries, name, pos): + """The last (pos, value) entry for `name` that precedes `pos`, or None. + Bindings shadow: an early-return arena promise named `promise` must not + taint a later `let promise = js_promise_new_cross_thread()`.""" + best = None + for entry_pos, value in entries.get(name, ()): + if entry_pos < pos and (best is None or entry_pos > best[0]): + best = (entry_pos, value) + return best + + +def analyze_source(src: str, path: str = ""): + """Return a list of (line, fn, message) findings for one Rust source.""" + src = strip_comments(src) + findings = [] + for fn_name, body_start, body in function_bodies(src): + bindings = {} + for m in PROMISE_BINDING.finditer(body): + bindings.setdefault(m.group("name"), []).append((m.start(), m.group("ctor"))) + if not any(ctor in ARENA_CTORS for entries in bindings.values() for _, ctor in entries): + continue + aliases = {} + for m in ALIAS.finditer(body): + aliases.setdefault(m.group("alias"), []).append((m.start(), m.group("src"))) + names = set(bindings) | set(aliases) + ident = re.compile(r"\b(" + "|".join(map(re.escape, sorted(names))) + r")\b") + for call in SINK_CALL.finditer(body): + args_end = match_brace(body, call.end() - 1) + args = body[call.end() - 1 : args_end] + for hit in ident.finditer(args): + name, pos = hit.group(1), call.start() + alias = latest_before(aliases, name, pos) + if alias is not None: + pos, name = alias + binding = latest_before(bindings, name, pos) + if binding is None or binding[1] not in ARENA_CTORS: + continue + line = src.count("\n", 0, body_start + call.start()) + 1 + findings.append( + ( + line, + fn_name, + f"arena promise `{hit.group(1)}` (from {binding[1]}) reaches native sink " + f"`{call.group('sink')}`; mint it with js_promise_new_cross_thread() instead", + ) + ) + break + return findings + + +def scan_tree(): + findings = [] + for rel in SCAN_DIRS: + for path in sorted((ROOT / rel).rglob("*.rs")): + for line, fn_name, msg in analyze_source(path.read_text(), str(path)): + findings.append(f"{path.relative_to(ROOT)}:{line}: in `{fn_name}`: {msg}") + return findings + + +BAD_DIRECT = """ +pub unsafe extern "C" fn bad_direct() -> *mut Promise { + let promise = perry_runtime::js_promise_new(); + queue_promise_resolution(promise as usize, true, 0); + promise +} +""" +BAD_ALIAS_SPAWN = """ +pub unsafe extern "C" fn bad_alias() -> *mut Promise { + let promise = js_promise_new(); + let promise_ptr = promise as usize; + spawn(async move { + queue_deferred_resolution(promise_ptr, true, || 0); + }); + promise +} +""" +BAD_WITH_PARENT = """ +fn bad_parent(parent: *mut Promise) { + let p = crate::promise::js_promise_new_with_parent(parent); + let raw = p as usize; + std::thread::spawn(move || queue_promise_string_result(0, raw, String::new())); +} +""" +GOOD_CROSS_THREAD = """ +pub unsafe extern "C" fn good() -> *mut Promise { + let promise = perry_runtime::js_promise_new_cross_thread(); + let promise_ptr = promise as usize; + spawn(async move { queue_promise_resolution(promise_ptr, true, 0); }); + promise +} +""" +GOOD_SHADOWED = """ +unsafe fn good_shadowed(closure: *const u8) -> *mut Promise { + if closure.is_null() { + let promise = crate::promise::js_promise_new(); + crate::promise::js_promise_resolve(promise, 0.0); + return promise; + } + let promise = crate::promise::js_promise_new_cross_thread(); + let promise_usize = promise as usize; + std::thread::spawn(move || queue_thread_result(0, promise_usize, 0)); + promise +} +""" +GOOD_UNRELATED = """ +fn good_unrelated(writable_id: usize) -> *mut Promise { + let promise = js_promise_new(); + // a different usize reaches the sink; the promise stays on the main thread + let job = writable_id as usize; + spawn(async move { finish(job) }); + promise +} +""" + + +def self_test() -> int: + failures = [] + for label, snippet, expect in [ + ("direct", BAD_DIRECT, True), + ("alias+spawn", BAD_ALIAS_SPAWN, True), + ("with_parent+thread::spawn", BAD_WITH_PARENT, True), + ("cross_thread ctor", GOOD_CROSS_THREAD, False), + ("unrelated usize", GOOD_UNRELATED, False), + ("shadowed early-return arena promise", GOOD_SHADOWED, False), + ]: + got = bool(analyze_source(snippet)) + if got != expect: + failures.append(f"{label}: expected {'a hit' if expect else 'no hit'}, got {analyze_source(snippet)}") + if failures: + print("check_cross_thread_promise_provenance --self-test FAILED:") + for f in failures: + print(" " + f) + return 1 + print("check_cross_thread_promise_provenance --self-test ok (3 planted shapes caught, 3 clean shapes pass)") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + if args.self_test: + return self_test() + findings = scan_tree() + if findings: + print("#9552 cross-thread promise provenance: arena promises handed to native settlement paths:") + for f in findings: + print(" " + f) + print(f"{len(findings)} finding(s). Mint with js_promise_new_cross_thread() (pinned until settled).") + return 1 + print("check_cross_thread_promise_provenance: no arena promise reaches a native settlement sink") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts b/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts new file mode 100644 index 0000000000..df6a4b10c5 --- /dev/null +++ b/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts @@ -0,0 +1,44 @@ +// #9552: a promise minted for a cross-thread settlement — every stdlib +// `fetch` response, among ~110 stdlib call sites — is referenced only by the +// worker's raw address while the request is in flight: the awaiting +// continuation hangs OFF the promise (`P.on_fulfilled`), nothing on the JS +// side points AT it. A full collection landing in that window freed it, and +// the completion then resolved whatever the allocator had reused the slot for +// (the report: a RegExp header read as a promise's `next`, SIGSEGV in the +// microtask pump). +// +// The constructor now pins the promise until it settles. This runs a request +// against a local server that answers late, forces collections while it is in +// flight, and expects the response to arrive. Node only exposes `gc` under +// --expose-gc, so the collection is conditional and the expected output is +// identical on both. +import http from "node:http"; + +declare const gc: undefined | (() => void); + +const server = http.createServer((_req, res) => { + setTimeout(() => { + res.end("ok"); + }, 60); +}); + +async function get(url: string): Promise { + const response = await fetch(url); + return await response.text(); +} + +server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const inflight = get(`http://127.0.0.1:${port}/`); + + let junk: Array<{ k: number; s: string }> = []; + for (let round = 0; round < 8; round++) { + junk = Array.from({ length: 4000 }, (_, k) => ({ k, s: "x".repeat(40) })); + if (typeof gc === "function") gc(); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + console.log(await inflight, junk.length); + server.close(); +}); From 749dab95640ff197e0b0459ccf7437e571e89d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 21:31:48 +0200 Subject: [PATCH 2/4] changelog: fragment for #9565 (#9552 cross-thread promise pin) Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up --- changelog.d/9565-cross-thread-promise-pin.md | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 changelog.d/9565-cross-thread-promise-pin.md diff --git a/changelog.d/9565-cross-thread-promise-pin.md b/changelog.d/9565-cross-thread-promise-pin.md new file mode 100644 index 0000000000..73aca54806 --- /dev/null +++ b/changelog.d/9565-cross-thread-promise-pin.md @@ -0,0 +1,49 @@ +### Fixed + +- **A promise handed to native code is now rooted until it settles; `fetch` + (and ~110 other stdlib hand-offs) could be freed mid-flight (#9552).** + `claude -p <120,000-char argument>` compiled with perry died with + SIGSEGV in the microtask pump (`pump_protected`, `si_addr=0`) where node + exits 1; nondeterministic, ~50% of runs. + + A promise minted by `js_promise_new_cross_thread` leaves the runtime as a + bare `usize` inside a worker future, which no root scanner visits, and + nothing on the JS side points **at** a pending promise whose only consumer + is an `await` — `P.on_fulfilled` and `P.next` are edges out of it. The + constructor's contract made the pin the caller's job; `spawn` and + `Atomics.waitAsync` took it, the stdlib's `fetch`/db/ws sites never did. + An old-generation reclaim at an allocation point ran its malloc sweep while + `js_fetch_with_options`'s promise was in flight and freed it (never + pinned, no token, still pending); mimalloc gave the 80-byte slot to a + `RegExp`; the stdlib pump resolved the stale address and the pump then + read `REGEXP_MAGIC` as the promise's `next`. The from-space quarantine + reports it as an unrelated fault because the object was never in the + arena. Diagnosed with a symbolized build and an env-gated trace of every + promise allocation, malloc-sweep free, pin and token event. + + The constructor now owns the invariant: `js_promise_new_cross_thread` + pins the (malloc-resident, non-moving) promise itself — one flag bit, + through `pin_object_non_young` so the copying minor's young-pin latch is + never armed — and `js_promise_resolve` / `js_promise_reject` release it + with one byte test on a field in the padding after `state` (no other + field moves; an arena promise pays a predictable-branch load and nothing + else). `remove_token_from_registry` releases it too, so a native-async + token dropped without settling cannot leak its promise. The caller-side + pins in `spawn` and `waitAsync` are gone. + + Every place a raw promise address re-enters the runtime from native code + — the stdlib pump, the native-async token pump, the `perry/thread` + result drain — now classifies it (`native_promise_from_raw`) and aborts + naming the site and the slot's current occupant, once per I/O completion, + never per `await`. `scripts/check_cross_thread_promise_provenance.py` is + a new `lint` step: it fails on an **arena** promise reaching a native + settlement sink or a spawned closure (shadowing- and alias-aware, + self-tested with three planted and three clean shapes). Unit coverage in + `promise/cross_thread_pin_tests.rs` (pinned at creation, released by both + settlements, survives `js_gc_collect()` while only an XOR-hidden integer + holds it, token-drop release, address classification); gap test + `test_gap_9552_cross_thread_promise_survives_gc.ts` fetches from a local + server that answers late while collections are forced in between. + + Validation on the report's binary (`cli_2.1.112.js`, `--enable-wasm-runtime`, + same compiler, only the runtime archives swapped): 4/5 crashes → 0/12. From e04face4a7143589f0ce6020a358ea6d48901327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 21:42:50 +0200 Subject: [PATCH 3/4] test: gc test-support Promise literals carry the #9552 pin byte Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up --- crates/perry-runtime/src/gc/tests/alloc.rs | 1 + crates/perry-runtime/src/gc/tests/support.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/perry-runtime/src/gc/tests/alloc.rs b/crates/perry-runtime/src/gc/tests/alloc.rs index ff33d88154..d18faf61d4 100644 --- a/crates/perry-runtime/src/gc/tests/alloc.rs +++ b/crates/perry-runtime/src/gc/tests/alloc.rs @@ -640,6 +640,7 @@ fn alloc_malloc_kind_test_object(obj_type: u8) -> *mut u8 { ptr as *mut crate::promise::Promise, crate::promise::Promise { state: crate::promise::PromiseState::Pending, + native_pinned: 0, value: 0.0, reason: 0.0, on_fulfilled: std::ptr::null(), diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index 6b35535846..35e1e7be01 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -64,6 +64,7 @@ pub(super) unsafe fn alloc_old_test_promise() -> *mut crate::promise::Promise { ptr, crate::promise::Promise { state: crate::promise::PromiseState::Pending, + native_pinned: 0, value: 0.0, reason: 0.0, on_fulfilled: std::ptr::null(), @@ -785,6 +786,7 @@ pub(super) fn allocate_dead_malloc_churn_headers(per_type: usize) -> Vec ptr, crate::promise::Promise { state: crate::promise::PromiseState::Pending, + native_pinned: 0, value: 0.0, reason: 0.0, on_fulfilled: std::ptr::null(), From b151265ff3e2db208607f8689407f8198ded418b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 21:53:45 +0200 Subject: [PATCH 4/4] =?UTF-8?q?test(gap):=20#9552=20probe=20that=20fails?= =?UTF-8?q?=20unfixed=20=E2=80=94=20three=20consumer=20shapes,=20malloc-co?= =?UTF-8?q?unt=20churn,=20slot=20reuse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up --- ...p_9552_cross_thread_promise_survives_gc.ts | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts b/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts index df6a4b10c5..e3a7357dd6 100644 --- a/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts +++ b/test-files/test_gap_9552_cross_thread_promise_survives_gc.ts @@ -1,17 +1,20 @@ // #9552: a promise minted for a cross-thread settlement — every stdlib // `fetch` response, among ~110 stdlib call sites — is referenced only by the -// worker's raw address while the request is in flight: the awaiting -// continuation hangs OFF the promise (`P.on_fulfilled`), nothing on the JS -// side points AT it. A full collection landing in that window freed it, and -// the completion then resolved whatever the allocator had reused the slot for -// (the report: a RegExp header read as a promise's `next`, SIGSEGV in the -// microtask pump). +// worker's raw address while the request is in flight: the consumer's +// reaction hangs OFF the promise (`P.on_fulfilled`), nothing on the JS side +// points AT it. A malloc sweep landing in that window freed it; the slot was +// reused (in the report, by a RegExp header); and the completion then either +// saw a "settled" state byte and dropped the response — the request never +// resolves — or the microtask pump read the occupant as a promise (SIGSEGV). // -// The constructor now pins the promise until it settles. This runs a request -// against a local server that answers late, forces collections while it is in -// flight, and expects the response to arrive. Node only exposes `gc` under -// --expose-gc, so the collection is conditional and the expected output is -// identical on both. +// The constructor now pins the promise until it settles. Three consumer +// shapes each start a request against a local server that answers late, +// from a frame that has returned before any collection runs (so no native +// stack slot still names the promise); the churn then trips the +// malloc-count sweep with Symbols and reuses freed 80-byte slots with RegExp +// headers (the promise's size class). Unfixed, this hangs: at least one of +// the three never resolves. Node only exposes `gc` under --expose-gc, so +// that call is conditional and the expected output is identical on both. import http from "node:http"; declare const gc: undefined | (() => void); @@ -19,26 +22,53 @@ declare const gc: undefined | (() => void); const server = http.createServer((_req, res) => { setTimeout(() => { res.end("ok"); - }, 60); + }, 1500); }); -async function get(url: string): Promise { - const response = await fetch(url); - return await response.text(); +class Client { + async request(url: string): Promise { + const response = await fetch(url); + return await response.text(); + } } -server.listen(0, "127.0.0.1", async () => { +server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; - const inflight = get(`http://127.0.0.1:${port}/`); + const url = `http://127.0.0.1:${port}/`; + const results: Array> = []; - let junk: Array<{ k: number; s: string }> = []; - for (let round = 0; round < 8; round++) { - junk = Array.from({ length: 4000 }, (_, k) => ({ k, s: "x".repeat(40) })); - if (typeof gc === "function") gc(); - await new Promise((resolve) => setTimeout(resolve, 5)); - } + setTimeout(() => { + // (a) no await at all: the reaction hangs off the fetch promise, nothing points at it + results.push(fetch(url).then((r) => r.text())); + // (b) async arrow + const viaArrow = async () => { + const r = await fetch(url); + return await r.text(); + }; + results.push(viaArrow()); + // (c) async class method + results.push(new Client().request(url)); + }, 0); - console.log(await inflight, junk.length); - server.close(); + let round = 0; + const churn = () => { + for (let i = 0; i < 40000; i++) { + Symbol(`s${i & 255}`); + } + if (typeof gc === "function") gc(); + for (let i = 0; i < 4000; i++) { + new RegExp(`r${i & 255}`); + } + round += 1; + if (round < 6) { + setTimeout(churn, 5); + return; + } + Promise.all(results).then((bodies) => { + console.log(bodies.join(","), round); + server.close(); + }); + }; + setTimeout(churn, 20); });