diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d97210a8b42..5b818bca011 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -509,6 +509,19 @@ jobs: python3 scripts/gc_runtime_root_holders.py --self-test python3 scripts/gc_runtime_root_holders.py + # #11471. The other half of the census above: a process-global cache + # KEYED by (or holding) a heap address is not a GC root, so that audit + # cannot see it, yet it goes stale the moment its thread exits and + # `Arena::drop` hands the blocks to another thread's arena. #11319, + # #11462 and #11470 were all this shape, each found by chasing a flake. + # Every such static must carry a thread-exit verdict; a new unclassified + # one fails, and so does a stale entry. Build-free, so it lives in `lint`. + - name: Thread-exit custody of address-keyed globals (#11471) + if: ${{ !cancelled() }} + run: | + python3 scripts/thread_exit_address_globals.py --self-test + python3 scripts/thread_exit_address_globals.py + # #8821. The poll-reach audit is static, build-free and sub-second, but it # lived ONLY in `gc-root-dominance.yml`, which is label-gated behind # `run-extended-tests` — so it is skipped on every PR and speaks only on diff --git a/changelog.d/11485-thread-exit-address-globals.md b/changelog.d/11485-thread-exit-address-globals.md new file mode 100644 index 00000000000..a44ca376659 --- /dev/null +++ b/changelog.d/11485-thread-exit-address-globals.md @@ -0,0 +1,5 @@ +- **runtime/stdlib: process-global tables forget an exiting thread's addresses (#11471).** A process-global table keyed by, or holding, a heap address outlived the thread whose arena owned it. After `Arena::drop` freed the blocks, another thread reused the addresses and the stale entry described a different object. #11319, #11462 and #11463/#11467 were three instances, each found by chasing a flake. An audit of every mutable process-global `static` in perry-runtime and perry-stdlib that can hold a 64-bit word (805 candidates, 234 of them counter-only atomics) found 83 more tables of the same shape. Examples: `SYMBOL_PROPERTIES`/`SYMBOL_POINTERS` (a new object at a reused address inherited a dead worker's symbol properties, or read as a Symbol), `OBJECT_PROTOTYPES`, `DOM_EXCEPTION_ERRORS` (a reused address passed `instanceof DOMException`), `CRYPTO_KEY_REGISTRY` and the external buffer registries, the Web Streams, zlib and worker registries, the stdin/readline/TLS/async_hooks/v8 listener tables, `GLOBAL_THIS_PTR`, and the child_process/pty/dgram/native-async reactors, which also kept the event loop alive for a dead thread. + + New mechanism: `arena::thread_exit`. `Arena::drop` and, new, `MallocState::drop` (for `gc_malloc` blocks such as `Symbol()` headers) report the ranges they are about to free to `release_freed_ranges`. That calls one hook per perry-runtime table and every hook registered with `register_thread_exit_range_hook`, which is how perry-stdlib tables participate. `FreedRanges::holds_bits` decodes both NaN-boxed and raw addresses. `HANDLES` payload types register per-type releasers through `register_handle_payload_releaser`. A few statics that held a handle into a per-thread table were made per-thread instead, or are released at agent retirement: the diagnostics_channel console channel ids, `Buffer.poolSize`, and the turnloop signal subscription ids. + + Gate: `scripts/thread_exit_address_globals.py` (in `lint`) enumerates the candidates and requires a thread-exit verdict for each one in `scripts/thread_exit_address_globals.json`. It fails on an unclassified static, a stale entry, or a `thread_exit_invalidated` verdict whose hook no longer runs at thread exit. `--self-test` plants each shape. Regression tests are in `crates/perry-stdlib/src/runtime_thread_exit_tests/`, one per fixed table where feasible. Each one populates the table from a spawned thread, asserts the entry exists while the thread lives, joins, and asserts it is gone. Each test was also run with its hook disabled and failed. diff --git a/crates/perry-runtime/src/arena/block.rs b/crates/perry-runtime/src/arena/block.rs index 93fb65eca81..fdf0396df4a 100644 --- a/crates/perry-runtime/src/arena/block.rs +++ b/crates/perry-runtime/src/arena/block.rs @@ -537,6 +537,9 @@ impl Drop for Arena { .map(|block| (block.data as usize, block.data as usize + block.size)) .collect(); crate::closure::release_closure_side_table_owners_in_ranges(&ranges); + // #11471: every other process-global table keyed by, or holding, + // an address in these blocks (see `arena::thread_exit`). + super::thread_exit::release_freed_ranges(&ranges); } for block in &self.blocks { // Skip tombstoned slots (gen-GC Phase C4b-δ): C4b-δ diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index bbaa5f298f6..0c2e098b363 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -25,6 +25,8 @@ mod promote; mod quarantine; mod reset; mod stats; +/// #11471: thread-exit release of process-global address-keyed tables. +pub mod thread_exit; mod walk; pub(crate) use map_allocations::walk_map_allocations; diff --git a/crates/perry-runtime/src/arena/thread_exit.rs b/crates/perry-runtime/src/arena/thread_exit.rs new file mode 100644 index 00000000000..71cebd8ae05 --- /dev/null +++ b/crates/perry-runtime/src/arena/thread_exit.rs @@ -0,0 +1,181 @@ +//! Thread-exit release of process-global address-keyed state (#11471). +//! +//! When a thread exits, [`Arena::drop`](super::Arena) hands its arena blocks +//! back to the allocator, and another thread's arena may reuse the same +//! addresses. A process-global table keyed by (or holding) an address inside +//! those blocks then describes a DIFFERENT object: #11319 (closure side +//! tables), #11462 (`tls.rs` roots) and #11470 (typed-array kind caches) each +//! shipped as a flake before anyone traced it here. +//! +//! This module is the one place those tables hear about a thread exit. +//! `Arena::drop` calls [`release_freed_ranges`] with the blocks it is about to +//! free; that runs every hook registered through +//! [`register_thread_exit_range_hook`]. A hook drops every entry whose key or +//! stored value lies in [`FreedRanges`]. +//! +//! # Rules for a hook +//! +//! It runs inside a TLS destructor of the exiting thread: +//! +//! * no thread-local state (it may already be gone), no GC allocation, no +//! collection; +//! * process-global locks are fine (the tables it touches are exactly those), +//! but take them with `lock()`/`try_lock()` and tolerate poisoning; +//! * it must not call back into JS. +//! +//! `scripts/thread_exit_address_globals.py` requires every process-global +//! static that can hold an arena address to carry a verdict; a +//! `thread_exit_invalidated` verdict names its hook, and the gate checks the +//! hook is registered here or called by `Arena::drop`. + +use std::sync::{Mutex, PoisonError}; + +/// The address ranges `[start, end)` of the arena blocks an exiting thread is +/// about to free, sorted by start. Arena blocks never overlap, so membership +/// is one binary search. +pub struct FreedRanges { + sorted: Vec<(usize, usize)>, +} + +impl FreedRanges { + pub fn new(ranges: &[(usize, usize)]) -> Self { + let mut sorted: Vec<(usize, usize)> = ranges + .iter() + .copied() + .filter(|&(start, end)| start < end) + .collect(); + sorted.sort_unstable_by_key(|&(start, _)| start); + Self { sorted } + } + + pub fn is_empty(&self) -> bool { + self.sorted.is_empty() + } + + /// Does `addr` lie inside one of the freed blocks? + #[inline] + pub fn contains(&self, addr: usize) -> bool { + if addr == 0 { + return false; + } + let after = self.sorted.partition_point(|&(start, _)| start <= addr); + after > 0 && addr < self.sorted[after - 1].1 + } + + /// Does this 64-bit word name freed memory, read either as a NaN-boxed + /// heap reference (pointer, string or bigint tag) or as a raw address + /// (closure pointers and `*mut Promise` are stored raw in several + /// tables)? A plain number never decodes to a block address unless it is + /// one, so erring toward "names it" can only drop an entry that pointed at + /// the dead thread's heap anyway. + #[inline] + pub fn holds_bits(&self, bits: u64) -> bool { + let tag = bits >> 48; + if matches!(tag, 0x7FFD | 0x7FFF | 0x7FFA) { + return self.contains((bits & 0x0000_FFFF_FFFF_FFFF) as usize); + } + tag == 0 && self.contains(bits as usize) + } + + /// [`holds_bits`](Self::holds_bits) for an `f64`-typed JS value. + #[inline] + pub fn holds_value(&self, value: f64) -> bool { + self.holds_bits(value.to_bits()) + } + + /// [`holds_bits`](Self::holds_bits) for an `i64`-typed word. + #[inline] + pub fn holds_i64(&self, word: i64) -> bool { + self.holds_bits(word as u64) + } +} + +/// Hooks for process-global tables, in this crate or downstream +/// (`perry-stdlib`). Function pointers only: no heap address lives here. +static RANGE_HOOKS: Mutex> = Mutex::new(Vec::new()); + +/// Register `hook` to run whenever a thread's arena frees its blocks. +/// Idempotent per function. Call it before the table's first insert (a +/// `Once` on the insert path is the usual shape), so no entry predates it. +pub fn register_thread_exit_range_hook(hook: fn(&FreedRanges)) { + let mut hooks = RANGE_HOOKS.lock().unwrap_or_else(PoisonError::into_inner); + if !hooks + .iter() + .any(|existing| std::ptr::fn_addr_eq(*existing, hook)) + { + hooks.push(hook); + } +} + +/// Run every registered hook over `ranges`. Called by `Arena::drop` (arena +/// blocks) and `MallocState::drop` (`gc_malloc` blocks) at thread exit. +pub(crate) fn release_freed_ranges(ranges: &[(usize, usize)]) { + let freed = FreedRanges::new(ranges); + if freed.is_empty() { + return; + } + // -- perry-runtime tables (#11471): one call per owning module -- + crate::geisterhand_registry::release_geisterhand_in_freed_ranges(&freed); + crate::ui_text_registry::release_ui_text_registry_in_freed_ranges(&freed); + crate::frame::release_frame_callbacks_in_freed_ranges(&freed); + crate::tui::input::release_tui_input_handler_in_freed_ranges(&freed); + crate::tui::state::release_tui_state_slots_in_freed_ranges(&freed); + crate::tui::hooks::release_tui_hook_slots_in_freed_ranges(&freed); + #[cfg(feature = "full")] + crate::plugin::release_plugin_registry_in_freed_ranges(&freed); + #[cfg(feature = "ohos-napi")] + crate::media_playback::release_media_callbacks_in_freed_ranges(&freed); + crate::symbol::release_symbol_tables_in_freed_ranges(&freed); + crate::symbol::release_symbol_accessors_in_freed_ranges(&freed); + crate::object::prototype_chain::release_object_prototypes_in_freed_ranges(&freed); + crate::event_target::release_dom_exception_errors_in_freed_ranges(&freed); + crate::node_vm::release_vm_owner_entries_in_freed_ranges(&freed); + crate::messaging::release_port_states_in_freed_ranges(&freed); + crate::v8::release_promise_hooks_in_freed_ranges(&freed); + crate::async_hooks::release_async_hooks_in_freed_ranges(&freed); + crate::object::release_global_this_ptr_in_freed_ranges(&freed); + crate::tls::release_tls_client_metadata_in_freed_ranges(&freed); + #[cfg(feature = "mod-dgram")] + crate::dgram_reactor::release_dgram_sockets_in_freed_ranges(&freed); + #[cfg(feature = "mod-dgram")] + crate::dgram::release_dgram_bindings_in_freed_ranges(&freed); + crate::child_process::reactor::release_cp_children_in_freed_ranges(&freed); + #[cfg(any(unix, windows))] + crate::pty::reactor::release_ptys_in_freed_ranges(&freed); + crate::promise::native_async::release_native_async_tokens_in_freed_ranges(&freed); + crate::typed_feedback::release_typed_feedback_in_freed_ranges(&freed); + // -- end perry-runtime tables -- + // Copied out so a hook may itself take locks without holding this one. + let hooks: Vec = RANGE_HOOKS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + for hook in hooks { + hook(&freed); + } +} + +#[cfg(test)] +mod tests { + use super::FreedRanges; + + #[test] + fn membership_and_value_decoding() { + let freed = FreedRanges::new(&[(0x2000, 0x3000), (0x1000, 0x1800)]); + assert!(freed.contains(0x1000)); + assert!(freed.contains(0x17ff)); + assert!(!freed.contains(0x1800)); + assert!(freed.contains(0x2fff)); + assert!(!freed.contains(0x3000)); + assert!(!freed.contains(0)); + assert!(freed.holds_bits(0x7FFD_0000_0000_2010)); + assert!(freed.holds_bits(0x7FFF_0000_0000_1010)); + assert!(freed.holds_bits(0x2010)); + assert!(!freed.holds_bits(0x7FFD_0000_0000_4000)); + assert!(!freed.holds_value(1.5)); + assert!( + !freed.holds_bits(0x7FFE_0000_0000_2010), + "an int32 is not an address" + ); + } +} diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index eade902e591..c32b5860ead 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -1969,6 +1969,15 @@ pub fn scan_async_hooks_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_ } } +// #11471: thread-exit release of this module's process-global tables. +mod thread_exit; +pub(crate) use thread_exit::release_async_hooks_in_freed_ranges; +#[doc(hidden)] +pub use thread_exit::{ + cached_singletons_for_test, clear_cached_singletons_for_test, context_snapshot_holds_for_test, + hook_callback_registered_for_test, resource_tracked_for_test, +}; + #[cfg(test)] mod test_support; #[cfg(test)] diff --git a/crates/perry-runtime/src/async_hooks/thread_exit.rs b/crates/perry-runtime/src/async_hooks/thread_exit.rs new file mode 100644 index 00000000000..bae6d01688e --- /dev/null +++ b/crates/perry-runtime/src/async_hooks/thread_exit.rs @@ -0,0 +1,131 @@ +//! #11471: thread-exit release of `async_hooks`' process-global tables. + +use std::sync::atomic::Ordering; + +use super::{ + HookCallbacks, ASYNC_WRAP_PROVIDERS, CONTEXT_SNAPSHOTS, HOOKS, HOOKS_ACTIVE, + PROMISE_HOOKS_ACTIVE, RESOURCES, TOP_LEVEL_RESOURCE, +}; + +/// #11471: release every async_hooks entry naming an exiting thread's arena. +/// +/// All six tables are process-global (`per_test_global!` is a plain static +/// outside perry-runtime's own unit tests) while their writers run on any +/// thread, and none is otherwise told about a thread exit: +/// +/// * `HOOKS` — a `createHook` record whose callback is freed is disabled +/// exactly as `set_hook_enabled(_, false)` does (keeping `HOOKS_ACTIVE` / +/// `PROMISE_HOOKS_ACTIVE` consistent) and its callbacks nulled. The record +/// stays: `AsyncHookHandle` names it by index. +/// * `RESOURCES` — an entry whose resource value or captured context store is +/// freed is dropped without a destroy event (the thread that could observe +/// it is gone). +/// * `CONTEXT_SNAPSHOTS` — a snapshot holding a freed store is dropped; only +/// the dead thread's `AsyncLocalStorage.snapshot()` closures named it. +/// * `TOP_LEVEL_RESOURCE` / `ASYNC_WRAP_PROVIDERS` — lazily allocated by the +/// first thread to ask; cleared if that thread's arena is going away, so the +/// next caller allocates a fresh one on its own heap. +pub(crate) fn release_async_hooks_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + let snapshot_is_dead = |snapshot: &crate::async_context::AsyncContextSnapshot| { + let mut dead = false; + crate::async_context::scan_snapshot_roots(snapshot, &mut |store| { + dead |= freed.holds_value(store); + }); + dead + }; + { + let mut hooks = HOOKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for record in hooks.iter_mut() { + let c = record.callbacks; + let dead = [c.init, c.before, c.after, c.destroy, c.promise_resolve] + .iter() + .any(|callback| freed.contains(*callback as usize)); + if !dead { + continue; + } + if record.enabled && c.has_any() { + HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + if record.track_promises { + PROMISE_HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + } + } + record.enabled = false; + record.callbacks = HookCallbacks::empty(); + } + } + RESOURCES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, meta| !freed.holds_value(meta.resource) && !snapshot_is_dead(&meta.context)); + CONTEXT_SNAPSHOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, snapshot| !snapshot_is_dead(snapshot)); + for slot in [&ASYNC_WRAP_PROVIDERS, &TOP_LEVEL_RESOURCE] { + let bits = slot.load(Ordering::Acquire); + if bits != 0 && freed.holds_bits(bits) { + let _ = slot.compare_exchange(bits, 0, Ordering::AcqRel, Ordering::Acquire); + } + } +} + +/// #11471 test probe: the cached `executionAsyncResource()` bootstrap object +/// and `asyncWrapProviders` bits (0 when unset). +#[doc(hidden)] +pub fn cached_singletons_for_test() -> (u64, u64) { + ( + TOP_LEVEL_RESOURCE.load(Ordering::Acquire), + ASYNC_WRAP_PROVIDERS.load(Ordering::Acquire), + ) +} + +/// #11471 test probe: forget both lazily cached singletons, so the calling +/// thread allocates its own on next use. +#[doc(hidden)] +pub fn clear_cached_singletons_for_test() { + TOP_LEVEL_RESOURCE.store(0, Ordering::Release); + ASYNC_WRAP_PROVIDERS.store(0, Ordering::Release); +} + +/// #11471 test probe: does any `createHook` record hold `callback`? +#[doc(hidden)] +pub fn hook_callback_registered_for_test(callback: usize) -> bool { + HOOKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|record| { + let c = record.callbacks; + [c.init, c.before, c.after, c.destroy, c.promise_resolve] + .iter() + .any(|p| *p as usize == callback) + }) +} + +/// #11471 test probe: is `async_id` tracked in `RESOURCES`? +#[doc(hidden)] +pub fn resource_tracked_for_test(async_id: u64) -> bool { + RESOURCES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&async_id) +} + +/// #11471 test probe: does any registered context snapshot hold a store with +/// these bits? +#[doc(hidden)] +pub fn context_snapshot_holds_for_test(store_bits: u64) -> bool { + CONTEXT_SNAPSHOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .any(|snapshot| { + let mut found = false; + crate::async_context::scan_snapshot_roots(snapshot, &mut |store| { + found |= store.to_bits() == store_bits; + }); + found + }) +} diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index 1fcf961a051..2a119341835 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -662,8 +662,61 @@ pub fn mark_as_uint8array(addr: usize) { }); } +/// #11471: thread-exit release for the process-global external-buffer +/// registries and the `PERRY_U8_INLINE_CACHE` admission cache. +/// +/// Their only removal sites are the dead-buffer finalizer and +/// `register_buffer` on a re-issued address; `Arena::drop` runs neither, so a +/// webcrypto buffer registered on an exiting thread would keep answering +/// `is_registered_buffer` / `is_uint8array_buffer` / `crypto_key_meta` for +/// whatever another thread later allocates at that address, and a stale cache +/// slot would let codegen's inline guard read the new tenant's words as +/// (length, bytes). Runs from a TLS destructor: process-global locks taken +/// one at a time, atomics, no thread-locals, no GC. The thread-local +/// registries die with the thread. perry-stdlib's `CRYPTO_KEY_REGISTRY` +/// carries its own hook, so `notify_crypto_key_death` is not called here. +fn release_external_buffer_registries_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + use std::sync::atomic::Ordering; + use std::sync::PoisonError; + for slot in PERRY_U8_INLINE_CACHE.iter() { + let old = slot.load(Ordering::Relaxed); + if old != 0 && freed.contains(old as usize) { + let _ = slot.compare_exchange(old, 0, Ordering::Relaxed, Ordering::Relaxed); + } + } + if let Some(set) = EXTERNAL_BUFFER_REGISTRY.get() { + set.lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|&addr| !freed.contains(addr)); + } + if let Some(set) = EXTERNAL_UINT8ARRAY_REGISTRY.get() { + set.lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|&addr| !freed.contains(addr)); + } + if let Some(map) = EXTERNAL_CRYPTO_KEY_META_REGISTRY.get() { + map.lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|&addr, _| !freed.contains(addr)); + } +} + +/// Register [`release_external_buffer_registries_in_freed_ranges`] before the +/// first insert into any table it clears. +fn register_thread_exit_hook() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_external_buffer_registries_in_freed_ranges, + ) + }); +} + #[no_mangle] pub extern "C" fn js_buffer_register_external(addr: usize) { + register_thread_exit_hook(); register_buffer(addr as *const BufferHeader); // Latch BEFORE the insert: a concurrent `is_registered_buffer` that // observed the latch after the insert-but-before-the-store window would @@ -699,6 +752,7 @@ pub extern "C" fn js_buffer_mark_as_uint8array_external(addr: usize) { /// precisely so an address registered on one thread is visible from another, /// and the thread-local set that would otherwise cover it is not. fn register_external_uint8array(addr: usize) { + register_thread_exit_hook(); // Both of this function's callers also call `mark_as_uint8array(addr)`, // which admits the same address — but that is an enumeration of callers, // and this is the funnel the doc comment above promises is authoritative. @@ -765,6 +819,7 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external( usages: u32, bit_length: u32, ) { + register_thread_exit_hook(); register_buffer(addr as *const BufferHeader); mark_as_uint8array(addr); mark_as_crypto_key_with_flags(addr, algo, hash, kind, extractable != 0, usages, bit_length); @@ -901,6 +956,7 @@ pub(crate) fn u8_inline_cache_try_prime(addr: usize) { && foreign_backing(addr).is_none() && super::view::lookup(addr).is_none() { + register_thread_exit_hook(); PERRY_U8_INLINE_CACHE[u8_inline_cache_slot(addr)] .store(addr as u64, std::sync::atomic::Ordering::Relaxed); } diff --git a/crates/perry-runtime/src/buffer/own_props.rs b/crates/perry-runtime/src/buffer/own_props.rs index 64d2f072184..30c335549b5 100644 --- a/crates/perry-runtime/src/buffer/own_props.rs +++ b/crates/perry-runtime/src/buffer/own_props.rs @@ -97,6 +97,7 @@ pub fn buffer_define_own_data_prop(addr: usize, prop: &str, value: f64) { if addr == 0 { return; } + register_thread_exit_hook(); BUFFER_OWN_PROPS_EVER.store(true, Ordering::Release); if let Ok(mut props) = buffer_props().lock() { let own = props.entry(addr).or_default(); @@ -266,6 +267,35 @@ pub fn clear_buffer_own_props(addr: usize) { } } +fn register_thread_exit_hook() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_buffer_own_props_in_freed_ranges, + ) + }); +} + +/// #11471: thread-exit release. Neither clear site above runs when a thread +/// exits (`Arena::drop` frees its blocks without finalizing the buffers), so +/// drop every record whose buffer address, or any stored value, lies in the +/// exiting thread's blocks; otherwise the scanner keeps tracing the dead +/// values and a later tenant of the address inherits the expandos. Runs from +/// a TLS destructor: one process-global lock, no thread-locals, no GC. +/// Registered (this module is private to `buffer`) by +/// [`register_thread_exit_hook`] before the table's first insert. +fn release_buffer_own_props_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + if !BUFFER_OWN_PROPS_EVER.load(Ordering::Acquire) { + return; + } + let mut props = buffer_props() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + props.retain(|&owner, own| { + !freed.contains(owner) && !own.values.values().any(|&bits| freed.holds_bits(bits)) + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-runtime/src/builtins/console.rs b/crates/perry-runtime/src/builtins/console.rs index 91be8c4568b..4aa71833969 100644 --- a/crates/perry-runtime/src/builtins/console.rs +++ b/crates/perry-runtime/src/builtins/console.rs @@ -150,6 +150,13 @@ pub extern "C" fn js_console_log_as_closure() -> f64 { let closure_ptr = if cached != 0 { cached as *mut crate::closure::ClosureHeader } else { + // #11471: the hook must exist before the slot can hold an address. + static REGISTER_THREAD_EXIT_HOOK: std::sync::Once = std::sync::Once::new(); + REGISTER_THREAD_EXIT_HOOK.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_console_log_singleton_in_freed_ranges, + ); + }); let fresh = crate::closure::js_closure_alloc(console_log_callable_thunk as *const u8, 0); // CAS so concurrent first-use callers don't leak a closure. // The loser's allocation is unreachable by any user code path @@ -169,6 +176,21 @@ pub extern "C" fn js_console_log_as_closure() -> f64 { f64::from_bits(JSValue::pointer(closure_ptr as *const u8).bits()) } +/// #11471: the singleton is allocated in the arena of whichever thread first +/// reads `console.log` as a value, but cached process-wide. When that thread +/// exits, clear the slot (only if it still holds that thread's closure), so +/// the next reader allocates a fresh one on its own heap instead of being +/// handed a freed (or reused) address. Registered from the allocation path +/// rather than called from `release_freed_ranges`, because this module is +/// private to `builtins`. +fn release_console_log_singleton_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + let cached = CONSOLE_LOG_SINGLETON.load(Ordering::Acquire); + if cached != 0 && freed.holds_i64(cached) { + let _ = + CONSOLE_LOG_SINGLETON.compare_exchange(cached, 0, Ordering::AcqRel, Ordering::Acquire); + } +} + /// `console[dynamicKey]` — resolve a console method by a RUNTIME key string, /// returning the same bound native closure a static `console.` value read /// produces. Codegen routes the computed-member form here (`console[m](...)`, diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 0301d5436f4..b26aeed3a41 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -1656,16 +1656,22 @@ fn cp_reactor_pump_inner() { // Release any loop entry still carrying one of this child's pipes // BEFORE the registry entry goes, because that is where the ids live. cp_release_loop_streams(item.handle); - if let Some(map) = cp_live_lock().as_mut() { - map.remove(&item.handle); - } + // #11471: the entry may already be gone — a thread exit released it + // (`release_cp_children_in_freed_ranges`), and that release owns the + // counter decrement. Decrement only for the removal made here. + let removed = cp_live_lock() + .as_mut() + .and_then(|map| map.remove(&item.handle)) + .is_some(); crate::async_hooks::destroy(item.process_ids.async_id); for pipe in item.pipe_ids { crate::async_hooks::destroy(pipe.async_id); } - CP_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); - if item.refed { - CP_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + if removed { + CP_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if item.refed { + CP_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } } } } @@ -1686,6 +1692,137 @@ struct CpCloseItem { close: bool, } +/// #11471: drop every live child registered by a thread whose arena is being +/// freed. +/// +/// A [`LiveChild`] holds the spawning thread's ChildProcess and stdio stream +/// objects, its AbortSignal/listener, an exec callback and stdin write +/// callbacks — all GC-rooted by `cp_reactor_scan_roots_mut`, and all emitted +/// on by the process-global pump. Left behind by an exited thread, every later +/// collection would mark/rewrite memory another arena may have reused, the +/// pump would emit `'exit'`/`'data'` onto it, and [`CP_REFED_COUNT`] would keep +/// the event loop alive forever. Values never cross threads, so an entry that +/// names the dead heap anywhere is wholly the dead thread's. +/// +/// Runs in the exiting thread's TLS destructor (see `arena::thread_exit`): no +/// thread-locals, no JS, no async_hooks `destroy`. The child process itself is +/// NOT killed (Node does not kill a worker's children either); its reader and +/// waiter threads keep running and their events for this handle are skipped by +/// the pump, which already tolerates a missing entry. Dropping the entry +/// closes the retained stdin writer and IPC clone. A turnloop entry carrying a +/// pipe (`loop_streams`) lives on the dead thread's loop and goes down with it +/// rather than through `cp_release_loop_streams`, which needs that thread's +/// loop state. The counters are decremented here, for exactly the entries +/// removed here (Phase B only decrements for a removal it makes itself). +pub(crate) fn release_cp_children_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + if CP_LIVE_COUNT.load(Ordering::Acquire) == 0 { + return; + } + let removed: Vec = { + let mut guard = cp_live_lock(); + let Some(map) = guard.as_mut() else { + return; + }; + let dead: Vec = map + .iter() + .filter(|(_, lc)| { + freed.holds_bits(lc.cp_bits) + || lc.pipe_bits.iter().any(|&bits| freed.holds_bits(bits)) + || freed.holds_bits(lc.abort_signal_bits) + || freed.holds_bits(lc.abort_listener_bits) + || lc + .exec + .as_ref() + .is_some_and(|exec| freed.holds_bits(exec.cb_bits)) + || lc.stdin.as_ref().is_some_and(|stdin| { + stdin.callbacks.iter().any(|&bits| freed.holds_bits(bits)) + }) + }) + .map(|(handle, _)| *handle) + .collect(); + dead.iter() + .filter_map(|handle| map.remove(handle)) + .collect() + }; + for lc in &removed { + CP_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if lc.refed { + CP_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + // Dropped after the lock is released: closing the stdin writer and IPC + // clone are syscalls, and nothing here needs the registry any more. + drop(removed); +} + +/// Test probe (#11471): register a live-child entry for `cp` exactly as a +/// spawn would, minus the OS process: no pipes, no reader/waiter threads, and +/// already `spawned` and un-`ref`'d so no pump acts on it and no event loop +/// is held open by it. Returns the registry handle. +#[doc(hidden)] +pub fn cp_register_idle_live_child_for_test(cp: f64) -> u64 { + let handle = CP_NEXT_LIVE_ID.fetch_add(1, Ordering::SeqCst); + let no_ids = crate::async_hooks::AsyncResourceIds { + async_id: 0, + trigger_async_id: 0, + }; + cp_live_lock().get_or_insert_with(HashMap::new).insert( + handle, + LiveChild { + cp_bits: cp.to_bits(), + process_ids: no_ids, + pipe_ids: [no_ids; 3], + pipe_bits: [0; 3], + pid: 0, + stdin: None, + stdout_open: false, + stderr_open: false, + stdout_eof_pending: false, + extra_open: Vec::new(), + loop_streams: Vec::new(), + spawned: true, + exited: None, + exit_emitted: false, + closed: false, + refed: false, + ipc_send: None, + ipc_advanced: false, + abort_signal_bits: 0, + abort_listener_bits: 0, + abort_kill_signal: libc_sigterm(), + #[cfg(windows)] + win_kill_signal: None, + #[cfg(windows)] + win_proc_handle: 0, + exec: None, + }, + ); + CP_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + handle +} + +/// Test probe (#11471): is child `handle` still registered? +#[doc(hidden)] +pub fn cp_live_child_registered_for_test(handle: u64) -> bool { + cp_live_lock() + .as_ref() + .is_some_and(|map| map.contains_key(&handle)) +} + +/// Test probes (#11471) for the reactor group's tables whose own modules are +/// `pub(crate)` (`pty::reactor`, `os::signal`), re-exported so the +/// thread-exit regression tests in `perry-stdlib` can reach them. +#[doc(hidden)] +pub mod thread_exit_test_probes { + #[cfg(unix)] + pub use crate::os::signal::process_signal_subscription_for_test; + pub use crate::os::signal::set_process_signal_listener_count_for_test; + #[cfg(any(unix, windows))] + pub use crate::pty::reactor::pty_live_registered_for_test; + #[cfg(unix)] + pub use crate::pty::reactor::pty_register_idle_live_for_test; +} + fn cp_lookup_cp_bits(handle: u64) -> Option { cp_live_lock() .as_ref() diff --git a/crates/perry-runtime/src/dgram.rs b/crates/perry-runtime/src/dgram.rs index bd32609bcad..de64128961b 100644 --- a/crates/perry-runtime/src/dgram.rs +++ b/crates/perry-runtime/src/dgram.rs @@ -273,6 +273,66 @@ pub(crate) static DGRAM_REGISTRY: LazyLock> = LazyLock::new }) }); +/// #11471: forget every deterministic-mode binding whose socket object lives +/// in a thread's arena that is being freed. +/// +/// `bound` maps an address to the NaN-boxed socket JS object that bound it, +/// and `lookup_bound_socket` hands that object to `'message'` delivery. A +/// binding left behind by an exited thread would deliver onto whatever object +/// another thread's arena later put at that address — and would also keep the +/// port "in use" for `allocate_port`. Runs in the exiting thread's TLS +/// destructor: this table's lock only, no thread-locals, no allocation. +pub(crate) fn release_dgram_bindings_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut registry = DGRAM_REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if registry.bound.is_empty() { + return; + } + registry + .bound + .retain(|_, socket| !freed.holds_value(*socket)); +} + +/// Test probe (#11471): create a socket object and bind it through the +/// deterministic-mode registry, as `bind()` does under +/// `PERRY_DETERMINISTIC_NET=1`. Returns the bound port. +#[doc(hidden)] +pub fn deterministic_bind_for_test(address: &str) -> u16 { + let scope = crate::gc::RuntimeHandleScope::new(); + let socket = scope.root_nanbox_f64(socket_object("udp4")); + net::bind_socket(socket.get_nanbox_f64(), 0, address.to_string()) +} + +/// Test probe (#11471): the socket bits bound at exactly `(address, port)` +/// in the deterministic-mode registry, without touching the socket object. +#[doc(hidden)] +pub fn deterministic_binding_for_test(address: &str, port: u16) -> Option { + let registry = DGRAM_REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + registry + .bound + .get(&SocketKey { + address: address.to_string(), + port, + }) + .map(|socket| socket.to_bits()) +} + +/// Test probe (#11471): create a socket object and bind a real OS socket on +/// `address`, registering it with [`crate::dgram_reactor`]. Returns the +/// reactor id, or `None` if the bind failed. +#[doc(hidden)] +pub fn real_bind_for_test(address: &str) -> Option { + let scope = crate::gc::RuntimeHandleScope::new(); + let socket = scope.root_nanbox_f64(socket_object("udp4")); + net::real_bind(socket.get_nanbox_f64(), 0, address).ok()?; + net::reactor_id(socket.get_nanbox_f64()) +} + pub(crate) fn key(name: &str) -> *mut crate::StringHeader { crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) } diff --git a/crates/perry-runtime/src/dgram_reactor.rs b/crates/perry-runtime/src/dgram_reactor.rs index 0d689db67cd..bf2b68b3e60 100644 --- a/crates/perry-runtime/src/dgram_reactor.rs +++ b/crates/perry-runtime/src/dgram_reactor.rs @@ -574,6 +574,73 @@ pub(crate) fn pump() { } } +/// #11471: drop every socket registered by a thread whose arena is being +/// freed. +/// +/// A [`LiveSocket`] holds the binding thread's socket object, bind-time async +/// context and pending send callbacks, and [`scan_roots_mut`] roots all of +/// them — so an entry that outlives its thread would have every later +/// collection mark (and rewrite) memory another thread's arena may already +/// have reused, [`pump`] would emit `'message'` on it, and [`REFED_COUNT`] +/// would keep the process's event loop alive forever. Values never cross +/// threads, so an entry naming the dead heap anywhere is wholly the dead +/// thread's, and the whole record goes. +/// +/// Runs in the exiting thread's TLS destructor (see `arena::thread_exit`): +/// no thread-locals, so the turnloop entry of a loop-backed socket is left to +/// die with that thread's loop (the retained duplicate descriptor closes when +/// the entry drops here). A thread-backed socket's reader is told to stop and +/// woken, but not joined — it exits on its own next `recv_from` return, and +/// its already-queued datagrams are skipped by [`pump`] because the entry is +/// gone. The liveness counters are decremented exactly as [`drop_entry`] +/// does, so `has_active` stays true only for sockets that still exist. +pub(crate) fn release_dgram_sockets_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if LIVE_COUNT.load(Ordering::Acquire) == 0 { + return; + } + let removed: Vec = { + let mut guard = live_lock(); + let Some(map) = guard.as_mut() else { + return; + }; + let dead: Vec = map + .iter() + .filter(|(_, ls)| { + freed.holds_bits(ls.socket_bits) + || ls + .sends + .values() + .any(|pending| freed.holds_bits(pending.callback_bits)) + }) + .map(|(id, _)| *id) + .collect(); + dead.iter().filter_map(|id| map.remove(id)).collect() + }; + for mut ls in removed { + LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if ls.refed { + REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } + if ls.proc_id.is_some() { + TURNLOOP_COUNT.fetch_sub(1, Ordering::SeqCst); + } + if ls.recv_thread.take().is_some() { + ls.closing.store(true, Ordering::Release); + wake_receiver(&ls.udp); + } + } +} + +/// Test probe (#11471): is socket `id` still registered? +#[doc(hidden)] +pub fn live_socket_registered_for_test(id: u64) -> bool { + live_lock() + .as_ref() + .is_some_and(|map| map.contains_key(&id)) +} + /// Whether any bound + `ref`'d socket should keep the event loop alive — OR'd /// into `js_stdlib_has_active_handles`. pub(crate) fn has_active() -> bool { diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index dbe3e7c6576..d68d5143cfa 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -461,6 +461,31 @@ pub(crate) fn dom_exception_error_clear_dead(err_addr: usize) { } } +/// #11471: thread-exit release for `DOM_EXCEPTION_ERRORS`. Its only removal +/// is the error finalize hook above, which `Arena::drop` never runs, so an +/// exiting thread's DOMExceptions would leave addresses behind that make an +/// unrelated Error at a reused address `instanceof DOMException`. Runs from a +/// TLS destructor: one process-global lock, no thread-locals, no GC. +pub(crate) fn release_dom_exception_errors_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if !DOM_EXCEPTIONS_CREATED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + dom_exception_errors() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|&addr| !freed.contains(addr)); +} + +/// Test probe (#11471): is `err_addr` recorded as a DOMException? +#[doc(hidden)] +pub fn dom_exception_error_registered_for_test(err_addr: usize) -> bool { + dom_exception_errors() + .lock() + .is_ok_and(|set| set.contains(&err_addr)) +} + #[cfg(test)] pub(crate) fn test_seed_dom_exception_error(err_addr: usize) { DOM_EXCEPTIONS_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/perry-runtime/src/frame.rs b/crates/perry-runtime/src/frame.rs index 485b6479648..a83f0cd392e 100644 --- a/crates/perry-runtime/src/frame.rs +++ b/crates/perry-runtime/src/frame.rs @@ -197,6 +197,54 @@ pub extern "C" fn js_frame_pump_default() -> i32 { js_frame_tick(crate::timer::js_timer_now()) } +/// Thread-exit release (#11471): drop every pending `onFrame` callback whose +/// closure lives in the exiting thread's freed arena blocks (nothing forces +/// `js_on_frame_callback` onto the UI thread, and `js_frame_tick` would call +/// through the dangling pointer), and every `LAST_FIRE_BY_CLOSURE` entry keyed +/// by such an address (a closure later allocated there would inherit the dead +/// one's last-fire time and get a wrong `deltaMs`). +/// +/// Runs in the exiting thread's TLS destructor: plain poison-tolerant locks, +/// no thread-locals. Dropping a `FrameCallback` drops its +/// `AsyncContextSnapshot`, a plain `Vec` with no `Drop` side effects. +pub(crate) fn release_frame_callbacks_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + FRAME_CALLBACKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|cb| !freed.holds_i64(cb.callback)); + if let Some(map) = LAST_FIRE_BY_CLOSURE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + { + map.retain(|closure, _| !freed.holds_i64(*closure)); + } +} + +/// Test probe (#11471): is the frame callback registered as `id` still +/// pending? +#[doc(hidden)] +pub fn frame_callback_pending_for_test(id: i64) -> bool { + FRAME_CALLBACKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|cb| cb.id == id && !cb.cleared) +} + +/// Test probe (#11471): does `LAST_FIRE_BY_CLOSURE` hold an entry for the +/// closure address `closure`? +#[doc(hidden)] +pub fn frame_last_fire_recorded_for_test(closure: i64) -> bool { + LAST_FIRE_BY_CLOSURE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&closure)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index aef3230d598..890a37cea1a 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -167,6 +167,30 @@ impl Drop for MallocState { // Free the worker thread's malloc objects instead of leaking them at // exit (audit §6 / #6185). See `free_all_tracked_objects` for the // thread-exit soundness and TLS-destruction-order argument. + // + // #11471: these blocks go back to the system allocator, so another + // thread can be handed the same addresses. Process-global tables + // keyed by, or holding, a `gc_malloc` address (fresh `Symbol()` + // headers in `SYMBOL_POINTERS`, malloc'd strings and closures in + // listener tables) hear about it here, exactly as they do for arena + // blocks in `Arena::drop`. Reported before the frees, so a hook still + // sees mapped memory. Test builds skip it for the same reason + // `Arena::drop` does: their side tables are per-thread. + if !cfg!(test) { + let ranges: Vec<(usize, usize)> = self + .objects + .iter() + .filter(|header| !header.is_null()) + .filter_map(|&header| { + // SAFETY: every entry in `objects` is a live `gc_malloc` + // header until `free_all_tracked_objects` frees it below. + let (flags, size) = unsafe { ((*header).gc_flags, (*header).size as usize) }; + (flags & GC_FLAG_PINNED == 0 && size != 0) + .then(|| (header as usize, header as usize + size)) + }) + .collect(); + crate::arena::thread_exit::release_freed_ranges(&ranges); + } self.free_all_tracked_objects(); } } diff --git a/crates/perry-runtime/src/geisterhand_registry.rs b/crates/perry-runtime/src/geisterhand_registry.rs index b19b32c315a..fafba632212 100644 --- a/crates/perry-runtime/src/geisterhand_registry.rs +++ b/crates/perry-runtime/src/geisterhand_registry.rs @@ -835,6 +835,54 @@ pub extern "C" fn perry_geisterhand_get_closure(handle: i64, callback_kind: u8) } } +/// Thread-exit release (#11471): drop every registered widget callback and +/// queued action that holds a value in the exiting thread's freed arena +/// blocks. Nothing in perry-ui enforces that widgets are created on the main +/// thread on every platform, so a worker's closure can land here; once its +/// arena is recycled the slot would name a different object, which the root +/// scanner would then rewrite and the pump would call. +/// +/// Runs in the exiting thread's TLS destructor: plain poison-tolerant locks +/// only (`lock_gc_root_registry` touches a thread-local depth counter). +pub(crate) fn release_geisterhand_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|w| !freed.holds_value(w.closure_f64)); + PENDING_ACTIONS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|action| match action { + PendingAction::InvokeCallback { closure_f64, args } => { + !freed.holds_value(*closure_f64) && !args.iter().any(|a| freed.holds_value(*a)) + } + PendingAction::SetState { value, .. } => !freed.holds_value(*value), + PendingAction::ApplyStyle { .. } + | PendingAction::SetText { .. } + | PendingAction::ScrollTo { .. } + | PendingAction::ReadValue { .. } + | PendingAction::QueryWidgetTree + | PendingAction::CaptureScreenshot => true, + }); +} + +/// Test probe (#11471): does any queued action hold exactly `value`? +#[doc(hidden)] +pub fn geisterhand_pending_holds_value_for_test(value: f64) -> bool { + let bits = value.to_bits(); + PENDING_ACTIONS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|action| match action { + PendingAction::InvokeCallback { closure_f64, args } => { + closure_f64.to_bits() == bits || args.iter().any(|a| a.to_bits() == bits) + } + PendingAction::SetState { value, .. } => value.to_bits() == bits, + _ => false, + }) +} + #[cfg(test)] pub(crate) fn test_clear_geisterhand_roots() { crate::gc::lock_gc_root_registry(®ISTRY).clear(); diff --git a/crates/perry-runtime/src/media_playback.rs b/crates/perry-runtime/src/media_playback.rs index dc41d602738..61c7f83ae7b 100644 --- a/crates/perry-runtime/src/media_playback.rs +++ b/crates/perry-runtime/src/media_playback.rs @@ -196,6 +196,34 @@ fn with_inbox) -> R>(f: F) -> f(map) } +/// Thread-exit release (#11471): forget every `onStateChange` / +/// `onTimeUpdate` callback that lies in the exiting thread's freed arena +/// blocks. `perry_media_on_*` accept a callback from any thread, and the +/// ArkTS state push would otherwise fire a recycled address. The player's +/// observed state is kept: it is pushed by ArkTS per handle, not owned by the +/// exiting thread. +/// +/// Runs in the exiting thread's TLS destructor: a plain poison-tolerant lock +/// (not `lock_gc_root_registry`, which touches a thread-local). +pub(crate) fn release_media_callbacks_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if let Some(map) = MEDIA_STATE_INBOX + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + { + for obs in map.values_mut() { + if obs.on_state_change.is_some_and(|c| freed.holds_value(c)) { + obs.on_state_change = None; + } + if obs.on_time_update.is_some_and(|c| freed.holds_value(c)) { + obs.on_time_update = None; + } + } + } +} + // --------------------------------------------------------------------------- // String header decoding // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/messaging.rs b/crates/perry-runtime/src/messaging.rs index c16f9bec782..d5c3122a516 100644 --- a/crates/perry-runtime/src/messaging.rs +++ b/crates/perry-runtime/src/messaging.rs @@ -219,6 +219,39 @@ pub fn port_states_root_scanner_mut(visitor: &mut crate::gc::RuntimeRootVisitor< } } +/// #11471: thread-exit release for `PORT_STATES`. The only removal is +/// `port.close()`, so an exiting thread's ports (their keys, entangled +/// partner, queued values, `onmessage` and listener closures all live in its +/// arena) would otherwise stay: a fresh object at a reused address would read +/// as a port and the scanner would trace the dead values. Delivery rides the +/// thread-local `setImmediate` queue, so no liveness counter needs adjusting. +/// Runs from a TLS destructor: one process-global lock, no thread-locals. +pub(crate) fn release_port_states_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + let mut guard = PORT_STATES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(map) = guard.as_mut() else { + return; + }; + map.retain(|&port, state| { + !freed.contains(port) + && !freed.contains(state.entangled) + && !freed.holds_value(state.onmessage) + && !state.queue.iter().any(|&v| freed.holds_value(v)) + && !state.listeners.iter().any(|&v| freed.holds_value(v)) + }); +} + +/// Test probe (#11471): does `PORT_STATES` hold an entry for `port`? +#[doc(hidden)] +pub fn port_state_registered_for_test(port: usize) -> bool { + PORT_STATES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&port)) +} + fn with_port_states(f: impl FnOnce(&mut HashMap) -> R) -> R { let mut guard = PORT_STATES.lock().unwrap(); let map = guard.get_or_insert_with(HashMap::new); @@ -586,6 +619,18 @@ pub extern "C" fn js_message_channel_new() -> f64 { if let Some(factory) = worker_threads_message_channel_factory() { return factory(); } + same_thread_message_channel_new() +} + +/// Test probe (#11471): the same-thread `MessageChannel` this crate builds when +/// perry-stdlib's worker_threads factory is not installed (a stdlib test +/// binary installs it, which would bypass `PORT_STATES`). +#[doc(hidden)] +pub fn same_thread_message_channel_new_for_test() -> f64 { + same_thread_message_channel_new() +} + +fn same_thread_message_channel_new() -> f64 { let obj = object::js_object_alloc(0, 0); set_object_prototype(obj, constructor_prototype("MessageChannel")); set_field(obj, "constructor", get_global_constructor("MessageChannel")); diff --git a/crates/perry-runtime/src/node_submodules/diagnostics.rs b/crates/perry-runtime/src/node_submodules/diagnostics.rs index 011650db399..5da13e60247 100644 --- a/crates/perry-runtime/src/node_submodules/diagnostics.rs +++ b/crates/perry-runtime/src/node_submodules/diagnostics.rs @@ -2,9 +2,9 @@ //! subscribe/unsubscribe, tracing channels) + the global Node diagnostic //! records consumed by the OBJECT_TYPE_ERROR getters. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; -use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::atomic::Ordering; use std::sync::{LazyLock, Mutex, OnceLock}; use std::thread::ThreadId; @@ -1140,23 +1140,29 @@ pub(crate) fn publish_channel(id: i64, data: f64) { } // Cached channel ids for the five `console.*` diagnostics channels. -// Zero means "not yet looked up". The five-element static keeps the -// `console.log` hot path branch-free: load atomic, miss → check by-key -// map, hit → check subscriber count. No string formatting or string -// allocation happens unless a subscriber actually exists. -static CONSOLE_LOG_CHANNEL_ID: AtomicI64 = AtomicI64::new(0); -static CONSOLE_INFO_CHANNEL_ID: AtomicI64 = AtomicI64::new(0); -static CONSOLE_DEBUG_CHANNEL_ID: AtomicI64 = AtomicI64::new(0); -static CONSOLE_ERROR_CHANNEL_ID: AtomicI64 = AtomicI64::new(0); -static CONSOLE_WARN_CHANNEL_ID: AtomicI64 = AtomicI64::new(0); - -pub(crate) fn console_channel_slot(method: &str) -> Option<(&'static AtomicI64, &'static str)> { +// Zero means "not yet looked up". The cache keeps the `console.log` hot path +// cheap: load the slot, miss → check by-key map, hit → check subscriber count. +// No string formatting or string allocation happens unless a subscriber +// actually exists. +// +// #11471: per-thread, like the `DIAG_CHANNEL_BY_KEY` / `DIAG_CHANNELS` tables +// the ids index (minted by the thread-local `NEXT_DIAG_ID`). As process-global +// atomics, the first thread to resolve one pinned ITS id for every thread, so +// another thread published console args to whichever of its own channels had +// that number (or to none), and kept doing so after the first thread exited. +thread_local! { + static CONSOLE_CHANNEL_IDS: [Cell; 5] = const { + [Cell::new(0), Cell::new(0), Cell::new(0), Cell::new(0), Cell::new(0)] + }; +} + +pub(crate) fn console_channel_slot(method: &str) -> Option<(usize, &'static str)> { match method { - "log" => Some((&CONSOLE_LOG_CHANNEL_ID, "console.log")), - "info" => Some((&CONSOLE_INFO_CHANNEL_ID, "console.info")), - "debug" => Some((&CONSOLE_DEBUG_CHANNEL_ID, "console.debug")), - "error" => Some((&CONSOLE_ERROR_CHANNEL_ID, "console.error")), - "warn" => Some((&CONSOLE_WARN_CHANNEL_ID, "console.warn")), + "log" => Some((0, "console.log")), + "info" => Some((1, "console.info")), + "debug" => Some((2, "console.debug")), + "error" => Some((3, "console.error")), + "warn" => Some((4, "console.warn")), _ => None, } } @@ -1177,28 +1183,31 @@ pub fn diagnostics_channel_publish_console(method: &str, arr: *const crate::arra // Resolve the channel id without allocating. If nobody has subscribed // (or even called `dc.channel("console.")`), the by-key lookup // misses and we return without formatting anything. - let mut id = slot.load(Ordering::Relaxed); - if id == 0 { + let mut id = CONSOLE_CHANNEL_IDS.with(|ids| ids[slot].get()); + // Fast subscriber check: if the channel exists but is empty, skip. A + // cached id whose channel was evicted (`evict_inactive_diag_channels_if_needed`) + // reads as `None` and is resolved again: a later `dc.channel(key)` mints a + // NEW id, which a stale cache would never see. + let mut subscribed = if id == 0 { + None + } else { + DIAG_CHANNELS.with(|m| m.borrow().get(&id).map(|c| !c.subscribers.is_empty())) + }; + if id == 0 || subscribed.is_none() { let lookup = DIAG_CHANNEL_BY_KEY.with(|m| { m.borrow() .get(&DiagChannelKey::String(key.to_string())) .copied() }); - match lookup { - Some(real_id) => { - slot.store(real_id, Ordering::Relaxed); - id = real_id; - } - None => return, + let real_id = lookup.unwrap_or(0); + CONSOLE_CHANNEL_IDS.with(|ids| ids[slot].set(real_id)); + if real_id == 0 { + return; } + id = real_id; + subscribed = DIAG_CHANNELS.with(|m| m.borrow().get(&id).map(|c| !c.subscribers.is_empty())); } - // Fast subscriber check: if the channel exists but is empty, skip. - let has_subs = DIAG_CHANNELS.with(|m| { - m.borrow() - .get(&id) - .is_some_and(|c| !c.subscribers.is_empty()) - }); - if !has_subs { + if subscribed != Some(true) { return; } let arr_value = if arr.is_null() { diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index a6739450f9e..c5f871b5351 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -1862,6 +1862,39 @@ pub(crate) fn prune_dead_vm_owner_entries(is_dead_owner: &dyn Fn(usize) -> bool) } } +/// #11471: thread-exit release for `VM_SCRIPTS` / `VM_COMPILED_FUNCTION_SOURCES`. +/// The death prune above retains other threads' owners, so an exiting thread's +/// Script objects and compiled closures would keep their entries and a fresh +/// cell at a reused address would report the dead one's source. Runs from a +/// TLS destructor: process-global locks only, no thread-locals, no GC. +pub(crate) fn release_vm_owner_entries_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + use std::sync::PoisonError; + if let Some(scripts) = VM_SCRIPTS.get() { + let mut guard = scripts.lock().unwrap_or_else(PoisonError::into_inner); + guard.retain(|owner, _| !freed.contains(*owner)); + } + if let Some(sources) = VM_COMPILED_FUNCTION_SOURCES.get() { + let mut guard = sources.lock().unwrap_or_else(PoisonError::into_inner); + guard.retain(|owner, _| !freed.contains(*owner)); + } +} + +/// Test probe (#11471): `(VM_SCRIPTS has owner, VM_COMPILED_FUNCTION_SOURCES has owner)`. +#[doc(hidden)] +pub fn vm_owner_entries_for_test(owner: usize) -> (bool, bool) { + let script = VM_SCRIPTS.get().is_some_and(|m| { + m.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&owner) + }); + ( + script, + compiled_function_source_for_closure(owner).is_some(), + ) +} + #[cfg(test)] pub(crate) fn test_seed_vm_script_entry(owner: usize, source: &str) { scripts().lock().unwrap().insert( diff --git a/crates/perry-runtime/src/object/global_this_webassembly.rs b/crates/perry-runtime/src/object/global_this_webassembly.rs index 30b4f4f18c2..79ba32bc37d 100644 --- a/crates/perry-runtime/src/object/global_this_webassembly.rs +++ b/crates/perry-runtime/src/object/global_this_webassembly.rs @@ -178,11 +178,46 @@ fn module_wrapper_registry_used() -> &'static std::sync::atomic::AtomicBool { &USED } +/// #11471: thread-exit release for both wrapper registries. The move/death +/// hooks keep entries aligned with evacuation and GC death, but `Arena::drop` +/// runs neither, so an exiting thread's wrapper addresses would stay branded +/// and a fresh object at a reused address would read as a genuine wrapper +/// carrying the dead thread's host handle. Host handles are process-global +/// engine objects, so they are dropped from the map, not freed here (the same +/// as the death hook). Runs from a TLS destructor: process-global locks, one +/// at a time, no thread-locals, no GC. Only the wasm-host inserters (and +/// tests) register it; without them the registries stay empty. +#[cfg(any(test, feature = "wasm-host"))] +fn release_wasm_wrapper_registries_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + if !module_wrapper_registry_used().load(std::sync::atomic::Ordering::Acquire) { + return; + } + module_wrappers() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|&wrapper, _| !freed.contains(wrapper)); + extern_wrappers() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|&wrapper, _| !freed.contains(wrapper)); +} + +#[cfg(any(test, feature = "wasm-host"))] +fn register_thread_exit_hook() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_wasm_wrapper_registries_in_freed_ranges, + ) + }); +} + /// Bind a genuine wrapper object identity to its private host module handle. /// Public `__wasm*` properties are compatibility data, never the brand. #[cfg(any(test, feature = "wasm-host"))] pub(crate) fn register_module_wrapper(wrapper: usize, host_handle: usize) { if wrapper != 0 && host_handle != 0 { + register_thread_exit_hook(); if let Ok(mut wrappers) = module_wrappers().lock() { wrappers.insert(wrapper, host_handle); module_wrapper_registry_used().store(true, std::sync::atomic::Ordering::Release); @@ -198,6 +233,7 @@ pub(crate) fn register_module_wrapper(wrapper: usize, host_handle: usize) { #[cfg(feature = "wasm-host")] pub(crate) fn register_extern_wrapper(wrapper: usize, kind: &'static [u8], host_handle: usize) { if wrapper != 0 && host_handle != 0 { + register_thread_exit_hook(); if let Ok(mut wrappers) = extern_wrappers().lock() { wrappers.insert(wrapper, (kind, host_handle)); module_wrapper_registry_used().store(true, std::sync::atomic::Ordering::Release); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 9cf868796de..bd1e07a1628 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -503,6 +503,26 @@ per_test_global! { static GLOBAL_THIS_READY: AtomicBool = AtomicBool::new(false); } +/// #11471: `GLOBAL_THIS_PTR` is a process-global root slot that every thread +/// overwrites with its own `globalThis` on first use (readers go through the +/// per-thread `THREAD_GLOBAL_THIS`, so the slot only feeds the root scanner). +/// When the last writer exits, clear the slot if it still names that thread's +/// object, so no collection keeps marking (or rewriting) a freed address. +pub(crate) fn release_global_this_ptr_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let cached = GLOBAL_THIS_PTR.load(Ordering::Acquire); + if cached != 0 && freed.holds_i64(cached) { + let _ = GLOBAL_THIS_PTR.compare_exchange(cached, 0, Ordering::AcqRel, Ordering::Acquire); + } +} + +/// #11471 test probe: the raw `GLOBAL_THIS_PTR` root slot (0 when unset). +#[doc(hidden)] +pub fn global_this_root_slot_for_test() -> i64 { + GLOBAL_THIS_PTR.load(Ordering::Acquire) +} + // Overflow field storage for objects that exceed their pre-allocated inline slot count. // Keyed by (obj_ptr as usize) -> Vec indexed by absolute field_index // (inline slots 0..alloc_limit remain `TAG_UNDEFINED` placeholders in the Vec; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 5cb3bc1734d..df0185c893c 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -73,6 +73,14 @@ crate::perry_thread_local! { RefCell::new(HashMap::new()); static HANDLE_PROPERTY_BIND_REENTRY: Cell = const { Cell::new(false) }; pub(crate) static BUFFER_CONSTRUCTOR_VALUE: Cell = const { Cell::new(0) }; + /// `Buffer.poolSize` as JS value bits. Node 26 raised its initial value + /// from 8 KiB to 64 KiB. It is a plain writable property of each realm's + /// `Buffer`, so any value may be stored: per-thread (a worker's write never + /// reaches the main thread, as in Node) and visited by + /// `scan_native_callable_export_roots_mut`. #11471: it used to be a + /// process-global atomic, which kept an object/string written by an + /// exited thread as a dangling, unrooted address. + static BUFFER_POOL_SIZE_BITS: Cell = const { Cell::new(65536f64.to_bits()) }; pub(crate) static SQLITE_STATEMENT_SYNC_CONSTRUCTOR_VALUE: Cell = const { Cell::new(0) }; pub(crate) static SQLITE_SESSION_CONSTRUCTOR_VALUE: Cell = const { Cell::new(0) }; pub(crate) static UTIL_INSPECT_DEFAULT_OPTIONS: Cell = const { Cell::new(0) }; @@ -254,6 +262,11 @@ pub fn scan_native_callable_export_roots_mut(visitor: &mut crate::gc::RuntimeRoo slot.set(value_bits); } }); + BUFFER_POOL_SIZE_BITS.with(|slot| { + let mut value_bits = slot.get(); + visitor.visit_nanbox_u64_slot(&mut value_bits); + slot.set(value_bits); + }); SQLITE_STATEMENT_SYNC_CONSTRUCTOR_VALUE.with(|slot| { let mut value_bits = slot.get(); if value_bits != 0 { @@ -391,10 +404,6 @@ pub const NATIVE_MODULE_CLASS_ID: u32 = 0xFFFFFFFE; pub(crate) const WORKER_THREADS_LOCK_MANAGER_CLASS_ID: u32 = 0xFFFF_00B1; pub(crate) const WORKER_THREADS_LOCK_CLASS_ID: u32 = 0xFFFF_00B2; -// Node 26 raised Buffer.poolSize's initial value from 8 KiB to 64 KiB. -static BUFFER_POOL_SIZE_BITS: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(65536f64.to_bits()); - type WorkerThreadsValueGetter = extern "C" fn() -> f64; pub(crate) static WORKER_THREADS_WORKER_DATA_GETTER: AtomicPtr<()> = AtomicPtr::new(null_mut()); @@ -431,11 +440,12 @@ pub(crate) fn call_worker_threads_getter( } pub(crate) fn buffer_pool_size() -> f64 { - f64::from_bits(BUFFER_POOL_SIZE_BITS.load(std::sync::atomic::Ordering::Relaxed)) + f64::from_bits(BUFFER_POOL_SIZE_BITS.with(Cell::get)) } pub(crate) fn set_buffer_pool_size(value: f64) { - BUFFER_POOL_SIZE_BITS.store(value.to_bits(), std::sync::atomic::Ordering::Relaxed); + BUFFER_POOL_SIZE_BITS.with(|slot| slot.set(value.to_bits())); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); } /// Linker-strippability vtable for every native-module behavior reachable @@ -1951,3 +1961,21 @@ mod cjs_default; pub(crate) use cjs_default::{ cjs_default_base_module, cjs_default_export_value, native_module_get_builtin_module_value, }; + +#[cfg(test)] +mod buffer_pool_size_tests { + // #11471: `Buffer.poolSize` is per realm, so a worker's write (possibly of + // a heap value) never reaches another thread. + #[test] + fn buffer_pool_size_writes_stay_on_their_thread() { + let written = std::thread::spawn(|| { + super::set_buffer_pool_size(1.0); + super::buffer_pool_size() + }) + .join() + .unwrap(); + assert_eq!(written, 1.0); + let elsewhere = std::thread::spawn(super::buffer_pool_size).join().unwrap(); + assert_eq!(elsewhere, 65536.0); + } +} diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 51cc40089bd..4ffc11b31f7 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -943,6 +943,45 @@ pub(crate) fn prune_dead_object_prototype_owners(is_dead_owner: &dyn Fn(usize) - } } +/// #11471: thread-exit release for `OBJECT_PROTOTYPES`. Death pruning above +/// only attributes the collecting thread's heap, so an exiting thread's +/// entries (owner in its arena, or a prototype value from it) would outlive +/// its blocks and a fresh object at a reused address would inherit the dead +/// owner's prototype. Runs from `Arena::drop` (TLS destructor): one plain +/// process-global lock, no thread-locals, no allocation. The owner's header +/// bit (`set_residual_proto_owner_bit`) lives in the freed memory itself. +pub(crate) fn release_object_prototypes_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { + return; + } + let Some(registry) = OBJECT_PROTOTYPES.get() else { + return; + }; + let mut map = registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + map.retain(|&owner, bits| !freed.contains(owner) && !freed.holds_bits(*bits)); + // Same latch release as the death prune, under the same lock (#7737). + if map.is_empty() { + OBJECT_PROTOTYPES_NONEMPTY.store(false, Ordering::Release); + } +} + +/// Test probe (#11471): does the residual registry hold an entry for `owner`? +/// Exported unmangled because this module is crate-private. +#[doc(hidden)] +#[no_mangle] +pub extern "C" fn perry_thread_exit_probe_object_prototype_recorded(owner: usize) -> bool { + OBJECT_PROTOTYPES.get().is_some_and(|registry| { + registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&owner) + }) +} + /// Can the residual owner registry hold an entry at all? /// /// The latch is stored (`Release`) before the first insert, so `false` proves diff --git a/crates/perry-runtime/src/os/signal.rs b/crates/perry-runtime/src/os/signal.rs index 74343828a7b..9e3ab3c0012 100644 --- a/crates/perry-runtime/src/os/signal.rs +++ b/crates/perry-runtime/src/os/signal.rs @@ -4,6 +4,8 @@ use crate::value::{JSValue, TAG_TRUE}; use std::collections::HashMap; #[cfg(unix)] use std::sync::atomic::AtomicI32; +#[cfg(unix)] +use std::sync::atomic::AtomicU64; #[cfg(any(unix, windows))] use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{LazyLock, Mutex}; @@ -403,6 +405,90 @@ static TURNLOOP_SIGNAL_IDS: [AtomicUsize; 9] = [ AtomicUsize::new(0), ]; +/// #11471: the agent whose loop carries each slot's subscription, written +/// just before [`TURNLOOP_SIGNAL_IDS`]. A turnloop id is a handle into ONE +/// agent's loop and `turnloop_proc` table, not a process-wide name, so when +/// that agent retires, [`release_signal_subscriptions_of_agent`] finds its +/// slots here and unwinds them. +#[cfg(unix)] +static TURNLOOP_SIGNAL_OWNERS: [AtomicU64; 9] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), +]; + +#[cfg(unix)] +const _: () = assert!( + TURNLOOP_SIGNAL_OWNERS.len() == PROCESS_SIGNAL_SLOTS.len(), + "one turnloop owner cell per process-signal slot" +); + +/// #11471: unwind every signal subscription carried by a retiring agent's +/// loop. Registered with `agent::register_retire_hook` on the first turnloop +/// install. +/// +/// `retire_agent` has already shut that loop down (it runs +/// `event_pump::shutdown_agent_loop` before any retire hook), which ended the +/// subscription and restored the disposition. What survives in these +/// process-global statics is the bookkeeping: the slot's `installed` flag, +/// its turnloop bit and the dead loop's entry id. Left in place, the next +/// `process.on(signal)` on any other thread would see `installed` and never +/// subscribe — the signal silently lost — and a later `off()` would hand the +/// dead loop's id to its own loop's `signal_stop`. Clearing them returns the +/// slot to "not installed", so the next listener sync installs afresh on its +/// own loop. The listener count is left alone: it mirrors whichever thread +/// synced last, and that thread's next sync rewrites it. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn release_signal_subscriptions_of_agent(agent: crate::agent::AgentId) { + for (idx, slot) in PROCESS_SIGNAL_SLOTS.iter().enumerate() { + if TURNLOOP_SIGNAL_OWNERS[idx].load(Ordering::Acquire) != agent { + continue; + } + let id = TURNLOOP_SIGNAL_IDS[idx].load(Ordering::Acquire); + // Zero: already unwound by `off()`. A failed exchange: another thread + // unwound (or replaced) it first and owns the cleanup. + if id == 0 + || TURNLOOP_SIGNAL_IDS[idx] + .compare_exchange(id, 0, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + mark_on_turnloop(slot, false); + slot.pending.store(0, Ordering::Release); + slot.installed.store(false, Ordering::Release); + crate::gc::census::census_after_signal_disposition_reset(slot.number); + } +} + +/// Test probe (#11471): sync `count` listeners for `name` on the calling +/// thread, exactly as `process.on`/`process.off` do. +#[doc(hidden)] +pub fn set_process_signal_listener_count_for_test(name: &str, count: usize) { + set_process_signal_listener_count(name, count); +} + +/// Test probe (#11471): `(installed, on turnloop, turnloop entry id, owner +/// agent)` for signal `name`. +#[cfg(unix)] +#[doc(hidden)] +pub fn process_signal_subscription_for_test(name: &str) -> Option<(bool, bool, usize, u64)> { + let slot = slot_by_name(name)?; + let idx = slot_index(slot); + Some(( + slot.installed.load(Ordering::Acquire), + is_on_turnloop(slot), + TURNLOOP_SIGNAL_IDS[idx].load(Ordering::Acquire), + TURNLOOP_SIGNAL_OWNERS[idx].load(Ordering::Acquire), + )) +} + // A slot added above without a matching id cell would silently index out of // bounds at runtime; say so at compile time instead. #[cfg(unix)] @@ -464,6 +550,14 @@ fn install_signal_on_loop(slot: &'static ProcessSignalSlot) -> bool { ) else { return false; }; + // #11471: the owner first, so a retire hook that sees this id also sees + // whose loop it names. + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::agent::register_retire_hook(release_signal_subscriptions_of_agent); + }); + TURNLOOP_SIGNAL_OWNERS[slot_index(slot)] + .store(crate::agent::current_agent(), Ordering::Release); TURNLOOP_SIGNAL_IDS[slot_index(slot)].store(id as usize, Ordering::Release); // A registered listener is ref-NEUTRAL (see `has_active_process_signal_listeners` // and `crates/perry/tests/issue_signal_listener_ref_neutral.rs`): it must diff --git a/crates/perry-runtime/src/os_process_streams.rs b/crates/perry-runtime/src/os_process_streams.rs index a6c7aa3423f..6cedbe5cdb0 100644 --- a/crates/perry-runtime/src/os_process_streams.rs +++ b/crates/perry-runtime/src/os_process_streams.rs @@ -259,8 +259,38 @@ static STDIN_LISTENERS_ARMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn arm_stdin_listeners() { + // #11471: the hook must exist before the first entry does. + static REGISTER_THREAD_EXIT_HOOK: std::sync::Once = std::sync::Once::new(); + REGISTER_THREAD_EXIT_HOOK.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_stdin_listeners_in_freed_ranges, + ); + }); STDIN_LISTENERS_ARMED.store(true, std::sync::atomic::Ordering::Release); } + +/// #11471: drop every stdin listener whose closure lives in an exiting +/// thread's arena. The six lists are process-global while `process.stdin.on` +/// can run on any thread; left behind, the main-thread pump would call a freed +/// (or reused) closure address and the root scanner would visit it. Removing +/// the entries also keeps `stdin_listeners_keep_loop_alive` honest: a dead +/// thread's listener no longer pins the event loop open. Registered (from +/// `arm_stdin_listeners`, which every push runs first) rather than called from +/// `release_freed_ranges`, because this module is private to `os`. +fn release_stdin_listeners_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + for list in [ + &STDIN_DATA_LISTENERS, + &STDIN_DATA_ONCE, + &STDIN_READABLE_LISTENERS, + &STDIN_READABLE_ONCE, + &STDIN_END_LISTENERS, + &STDIN_END_ONCE, + ] { + list.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|cb| !freed.holds_i64(*cb)); + } +} // Set by the reader thread on fd-0 EOF; observed by the main-thread pump. static STDIN_EOF_SEEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); // Set once the `'end'`/`'close'` listeners have fired, so they fire at most once. diff --git a/crates/perry-runtime/src/plugin.rs b/crates/perry-runtime/src/plugin.rs index b9f810664bf..b36b8c2d2cf 100644 --- a/crates/perry-runtime/src/plugin.rs +++ b/crates/perry-runtime/src/plugin.rs @@ -332,6 +332,37 @@ pub fn scan_plugin_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { } } +/// Thread-exit release (#11471): drop every plugin registration (hook, tool, +/// service, route, event handler) whose closure, and every config value that, +/// lies in the exiting thread's freed arena blocks. The registration FFI is +/// callable from any thread with no check, and the registry is scanned as a +/// GC root and invoked by `emit_hook`/`invoke_tool`, so a left-behind entry +/// would name whatever the recycled address holds next. Plugin entries and +/// library handles are left alone: they hold no heap address. +/// +/// Runs in the exiting thread's TLS destructor: a plain poison-tolerant lock +/// (not `lock_gc_root_registry`, which touches a thread-local), no JS. +pub(crate) fn release_plugin_registry_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut reg = REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for hooks in reg.hooks.values_mut() { + hooks.retain(|h| !freed.holds_bits(h.handler_closure)); + } + reg.hooks.retain(|_, v| !v.is_empty()); + reg.tools.retain(|t| !freed.holds_bits(t.handler_closure)); + reg.services + .retain(|s| !freed.holds_bits(s.start_fn) && !freed.holds_bits(s.stop_fn)); + reg.routes.retain(|r| !freed.holds_bits(r.handler_closure)); + for handlers in reg.events.values_mut() { + handlers.retain(|e| !freed.holds_bits(e.handler_closure)); + } + reg.events.retain(|_, v| !v.is_empty()); + reg.config.retain(|_, value| !freed.holds_bits(*value)); +} + // ============================================================================ // Helper: extract a Rust string from a NaN-boxed f64 string value // ============================================================================ diff --git a/crates/perry-runtime/src/promise/native_async.rs b/crates/perry-runtime/src/promise/native_async.rs index f1a306840a3..f9f6d9b8a59 100644 --- a/crates/perry-runtime/src/promise/native_async.rs +++ b/crates/perry-runtime/src/promise/native_async.rs @@ -90,6 +90,10 @@ pub struct NativeAsyncCompletion { thread_policy: u8, main_thread_id: u64, slots: Mutex, + /// #11471: the agent (JS heap) that created this token and owns its + /// Promise. [`release_native_async_tokens_of_agent`] purges a retired + /// agent's tokens by this tag. + owner_agent: crate::agent::AgentId, } unsafe impl Send for NativeAsyncCompletion {} @@ -288,7 +292,9 @@ fn make_token_for_promise( handles: Vec::new(), context: capture_context(), }), + owner_agent: crate::agent::current_agent(), }); + register_thread_exit_release(); let token_ptr = Box::into_raw(token); { let mut registry = crate::gc::lock_gc_root_registry(registry()); @@ -313,6 +319,111 @@ fn make_token_for_promise( token_ptr } +/// #11471: arm the agent-retire purge before the first token exists. The +/// arena-range half ([`release_native_async_tokens_in_freed_ranges`]) is called +/// directly by `arena::thread_exit::release_freed_ranges`. +fn register_thread_exit_release() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::agent::register_retire_hook(release_native_async_tokens_of_agent); + }); +} + +/// Retire every registered token `dead` selects: mark it completed (so a late +/// native completion reports `ALREADY_COMPLETED` instead of queueing), clear +/// its slots, and drop it from `tokens`, `pending` and `by_promise`, +/// republishing [`ACTIVE_ENTRIES`] so the keep-alive check stops counting it. +/// +/// Thread-exit safe: plain registry and slot locks (the same registry → +/// slots order as `enqueue_payload`), no GC-root-lock bookkeeping (that is a +/// thread-local), no allocation on the JS heap, no settlement. Deliberately +/// NOT done, because each needs the dead heap or its thread: the #9552 +/// cross-thread pin is not released (the malloc-resident Promise is +/// abandoned with the rest of the dead thread's malloc space), and attached +/// native handles are not disposed (their finalizers are leaked, not run on +/// a thread that does not own them). +fn release_tokens_where(dead: impl Fn(&NativeAsyncCompletion, &TokenSlots) -> bool) { + let Some(registry) = REGISTRY.get() else { + return; + }; + let mut registry = registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if registry.tokens.is_empty() && registry.pending.is_empty() { + return; + } + let mut released: Vec = Vec::new(); + for &token_ptr in registry.tokens.iter().chain(registry.pending.iter()) { + if released.contains(&token_ptr) { + continue; + } + // Tokens are leaked boxes and never freed, so the pointer is valid. + let token = unsafe { &*(token_ptr as *const NativeAsyncCompletion) }; + let mut slots = token + .slots + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !dead(token, &slots) { + continue; + } + token.state.store(STATE_COMPLETED, Ordering::Release); + slots.promise = 0; + slots.payload = None; + slots.handles.clear(); + slots.context = AsyncContextSnapshot::default(); + released.push(token_ptr); + } + if released.is_empty() { + return; + } + registry + .tokens + .retain(|token_ptr| !released.contains(token_ptr)); + registry + .pending + .retain(|token_ptr| !released.contains(token_ptr)); + registry + .by_promise + .retain(|_, token_ptr| !released.contains(token_ptr)); + publish_active_entries(®istry); +} + +/// #11471: drop every token that names memory in an exiting thread's arena. +/// +/// A token's payload bits and attached handle values are GC-rooted by +/// [`scan_native_async_completion_roots_mut`] and settled into its Promise by +/// whichever thread pumps [`js_native_async_process_pending`]; left behind, +/// later collections would mark/rewrite reused memory and the pump would +/// settle a dead heap's promise with dead values. The Promise itself is +/// malloc-resident and not in the range, so an idle token of a thread with +/// no agent is invisible here; an agent's idle tokens are released at +/// retirement instead ([`release_native_async_tokens_of_agent`]). +pub(crate) fn release_native_async_tokens_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + release_tokens_where(|_, slots| { + freed.contains(slots.promise) + || match &slots.payload { + Some(PendingPayload::ResolveBits(bits) | PendingPayload::RejectBits(bits)) => { + freed.holds_bits(*bits) + } + _ => false, + } + || slots + .handles + .iter() + .any(|handle| freed.holds_bits(handle.value_bits)) + }); +} + +/// #11471: at `perry/thread` / worker agent retirement, drop every token that +/// agent created. Its Promise is reachable only from that agent's heap, so +/// nothing can observe it any more — but an unsettled token would still count +/// in [`ACTIVE_ENTRIES`] and keep the process's event loop alive forever. +fn release_native_async_tokens_of_agent(agent: crate::agent::AgentId) { + release_tokens_where(|token, _| token.owner_agent == agent); +} + fn adopt_or_get_token_for_promise(promise: *mut Promise) -> *mut NativeAsyncCompletion { if promise.is_null() { return std::ptr::null_mut(); diff --git a/crates/perry-runtime/src/pty/reactor.rs b/crates/perry-runtime/src/pty/reactor.rs index 1232a3a3884..1d431595480 100644 --- a/crates/perry-runtime/src/pty/reactor.rs +++ b/crates/perry-runtime/src/pty/reactor.rs @@ -502,7 +502,15 @@ fn pty_reactor_pump_inner() { libc::close(item.master); } let scope = crate::gc::RuntimeHandleScope::new(); - let bits = pty_live_lock().as_ref().unwrap()[&item.handle].ipty_bits; + // #11471: a thread exit may have released this entry since the + // snapshot above (`release_ptys_in_freed_ranges`), and that release + // owns its counter decrement. + let Some(bits) = pty_live_lock() + .as_ref() + .and_then(|map| map.get(&item.handle).map(|lp| lp.ipty_bits)) + else { + continue; + }; let ipty = scope.root_nanbox_f64(f64::from_bits(bits)); if !item.tail.is_empty() { let chunk = crate::child_process::cp_box_string(&String::from_utf8_lossy(&item.tail)); @@ -525,12 +533,15 @@ fn pty_reactor_pump_inner() { // a handler reading `pty.process` state observes post-exit values. cp_set_field(ipty.get_nanbox_f64(), b"exitCode", exit_code); super::pty_emit(ipty.get_nanbox_f64(), "exit", &[payload.get_nanbox_f64()]); - if let Some(map) = pty_live_lock().as_mut() { - map.remove(&item.handle); - } - PTY_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); - if item.refed { - PTY_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + let removed = pty_live_lock() + .as_mut() + .and_then(|map| map.remove(&item.handle)) + .is_some(); + if removed { + PTY_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if item.refed { + PTY_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } } } } @@ -559,6 +570,86 @@ pub(crate) fn pty_reactor_scan_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } } +/// #11471: drop every live pty registered by a thread whose arena is being +/// freed. +/// +/// `ipty_bits` is the spawning thread's IPty object, GC-rooted by +/// [`pty_reactor_scan_roots_mut`] and emitted on by the pump. Left behind by an +/// exited thread, later collections would mark/rewrite memory another arena +/// may have reused, the pump would fire `onData`/`onExit` on it, and +/// [`PTY_REFED_COUNT`] would keep the event loop alive forever. +/// +/// Runs in the exiting thread's TLS destructor (see `arena::thread_exit`): no +/// thread-locals, no JS. The child is not signalled. The master descriptor is +/// deliberately LEAKED, not closed: the reader thread may still be blocked in +/// `read(master)`, and closing it under that thread would let the descriptor +/// number be reused while the read is in flight — the same race the pump +/// avoids by closing only after `Eof`. The reader/waiter threads finish on the +/// child's exit and their events for this handle are skipped by the pump. +/// Dropping the entry drops the writer channel, which ends the writer thread. +pub(crate) fn release_ptys_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + if PTY_LIVE_COUNT.load(Ordering::Acquire) == 0 { + return; + } + let removed: Vec = { + let mut guard = pty_live_lock(); + let Some(map) = guard.as_mut() else { + return; + }; + let dead: Vec = map + .iter() + .filter(|(_, lp)| freed.holds_bits(lp.ipty_bits)) + .map(|(handle, _)| *handle) + .collect(); + dead.iter() + .filter_map(|handle| map.remove(handle)) + .collect() + }; + for lp in &removed { + PTY_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if lp.refed { + PTY_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + drop(removed); +} + +/// Test probe (#11471): register a live-pty entry for `ipty` as +/// `pty_register_live` would, minus the child: no descriptor (`-1`), no +/// reader/waiter threads, un-`ref`'d and never exiting, so no pump acts on it +/// and no event loop is held open by it. Returns the registry handle. +#[cfg(unix)] +#[doc(hidden)] +pub fn pty_register_idle_live_for_test(ipty: f64) -> u64 { + let handle = PTY_NEXT_LIVE_ID.fetch_add(1, Ordering::SeqCst); + pty_live_lock().get_or_insert_with(HashMap::new).insert( + handle, + LivePty { + ipty_bits: ipty.to_bits(), + pid: 0, + master: -1, + write_tx: std::sync::mpsc::channel().0, + utf8_carry: Vec::new(), + eof: false, + exited: None, + closed: false, + refed: false, + paused: false, + pending: Vec::new(), + }, + ); + PTY_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + handle +} + +/// Test probe (#11471): is pty `handle` still registered? +#[doc(hidden)] +pub fn pty_live_registered_for_test(handle: u64) -> bool { + pty_live_lock() + .as_ref() + .is_some_and(|map| map.contains_key(&handle)) +} + #[cfg(test)] pub(crate) fn pty_live_count_for_test() -> u64 { PTY_LIVE_COUNT.load(Ordering::Relaxed) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 7c3b51978f6..7f6c445a75e 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -32,7 +32,10 @@ mod inherited_accessor_tests; mod iterator; mod properties; +pub(crate) use accessors::release_symbol_accessors_in_freed_ranges; pub(crate) use accessors::set_symbol_accessor_property; +#[doc(hidden)] +pub use accessors::symbol_accessor_held_for_test; // Symbol constructor + value FFI (no_mangle entry points re-exported so existing // `crate::symbol::js_symbol_*` call paths keep resolving). @@ -901,6 +904,120 @@ pub(crate) fn prune_dead_symbol_pointers(is_dead_symbol: &dyn Fn(usize) -> bool) } } +/// #11471: thread-exit release for this file's process-global symbol tables. +/// +/// `prune_dead_symbol_pointers` / `prune_dead_symbol_property_owners` only +/// attribute the COLLECTING thread's heap, so an exiting thread's fresh +/// symbols, symbol-keyed props (owner, key or value in its arena) and +/// class-static symbol members it stored would otherwise outlive its blocks: +/// once another thread reuses an address, `js_is_symbol` reports an unrelated +/// cell as a Symbol, a new object inherits the dead one's symbol props, and +/// the root scanners trace dangling value bits. +/// +/// Runs from `Arena::drop` (a TLS destructor): plain `lock()`s only — +/// `lock_gc_root_registry` touches a thread-local depth counter and may flush +/// a deferred GC — no allocation on the GC heap, no JS. +pub(crate) fn release_symbol_tables_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + use std::sync::PoisonError; + let mut changed = false; + { + let mut guard = SYMBOL_POINTERS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(set) = guard.as_mut() { + let before = set.len(); + set.retain(|&ptr| !freed.contains(ptr)); + changed |= set.len() != before; + } + } + { + let mut guard = SYMBOL_PROPERTIES + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(map) = guard.as_mut() { + map.retain(|&owner, entries| { + if freed.contains(owner) { + changed = true; + return false; + } + let before = entries.len(); + entries.retain(|&(sym, bits)| !freed.contains(sym) && !freed.holds_bits(bits)); + changed |= entries.len() != before; + !entries.is_empty() + }); + } + } + { + let mut guard = SYMBOL_PROPERTY_ATTRS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(map) = guard.as_mut() { + let before = map.len(); + map.retain(|&(owner, sym), _| !freed.contains(owner) && !freed.contains(sym)); + changed |= map.len() != before; + } + } + // Class ids are process-global, but a member a dying thread stored holds + // its symbol and/or value. The symbol is deliberately NOT dereferenced: a + // `gc_malloc`'d symbol may already have been freed by the thread's + // `MallocState` destructor. `CLASS_STATIC_SYMBOL_ORDER` therefore keeps + // the removed member's symbol id; ids are monotonic and never reissued, + // so a stale id can only cost a few bytes, never a wrong position. + { + let mut guard = CLASS_STATIC_SYMBOLS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(map) = guard.as_mut() { + let before = map.len(); + map.retain(|&(_, sym), bits| !freed.contains(sym) && !freed.holds_bits(*bits)); + changed |= map.len() != before; + } + } + if changed { + symbol_property_ic_epoch_bump(); + } +} + +/// Test probe (#11471): is `ptr` in the process-global symbol set? +#[doc(hidden)] +pub fn symbol_pointer_registered_for_test(ptr: usize) -> bool { + SYMBOL_POINTERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|set| set.contains(&ptr)) +} + +/// Test probe (#11471): does `owner` have a `SYMBOL_PROPERTIES` record, and a +/// `SYMBOL_PROPERTY_ATTRS` entry for `sym`? +#[doc(hidden)] +pub fn symbol_property_tables_hold_for_test(owner: usize, sym: usize) -> (bool, bool) { + let props = SYMBOL_PROPERTIES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&owner)); + let attrs = SYMBOL_PROPERTY_ATTRS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&(owner, sym))); + (props, attrs) +} + +/// Test probe (#11471): does class `class_id` hold a static member under the +/// symbol at `sym`? +#[doc(hidden)] +pub fn class_static_symbol_held_for_test(class_id: u32, sym: usize) -> bool { + CLASS_STATIC_SYMBOLS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&(class_id, sym))) +} + // Monotonic id counter for fresh symbols. Not thread-safe per-thread but // Symbol semantics are compatible with coarse locking. static NEXT_SYMBOL_ID: Mutex = Mutex::new(1); diff --git a/crates/perry-runtime/src/symbol/accessors.rs b/crates/perry-runtime/src/symbol/accessors.rs index b8caaf9addb..514f60d06ea 100644 --- a/crates/perry-runtime/src/symbol/accessors.rs +++ b/crates/perry-runtime/src/symbol/accessors.rs @@ -60,6 +60,48 @@ pub(super) fn prune_dead_symbol_accessor_owners(is_dead_owner: &dyn Fn(usize) -> } } +/// #11471: thread-exit release for `SYMBOL_ACCESSOR_PROPERTIES`. Death +/// pruning above only attributes the collecting thread's heap, so an exiting +/// thread's accessor entries (owner or symbol in its arena, or get/set +/// closures from it) would outlive its blocks: a new object at a reused owner +/// address would gain the dead one's accessor, and +/// `scan_symbol_accessor_roots_mut` would trace the dangling closure bits. +/// +/// Runs from `Arena::drop` (a TLS destructor): a plain `lock()`, never +/// `lock_gc_root_registry` (thread-local depth counter + deferred-GC flush). +pub(crate) fn release_symbol_accessors_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut guard = SYMBOL_ACCESSOR_PROPERTIES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(map) = guard.as_mut() else { + return; + }; + let before = map.len(); + map.retain(|&(owner, sym), acc| { + !freed.contains(owner) + && !freed.contains(sym) + && !freed.holds_bits(acc.get) + && !freed.holds_bits(acc.set) + }); + let changed = map.len() != before; + drop(guard); + if changed { + crate::symbol::symbol_property_ic_epoch_bump(); + } +} + +/// Test probe (#11471): is there a symbol accessor for `(owner, sym)`? +#[doc(hidden)] +pub fn symbol_accessor_held_for_test(owner: usize, sym: usize) -> bool { + SYMBOL_ACCESSOR_PROPERTIES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|map| map.contains_key(&(owner, sym))) +} + #[cfg(test)] pub(crate) fn test_symbol_accessor_property_count() -> usize { let guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); diff --git a/crates/perry-runtime/src/timer/mock.rs b/crates/perry-runtime/src/timer/mock.rs index 4c84118eb24..1bb661bf47c 100644 --- a/crates/perry-runtime/src/timer/mock.rs +++ b/crates/perry-runtime/src/timer/mock.rs @@ -167,6 +167,7 @@ pub(super) fn schedule_mock_callback_timer( if !state.enabled || (state.apis & api) == 0 { return None; } + register_thread_exit_release(); let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = scope.root_raw_const_ptr(callback as *const crate::closure::ClosureHeader); @@ -207,6 +208,7 @@ pub(super) fn schedule_mock_interval_timer( if !state.enabled || (state.apis & MOCK_TIMERS_API_SET_INTERVAL) == 0 { return None; } + register_thread_exit_release(); let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = scope.root_raw_const_ptr(callback as *const crate::closure::ClosureHeader); @@ -350,6 +352,66 @@ pub(super) fn mock_clear_immediate(timer_id: i64) { state.callbacks.retain(|timer| !timer.cleared); } +// ── Thread exit (#11471) ──────────────────────────────────────────────────── + +/// Register [`release_mock_timers_in_freed_ranges`] before the first entry is +/// queued. `timer::mock` is private to `timer`, so the hook is registered +/// rather than named in `arena::thread_exit`'s anchor block. +fn register_thread_exit_release() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + crate::arena::thread_exit::register_thread_exit_range_hook( + release_mock_timers_in_freed_ranges, + ); + }); +} + +/// #11471: drop every queued mock timer whose closure or arguments live in an +/// exiting thread's arena. +/// +/// `MOCK_TIMERS` is process-global, and while it is enabled ANY thread's +/// `setTimeout`/`setInterval` lands here, so an entry can hold a worker's raw +/// closure pointer and NaN-boxed arguments. The agent-timer purge at worker +/// retirement covers only the real store; left behind here, the next +/// `mock.timers.tick()`/`runAll()` would call a dead closure and +/// `scan_mock_timers_step` would mark/rewrite reused memory. +/// +/// Runs in the exiting thread's TLS destructor (see `arena::thread_exit`): +/// this table's lock only (poison-tolerant), no thread-locals. The removed +/// entries are dropped AFTER the lock is released: each carries a +/// `ScheduledTimerId` whose drop retires the id in the ref-state registry +/// (its own process-global lock — the same queue → registry order the clear +/// paths use, but there is no need to nest them here). +fn release_mock_timers_in_freed_ranges(freed: &crate::arena::thread_exit::FreedRanges) { + let (callbacks, intervals) = { + let mut state = MOCK_TIMERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.callbacks.is_empty() && state.intervals.is_empty() { + return; + } + let (dead_callbacks, live_callbacks): (Vec<_>, Vec<_>) = + std::mem::take(&mut state.callbacks) + .into_iter() + .partition(|timer| { + freed.holds_i64(timer.callback) + || timer.args.iter().any(|&arg| freed.holds_value(arg)) + }); + state.callbacks = live_callbacks; + let (dead_intervals, live_intervals): (Vec<_>, Vec<_>) = + std::mem::take(&mut state.intervals) + .into_iter() + .partition(|timer| { + freed.holds_i64(timer.callback) + || timer.args.iter().any(|&arg| freed.holds_value(arg)) + }); + state.intervals = live_intervals; + (dead_callbacks, dead_intervals) + }; + drop(callbacks); + drop(intervals); +} + // ── GC roots and accounting for the mock tables ───────────────────────────── pub(super) fn scan_mock_timers_step( diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index f75d7881176..85af04c95a7 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -96,6 +96,21 @@ fn client_metadata() -> &'static Mutex> { crate::once_init::get_or_init(&TLS_CLIENT_METADATA, || Mutex::new(HashMap::new())) } +/// #11471: drop every client record whose `checkServerIdentity` closure lives +/// in an exiting thread's arena. The map is process-global while +/// `tls.connect` can run on any thread, and closing a socket keeps its record, +/// so a left-behind entry would be rooted as a dangling address and called by a +/// late `js_tls_client_check_identity`. +pub(crate) fn release_tls_client_metadata_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if let Some(map) = TLS_CLIENT_METADATA.get() { + map.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, metadata| !freed.holds_i64(metadata.check_server_identity)); + } +} + pub fn tls_client_metadata(handle: i64) -> Option { client_metadata().lock().ok()?.get(&handle).cloned() } diff --git a/crates/perry-runtime/src/tui/hooks.rs b/crates/perry-runtime/src/tui/hooks.rs index 459c1b8a2b6..72599de0a45 100644 --- a/crates/perry-runtime/src/tui/hooks.rs +++ b/crates/perry-runtime/src/tui/hooks.rs @@ -187,6 +187,83 @@ pub(crate) fn scan_hook_slot_roots_mut_step( state.index >= slots.len() } +/// Thread-exit release (#11471): reset every hook-slot edge that lies in the +/// exiting thread's freed arena blocks. Slots are addressed by hook index, so +/// they are cleared in place: a `State`/`Ref` value becomes `undefined`, a +/// `Memo` is marked uncomputed (it recomputes on the next render), a `Ref` +/// handle object is forgotten (the next `useRef` at that index mints a new +/// one), and an `Effect` cleanup pointer is zeroed. Nothing in `perry/tui` +/// restricts setters / `ref.current =` to the render thread, and a slot left +/// holding a recycled address would be rewritten by the root scanner and read +/// back by the next render as a different object. +/// +/// Runs in the exiting thread's TLS destructor: a plain poison-tolerant lock +/// (not `lock_gc_root_registry`, which touches a thread-local), and +/// `STATE_DIRTY` is deliberately left alone. +pub(crate) fn release_tui_hook_slots_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut slots = SLOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for slot in slots.iter_mut() { + match slot { + HookSlot::State { value_bits } => { + if freed.holds_bits(*value_bits) { + *value_bits = TAG_UNDEFINED; + } + } + HookSlot::Memo { + value_bits, + computed, + last_deps_hash: _, + } => { + if freed.holds_bits(*value_bits) { + *value_bits = TAG_UNDEFINED; + *computed = false; + } + } + HookSlot::Ref { + value_bits, + handle_bits, + } => { + if freed.holds_bits(*value_bits) { + *value_bits = TAG_UNDEFINED; + } + if freed.holds_bits(*handle_bits) { + *handle_bits = 0; + } + } + HookSlot::Effect { cleanup, .. } => { + if freed.holds_i64(*cleanup) { + *cleanup = 0; + } + } + HookSlot::Focus { .. } => {} + } + } +} + +/// Test probe (#11471): does any hook slot hold exactly `bits` as a value or +/// handle? +#[doc(hidden)] +pub fn tui_hook_slots_hold_bits_for_test(bits: u64) -> bool { + SLOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|slot| match slot { + HookSlot::State { value_bits } | HookSlot::Memo { value_bits, .. } => { + *value_bits == bits + } + HookSlot::Ref { + value_bits, + handle_bits, + } => *value_bits == bits || *handle_bits == bits, + HookSlot::Effect { .. } | HookSlot::Focus { .. } => false, + }) +} + #[cfg(test)] pub(crate) fn test_seed_hook_slot_roots(value_bits: u64) { let mut slots = crate::gc::lock_gc_root_registry(&SLOTS); diff --git a/crates/perry-runtime/src/tui/input.rs b/crates/perry-runtime/src/tui/input.rs index 45eb564127d..7300eaba0ac 100644 --- a/crates/perry-runtime/src/tui/input.rs +++ b/crates/perry-runtime/src/tui/input.rs @@ -184,6 +184,27 @@ pub(crate) fn scan_tui_input_handler_root_mut(visitor: &mut crate::gc::RuntimeRo visitor.visit_atomic_i64_slot(&INPUT_HANDLER, Ordering::Acquire, Ordering::Release); } +/// Thread-exit release (#11471): clear the published `useInput` handler if it +/// is a closure in the exiting thread's freed arena blocks. Nothing restricts +/// `js_perry_tui_use_input` to the render thread, and `drain_input` would +/// otherwise call through the dangling pointer once the address is reused. +/// A compare-exchange, so a handler published concurrently by a live thread +/// is never cleared. +pub(crate) fn release_tui_input_handler_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let current = INPUT_HANDLER.load(Ordering::Acquire); + if freed.holds_i64(current) { + let _ = INPUT_HANDLER.compare_exchange(current, 0, Ordering::AcqRel, Ordering::Acquire); + } +} + +/// Test probe (#11471): the currently published `useInput` handler word. +#[doc(hidden)] +pub fn tui_input_handler_for_test() -> i64 { + INPUT_HANDLER.load(Ordering::Acquire) +} + /// Register the user's `useInput` handler. Replaces any prior handler /// — v1 supports a single handler. #[no_mangle] diff --git a/crates/perry-runtime/src/tui/state.rs b/crates/perry-runtime/src/tui/state.rs index 40309bc72db..1699664ec3c 100644 --- a/crates/perry-runtime/src/tui/state.rs +++ b/crates/perry-runtime/src/tui/state.rs @@ -175,6 +175,38 @@ pub(super) fn state_set_by_id(id: i64, value: f64) { } } +/// Thread-exit release (#11471): reset to `undefined` every state slot whose +/// value lies in the exiting thread's freed arena blocks. Slots are addressed +/// by index, so they are cleared in place rather than removed. Nothing in +/// `perry/tui` restricts `state()` / `.set()` to the render thread, and a slot +/// left holding a recycled address would be rewritten by the root scanner and +/// returned by `.get()` as a different object. +/// +/// Runs in the exiting thread's TLS destructor: a plain poison-tolerant lock +/// (not `lock_gc_root_registry`, which touches a thread-local), and +/// `STATE_DIRTY` is deliberately left alone. +pub(crate) fn release_tui_state_slots_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut slots = SLOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for bits in slots.iter_mut() { + if freed.holds_bits(*bits) { + *bits = 0x7FFC_0000_0000_0001; // TAG_UNDEFINED + } + } +} + +/// Test probe (#11471): does any state slot hold exactly `bits`? +#[doc(hidden)] +pub fn tui_state_slots_hold_bits_for_test(bits: u64) -> bool { + SLOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&bits) +} + #[cfg(test)] pub(crate) fn test_reset_state_slots() { crate::gc::lock_gc_root_registry(&SLOTS).clear(); diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 06c9455036d..18b884d3cbc 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2989,6 +2989,63 @@ fn invalidate_representation_change_when(obj_addr: usize, feedback_on: bool) { } } +/// #11471: forget every observation whose object or shape address lies in an +/// exiting thread's arena. +/// +/// Sites are process-global and record raw `object_addr`/`shape_addr` from +/// whichever thread executes them; [`scan_typed_feedback_roots_mut`] roots +/// the shape addresses and the invalidation paths compare both by equality. +/// Left behind, later collections would mark/rewrite memory another arena +/// may have reused, and an unrelated object allocated there would read as +/// this site's observed receiver. Only the observation goes: the site, its +/// metadata, its counters and a `megamorphic` verdict (a statement about the +/// past, not an address) stay. +/// +/// Runs in the exiting thread's TLS destructor (see `arena::thread_exit`): +/// the plain registry lock, poison-tolerant — NOT `registry()`, whose +/// GC-root-lock depth is a thread-local. +pub(crate) fn release_typed_feedback_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut reg = REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for site in reg.sites.values_mut() { + site.observations + .retain(|obs| !freed.contains(obs.object_addr) && !freed.contains(obs.shape_addr)); + } +} + +/// Test probe (#11471): record an array observation of `addr` at `site_id`, +/// as an instrumented array site does (bypassing the env-var gate, which a +/// test process cannot flip after first read). +#[doc(hidden)] +pub fn observe_array_address_for_test(site_id: u64, addr: usize) { + observe( + site_id, + TypedFeedbackSiteKind::ArrayElement, + Observation { + source: ObservationSource::Array, + object_addr: addr, + shape_addr: 0, + key_hash: 0, + class_id: 0, + heap_type: 0, + aux: 0, + value_tag: 0, + }, + ); +} + +/// Test probe (#11471): how many observations site `site_id` holds. +#[doc(hidden)] +pub fn site_observation_count_for_test(site_id: u64) -> usize { + registry() + .sites + .get(&site_id) + .map_or(0, |site| site.observations.len()) +} + pub fn scan_typed_feedback_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let mut reg = registry(); for site in reg.sites.values_mut() { diff --git a/crates/perry-runtime/src/ui_text_registry.rs b/crates/perry-runtime/src/ui_text_registry.rs index 38305f8ddcc..24b23197e76 100644 --- a/crates/perry-runtime/src/ui_text_registry.rs +++ b/crates/perry-runtime/src/ui_text_registry.rs @@ -607,6 +607,48 @@ pub extern "C" fn perry_arkts_register_text_id(widget_handle: i64, id_handle: f6 } } +/// Thread-exit release (#11471): drop every `state` value and `ForEach` +/// binding that holds a value in the exiting thread's freed arena blocks. +/// Neither `js_state_set` nor `js_foreach_register` checks the calling thread, +/// so a worker can store its own heap value here; after its arena is recycled +/// the root scanner and `js_state_get` would see a different object. +/// +/// Runs in the exiting thread's TLS destructor: plain poison-tolerant locks +/// only (`lock_gc_root_registry` touches a thread-local depth counter). +pub(crate) fn release_ui_text_registry_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + if let Some(values) = STATE_VALUES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + { + values.retain(|_, value| !freed.holds_value(*value)); + } + if let Some(bindings_by_state) = FOREACH_REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + { + for bindings in bindings_by_state.values_mut() { + bindings.retain(|b| !freed.holds_value(b.render_closure)); + } + bindings_by_state.retain(|_, bindings| !bindings.is_empty()); + } +} + +/// Test probe (#11471): how many `ForEach` bindings are registered for +/// `synth_id`. +#[doc(hidden)] +pub fn foreach_binding_count_for_test(synth_id: &str) -> usize { + FOREACH_REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|m| m.get(synth_id)) + .map_or(0, Vec::len) +} + #[cfg(test)] pub(crate) fn test_clear_ui_text_registry_roots() { *crate::gc::lock_gc_root_registry(&STATE_VALUES) = None; diff --git a/crates/perry-runtime/src/v8.rs b/crates/perry-runtime/src/v8.rs index 5e8818209e2..72a002f66ad 100644 --- a/crates/perry-runtime/src/v8.rs +++ b/crates/perry-runtime/src/v8.rs @@ -366,6 +366,55 @@ pub fn scan_v8_promise_hook_roots_mut(visitor: &mut crate::gc::RuntimeRootVisito } } +/// #11471: disable every `v8.promiseHooks` record holding a callback from an +/// exiting thread's arena. `PROMISE_HOOKS` is process-global while the hook +/// can be installed from any thread, so a left-behind record would have every +/// other thread's promise machinery call a freed (or reused) closure, and the +/// root scanner visit it. The record stays in place (its stop function +/// captures its index) and is disabled exactly as `disable_promise_hook` does, +/// keeping `PROMISE_HOOKS_ACTIVE` consistent. +pub(crate) fn release_promise_hooks_in_freed_ranges( + freed: &crate::arena::thread_exit::FreedRanges, +) { + let mut hooks = PROMISE_HOOKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for record in hooks.iter_mut() { + let callbacks = record.callbacks; + let dead = [ + callbacks.init, + callbacks.before, + callbacks.after, + callbacks.settled, + ] + .iter() + .any(|callback| freed.contains(*callback as usize)); + if !dead { + continue; + } + if record.enabled && callbacks.has_any() { + PROMISE_HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + } + record.enabled = false; + record.callbacks = PromiseHookCallbacks::empty(); + } +} + +/// #11471 test probe: does any `v8.promiseHooks` record hold `callback`? +#[doc(hidden)] +pub fn promise_hook_callback_registered_for_test(callback: usize) -> bool { + PROMISE_HOOKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .any(|record| { + let c = record.callbacks; + [c.init, c.before, c.after, c.settled] + .iter() + .any(|p| *p as usize == callback) + }) +} + #[cfg(test)] pub fn reset_for_tests() { PROMISE_HOOKS diff --git a/crates/perry-stdlib/src/common/handle.rs b/crates/perry-stdlib/src/common/handle.rs index f6597237d42..7f9e33b08fe 100644 --- a/crates/perry-stdlib/src/common/handle.rs +++ b/crates/perry-stdlib/src/common/handle.rs @@ -111,6 +111,86 @@ fn publish_payload( handle } +/// #11471: `HANDLES` is process-global, but some payload types store JS values +/// (closure pointers, NaN-boxed values, promise pointers) of the thread that +/// created or mutated them. When that thread exits its arena is freed and a +/// surviving thread may reuse the addresses, while every thread's GC scanner +/// still walks those payloads through `for_each_handle_mut_of`. +/// +/// The registry is type-erased, so it cannot see inside a payload itself. A +/// payload type that holds GC values registers a releaser here (from a `Once` +/// on its own insert path, before its first GC value is stored); at thread exit +/// [`release_handle_payloads_in_freed_ranges`] hands every payload of that type +/// to it. The releaser neutralizes the slots that name freed memory and returns +/// `true` if the whole payload should instead be dropped (retired exactly like +/// `drop_handle`). A releaser runs inside the exiting thread's TLS destructor +/// with a `HANDLES` shard write-locked: it must not touch thread-locals, +/// allocate on the GC heap, call JS, or call back into this registry. +type ErasedPayloadReleaser = std::sync::Arc< + dyn Fn(&mut (dyn Any + Send + Sync), &perry_runtime::arena::thread_exit::FreedRanges) -> bool + + Send + + Sync, +>; + +static PAYLOAD_RELEASERS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +/// Register `release` for every `HANDLES` payload of type `T` (see +/// [`ErasedPayloadReleaser`]). Idempotent per type: the first registration wins. +pub fn register_handle_payload_releaser( + release: fn(&mut T, &perry_runtime::arena::thread_exit::FreedRanges) -> bool, +) { + static REGISTER_HOOK: std::sync::Once = std::sync::Once::new(); + REGISTER_HOOK.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_handle_payloads_in_freed_ranges, + ) + }); + let type_id = std::any::TypeId::of::(); + let mut releasers = PAYLOAD_RELEASERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if releasers.iter().any(|(existing, _)| *existing == type_id) { + return; + } + releasers.push(( + type_id, + std::sync::Arc::new(move |payload, freed| { + payload + .downcast_mut::() + .is_some_and(|payload| release(payload, freed)) + }), + )); +} + +/// Thread-exit hook (#11471): run each registered payload releaser over the +/// payloads of its type, then retire the payloads a releaser asked to drop. +/// Payload types with no releaser hold no GC value and are left alone. +fn release_handle_payloads_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + let releasers: Vec<(std::any::TypeId, ErasedPayloadReleaser)> = PAYLOAD_RELEASERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if releasers.is_empty() { + return; + } + let mut to_drop = Vec::new(); + for mut entry in HANDLES.iter_mut() { + let handle = *entry.key(); + let payload: &mut (dyn Any + Send + Sync) = entry.value_mut().as_mut(); + let type_id = (*payload).type_id(); + if let Some((_, release)) = releasers.iter().find(|(id, _)| *id == type_id) { + if release(payload, freed) { + to_drop.push(handle); + } + } + } + // Retired outside the iteration: `remove_payload` write-locks the shard. + for handle in to_drop { + drop(remove_payload(handle)); + } +} + /// Get a reference to a registered object and execute a closure with it. /// This is the safe way to access handle data without lifetime issues. pub fn with_handle R>( diff --git a/crates/perry-stdlib/src/crypto/hash_handles.rs b/crates/perry-stdlib/src/crypto/hash_handles.rs index 569820d69a5..29fac6f486a 100644 --- a/crates/perry-stdlib/src/crypto/hash_handles.rs +++ b/crates/perry-stdlib/src/crypto/hash_handles.rs @@ -30,6 +30,13 @@ struct CryptoDigestStream { } impl CryptoDigestStream { + fn holds_freed(&self, freed: &perry_runtime::arena::thread_exit::FreedRanges) -> bool { + self.listeners + .values() + .flatten() + .any(|cb| freed.holds_i64(*cb)) + } + fn scan_roots(&mut self, visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { for callbacks in self.listeners.values_mut() { for cb in callbacks { @@ -67,6 +74,24 @@ thread_local! { } fn ensure_crypto_stream_gc_scanner() { + // #11471: retire a Hash/Hmac whose stream listeners live in an exiting + // thread's arena (`HANDLES` is process-global). + static REGISTER_RELEASERS: std::sync::Once = std::sync::Once::new(); + REGISTER_RELEASERS.call_once(|| { + use crate::common::handle::register_handle_payload_releaser as register; + register::(|h, freed| { + h.stream + .get_mut() + .unwrap_or_else(|p| p.into_inner()) + .holds_freed(freed) + }); + register::(|h, freed| { + h.stream + .get_mut() + .unwrap_or_else(|p| p.into_inner()) + .holds_freed(freed) + }); + }); CRYPTO_STREAM_GC_REGISTERED.with(|registered| { if registered.get() { return; diff --git a/crates/perry-stdlib/src/domain.rs b/crates/perry-stdlib/src/domain.rs index ca0666bd68d..ca20eb4764f 100644 --- a/crates/perry-stdlib/src/domain.rs +++ b/crates/perry-stdlib/src/domain.rs @@ -108,6 +108,13 @@ thread_local! { } fn ensure_gc_scanner_registered() { + // #11471: retire a domain whose values live in an exiting thread's arena. + static REGISTER_RELEASER: std::sync::Once = std::sync::Once::new(); + REGISTER_RELEASER.call_once(|| { + crate::common::handle::register_handle_payload_releaser::( + release_domain_in_freed_ranges, + ) + }); DOMAIN_GC_REGISTERED.with(|registered| { if registered.get() { return; @@ -127,6 +134,19 @@ fn ensure_wrapper_closures_registered() { }); } +/// `HANDLES` payload releaser (#11471): see `register_handle_payload_releaser`. +fn release_domain_in_freed_ranges( + domain: &mut DomainHandle, + freed: &perry_runtime::arena::thread_exit::FreedRanges, +) -> bool { + domain + .listeners + .values() + .flatten() + .any(|v| freed.holds_value(*v)) + || domain.members.iter().any(|v| freed.holds_value(*v)) +} + fn scan_domain_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { for_each_handle_mut_of::(|domain| { for callbacks in domain.listeners.values_mut() { diff --git a/crates/perry-stdlib/src/events.rs b/crates/perry-stdlib/src/events.rs index f8c1bb8ca87..770e0a5f1ff 100644 --- a/crates/perry-stdlib/src/events.rs +++ b/crates/perry-stdlib/src/events.rs @@ -338,6 +338,14 @@ thread_local! { /// GC between `.on(...)` and the next `.emit(...)` would sweep the /// closure — same root cause as issue #35 for net.Socket listeners. fn ensure_gc_scanner_registered() { + // #11471: `HANDLES` is process-global; drop an emitter whose listeners / + // pending promises live in an exiting thread's arena. + static REGISTER_RELEASER: std::sync::Once = std::sync::Once::new(); + REGISTER_RELEASER.call_once(|| { + crate::common::handle::register_handle_payload_releaser::( + release_emitter_in_freed_ranges, + ) + }); EVENTS_GC_REGISTERED.with(|registered| { if registered.get() { return; @@ -350,6 +358,25 @@ fn ensure_gc_scanner_registered() { }); } +/// `HANDLES` payload releaser (#11471): an emitter holding any GC value in the +/// exiting thread's arena was used only by that thread, so it is retired whole. +fn release_emitter_in_freed_ranges( + emitter: &mut EventEmitterHandle, + freed: &perry_runtime::arena::thread_exit::FreedRanges, +) -> bool { + emitter + .events + .values() + .flatten() + .any(|l| freed.holds_i64(l.callback) || freed.holds_i64(l.raw_wrapper)) + || freed.holds_i64(emitter.async_resource_handle) + || emitter.pending_once_promises.values().flatten().any(|p| { + freed.contains(p.promise as usize) + || freed.holds_value(p.signal) + || freed.holds_i64(p.abort_listener) + }) +} + /// GC root scanner for EventEmitter listener closures and pending /// `events.once` promises. #[allow(dead_code)] diff --git a/crates/perry-stdlib/src/readline/mod.rs b/crates/perry-stdlib/src/readline/mod.rs index bdd6f0523bd..04561342956 100644 --- a/crates/perry-stdlib/src/readline/mod.rs +++ b/crates/perry-stdlib/src/readline/mod.rs @@ -258,6 +258,7 @@ thread_local! { } fn ensure_gc_scanner_registered() { + ensure_stdin_listener_thread_exit_hook_registered(); READLINE_GC_REGISTERED.with(|registered| { if registered.get() { return; @@ -270,6 +271,51 @@ fn ensure_gc_scanner_registered() { }); } +/// #11471: the shared stdin listener lists hold closure pointers of whichever +/// thread called `process.stdin.on(...)`. Both insert paths call this first. +fn ensure_stdin_listener_thread_exit_hook_registered() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + use perry_runtime::arena::thread_exit::register_thread_exit_range_hook as register; + register(release_stdin_listeners_in_freed_ranges) + }); +} + +/// Thread-exit hook (#11471): drop the exiting thread's stdin listener closures +/// as `removeListener` would (clearing the flowing / pull-mode mirrors when a +/// list empties). Process-global locks only, one at a time. +fn release_stdin_listeners_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + let prune = |list: &Mutex>| -> bool { + let mut list = list.lock().unwrap_or_else(|p| p.into_inner()); + let before = list.len(); + list.retain(|cb| !freed.holds_i64(*cb)); + list.len() != before && list.is_empty() + }; + if prune(&DATA_CALLBACKS) { + STDIN_DATA_FLOWING.store(false, Ordering::Release); + } + if prune(&READABLE_CALLBACKS) { + STDIN_PULL_MODE.store(false, Ordering::Release); + } + prune(&KEYPRESS_CALLBACKS); + prune(&STDIN_END_CALLBACKS); +} + +/// #11471 test probes: the readline test lock, and "is `cb` listening on `event`?". +#[cfg(test)] +pub(crate) fn thread_exit_test_guard() -> std::sync::MutexGuard<'static, ()> { + test_support::reset() +} +#[cfg(test)] +pub(crate) fn stdin_listener_registered_for_test(event: &str, cb: i64) -> bool { + let lists = [&DATA_CALLBACKS, &KEYPRESS_CALLBACKS, &READABLE_CALLBACKS]; + let i = ["data", "keypress", "readable"] + .iter() + .position(|e| *e == event); + let list = i.map_or(&STDIN_END_CALLBACKS, |i| lists[i]); + list.lock().unwrap().contains(&cb) +} + fn scan_readline_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { // Shared cross-thread stdin listener lists. Recover from a poisoned lock so // a rewrite is never silently skipped (a skipped rewrite = the stale-ref diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests.rs index 117f2e06351..285fb1c5148 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests.rs @@ -61,3 +61,11 @@ fn thread_exit_releases_the_threads_closure_side_table_entries() { "a dead thread's static-prototype entry outlived its heap" ); } + +// #11471: one file per audited group of process-global tables. +mod listeners_tests; +mod reactors_tests; +mod stdlib_misc_tests; +mod streams_tests; +mod symbols_tests; +mod ui_tests; diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs new file mode 100644 index 00000000000..88ce3e95417 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs @@ -0,0 +1,243 @@ +//! #11471 thread-exit regression tests (listeners group). +//! +//! Each test populates a process-global perry-runtime table from a thread +//! through the real entry point, proves the entry is live while the thread +//! runs, and asserts the thread's exit released it. + +use perry_runtime::gc::RuntimeHandleScope; +use perry_runtime::{ClosureHeader, JSValue, ObjectHeader}; + +extern "C" fn thunk1(_closure: *const ClosureHeader, _a: f64) -> f64 { + f64::from_bits(JSValue::undefined().bits()) +} + +extern "C" fn thunk2(_closure: *const ClosureHeader, _a: f64, _b: f64) -> f64 { + f64::from_bits(JSValue::undefined().bits()) +} + +fn pointer_value(ptr: *const u8) -> f64 { + f64::from_bits(JSValue::pointer(ptr).bits()) +} + +fn set_field(obj: *mut ObjectHeader, name: &str, value: f64) { + let key = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); + perry_runtime::js_object_set_field_by_name(obj, key, value); +} + +#[test] +fn thread_exit_disables_the_threads_v8_promise_hooks() { + let (callback, live) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(perry_runtime::closure::js_closure_alloc( + thunk1 as *const u8, + 0, + )); + let addr = || closure.get_raw_mut_ptr::() as usize; + let _stop = + perry_runtime::v8::js_v8_promise_hooks_on_settled(pointer_value(addr() as *const u8)); + let live = perry_runtime::v8::promise_hook_callback_registered_for_test(addr()); + (addr(), live) + }) + .join() + .unwrap(); + assert!(live, "the hook must be registered while its thread lives"); + assert!( + !perry_runtime::v8::promise_hook_callback_registered_for_test(callback), + "a dead thread's v8.promiseHooks callback outlived its heap" + ); +} + +#[test] +fn thread_exit_disables_the_threads_async_hooks_create_hook_record() { + let (callback, live) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(perry_runtime::closure::js_closure_alloc( + thunk1 as *const u8, + 0, + )); + let options = scope.root_raw_mut_ptr(perry_runtime::js_object_alloc(0, 1)); + let addr = || closure.get_raw_mut_ptr::() as usize; + set_field( + options.get_raw_mut_ptr::(), + "init", + pointer_value(addr() as *const u8), + ); + let _hook = perry_runtime::async_hooks::js_async_hooks_create_hook(pointer_value( + options.get_raw_mut_ptr::() as *const u8, + )); + let live = perry_runtime::async_hooks::hook_callback_registered_for_test(addr()); + (addr(), live) + }) + .join() + .unwrap(); + assert!( + live, + "the createHook record must exist while its thread lives" + ); + assert!( + !perry_runtime::async_hooks::hook_callback_registered_for_test(callback), + "a dead thread's createHook callback outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_async_hooks_resources_and_snapshots() { + // The ALS handle is only an id for the store entry. + const ALS_HANDLE: i64 = 0x1147_1001; + let (async_id, store_bits, live) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let resource = scope.root_raw_mut_ptr(perry_runtime::js_object_alloc(0, 0)); + let store = scope.root_raw_mut_ptr(perry_runtime::js_object_alloc(0, 0)); + let ids = perry_runtime::async_hooks::init_resource( + "PERRY_11471", + pointer_value(resource.get_raw_mut_ptr::() as *const u8), + true, + ); + perry_runtime::async_context::enter_with( + ALS_HANDLE, + pointer_value(store.get_raw_mut_ptr::() as *const u8), + ); + let _snapshot = + perry_runtime::async_hooks::js_async_local_storage_static_snapshot_direct(0); + let store_bits = + JSValue::pointer(store.get_raw_mut_ptr::() as *const u8).bits(); + let live = perry_runtime::async_hooks::resource_tracked_for_test(ids.async_id) + && perry_runtime::async_hooks::context_snapshot_holds_for_test(store_bits); + (ids.async_id, store_bits, live) + }) + .join() + .unwrap(); + assert!( + live, + "the resource and the snapshot must be registered while their thread lives" + ); + assert!( + !perry_runtime::async_hooks::resource_tracked_for_test(async_id), + "a dead thread's async resource outlived its heap" + ); + assert!( + !perry_runtime::async_hooks::context_snapshot_holds_for_test(store_bits), + "a dead thread's AsyncLocalStorage snapshot outlived its heap" + ); +} + +#[test] +fn thread_exit_clears_the_async_hooks_singletons_it_allocated() { + let (top, providers, live) = std::thread::spawn(|| { + perry_runtime::async_hooks::clear_cached_singletons_for_test(); + let top = perry_runtime::async_hooks::js_async_hooks_execution_async_resource().to_bits(); + let providers = perry_runtime::async_hooks::js_async_hooks_async_wrap_providers().to_bits(); + let cached = perry_runtime::async_hooks::cached_singletons_for_test(); + (top, providers, cached == (top, providers)) + }) + .join() + .unwrap(); + assert!( + live, + "the singletons must be cached while their thread lives" + ); + let (top_after, providers_after) = perry_runtime::async_hooks::cached_singletons_for_test(); + assert_ne!( + top_after, top, + "a dead thread's executionAsyncResource() object stayed cached" + ); + assert_ne!( + providers_after, providers, + "a dead thread's asyncWrapProviders object stayed cached" + ); +} + +/// The addresses the console.log singleton's root scanner reports right now, +/// read without allocating (a fresh allocation could land at the dead +/// thread's reused address and read as "still cached"). +fn console_log_singleton_roots() -> Vec { + let mut seen = Vec::new(); + perry_runtime::builtins::scan_console_log_singleton_roots(&mut |value| { + seen.push(value.to_bits() & 0x0000_FFFF_FFFF_FFFF) + }); + seen +} + +#[test] +fn thread_exit_clears_the_console_log_singleton_it_allocated() { + let (closure, live) = std::thread::spawn(|| { + let bits = perry_runtime::builtins::js_console_log_as_closure().to_bits(); + let closure = bits & 0x0000_FFFF_FFFF_FFFF; + (closure, console_log_singleton_roots().contains(&closure)) + }) + .join() + .unwrap(); + assert!(live, "the singleton must be cached while its thread lives"); + assert!( + !console_log_singleton_roots().contains(&closure), + "a dead thread's console.log closure stayed cached process-wide" + ); +} + +#[test] +fn thread_exit_clears_the_global_this_root_slot_it_wrote() { + // Every thread's first `globalThis` overwrites the shared slot, so a + // concurrent test can replace ours before we look; retry until one + // attempt observes its own write. + let mut observed = None; + for _ in 0..50 { + let (global, live) = std::thread::spawn(|| { + let value = perry_runtime::object::js_get_global_this(); + let raw = (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + ( + raw, + perry_runtime::object::global_this_root_slot_for_test() == raw, + ) + }) + .join() + .unwrap(); + if live { + observed = Some(global); + break; + } + } + let global = observed.expect("the root slot must name the thread's globalThis while it lives"); + assert_ne!( + perry_runtime::object::global_this_root_slot_for_test(), + global, + "a dead thread's globalThis stayed in the process-global root slot" + ); +} + +#[test] +fn thread_exit_releases_the_threads_tls_client_records() { + const HANDLE: i64 = 0x1147_1002; + let live = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(perry_runtime::closure::js_closure_alloc( + thunk2 as *const u8, + 0, + )); + let options = scope.root_raw_mut_ptr(perry_runtime::js_object_alloc(0, 1)); + set_field( + options.get_raw_mut_ptr::(), + "checkServerIdentity", + pointer_value(closure.get_raw_mut_ptr::() as *const u8), + ); + unsafe { + perry_runtime::tls::js_tls_client_record_start( + HANDLE, + pointer_value(options.get_raw_mut_ptr::() as *const u8), + std::ptr::null(), + 0, + ); + } + perry_runtime::tls::tls_client_metadata(HANDLE) + .is_some_and(|metadata| metadata.check_server_identity != 0) + }) + .join() + .unwrap(); + assert!( + live, + "the record must hold the thread's checkServerIdentity while it lives" + ); + assert!( + perry_runtime::tls::tls_client_metadata(HANDLE).is_none(), + "a dead thread's checkServerIdentity record outlived its heap" + ); +} diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/reactors_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/reactors_tests.rs new file mode 100644 index 00000000000..925035905e4 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/reactors_tests.rs @@ -0,0 +1,175 @@ +//! #11471 thread-exit regression tests (reactors group). +//! +//! Each test populates one process-global reactor table from a spawned +//! thread, proves the entry exists while that thread lives (so the absence +//! asserted afterwards is not vacuous), joins, and asserts the entry is gone. +//! +//! Not covered here, and why: +//! * `dgram_reactor::LIVE` and `dgram::DGRAM_REGISTRY` live behind +//! perry-runtime's `mod-dgram` feature, which perry-stdlib's test build does +//! not enable (and a test here cannot `cfg` on a dependency's feature). +//! * `timer::mock::MOCK_TIMERS` is reachable only through `node:test` mock +//! timers, whose `enable` is process-global: it would capture every +//! concurrently running test's `setTimeout`/`setInterval`. + +use perry_runtime::JSValue; + +/// A fresh arena object on the calling thread, NaN-boxed. +fn arena_object() -> f64 { + let arr = perry_runtime::js_array_alloc(0); + f64::from_bits(JSValue::pointer(arr as *const u8).bits()) +} + +#[test] +fn thread_exit_releases_the_threads_live_child_process_entries() { + use perry_runtime::child_process::reactor as cp; + let (handle, registered_while_alive) = std::thread::spawn(|| { + let handle = cp::cp_register_idle_live_child_for_test(arena_object()); + (handle, cp::cp_live_child_registered_for_test(handle)) + }) + .join() + .unwrap(); + assert!( + registered_while_alive, + "the child entry must exist while its thread lives" + ); + assert!( + !cp::cp_live_child_registered_for_test(handle), + "a dead thread's ChildProcess entry outlived its heap" + ); +} + +#[cfg(unix)] +#[test] +fn thread_exit_releases_the_threads_live_pty_entries() { + use perry_runtime::child_process::reactor::thread_exit_test_probes as probes; + let (handle, registered_while_alive) = std::thread::spawn(|| { + let handle = probes::pty_register_idle_live_for_test(arena_object()); + (handle, probes::pty_live_registered_for_test(handle)) + }) + .join() + .unwrap(); + assert!( + registered_while_alive, + "the pty entry must exist while its thread lives" + ); + assert!( + !probes::pty_live_registered_for_test(handle), + "a dead thread's IPty entry outlived its heap" + ); +} + +/// Range half: a token holding a value from the dead thread's arena (here an +/// attached handle) is released by the arena-drop hook, even on a thread with +/// no agent of its own. +#[test] +fn thread_exit_releases_native_async_tokens_holding_the_threads_values() { + use perry_runtime::promise::native_async as na; + let (promise, registered_while_alive) = std::thread::spawn(|| { + let token = na::js_native_async_completion_new(0); + let promise = na::js_native_async_completion_promise(token); + let status = na::js_native_async_completion_attach_handle( + token, + arena_object().to_bits(), + // Cleanup on success only: the token is never settled here. + na::PERRY_NATIVE_ASYNC_CLEANUP_ON_SUCCESS, + ); + assert_eq!(status, na::PERRY_NATIVE_ASYNC_OK); + ( + promise as usize, + na::native_async_promise_has_token(promise), + ) + }) + .join() + .unwrap(); + assert!( + registered_while_alive, + "the token must be registered while its thread lives" + ); + assert!( + !na::native_async_promise_has_token(promise as *mut _), + "a dead thread's native-async token outlived its heap" + ); +} + +/// Agent half: an idle token (its Promise is malloc-resident, so no arena +/// range names it) is released when its worker agent retires — otherwise it +/// would count in the keep-alive mirror forever. +#[test] +fn agent_retirement_releases_the_agents_idle_native_async_tokens() { + use perry_runtime::promise::native_async as na; + let (promise, registered_while_alive) = std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + let token = na::js_native_async_completion_new(0); + let promise = na::js_native_async_completion_promise(token); + let registered = na::native_async_promise_has_token(promise); + perry_runtime::agent::retire_agent(agent); + (promise as usize, registered) + }) + .join() + .unwrap(); + assert!( + registered_while_alive, + "the token must be registered while its agent lives" + ); + assert!( + !na::native_async_promise_has_token(promise as *mut _), + "a retired agent's idle native-async token kept the event loop alive" + ); +} + +/// A worker agent's `process.on(signal)` subscribes ITS loop; at retirement +/// the process-global slot must return to "not installed", or the next +/// listener on any other thread never subscribes. +#[cfg(unix)] +#[test] +fn agent_retirement_releases_the_agents_turnloop_signal_subscription() { + use perry_runtime::child_process::reactor::thread_exit_test_probes as probes; + // SIGUSR1: carried by turnloop, and not used by any other stdlib test. + const SIGNAL: &str = "SIGUSR1"; + let (agent, while_alive) = std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + probes::set_process_signal_listener_count_for_test(SIGNAL, 1); + let while_alive = probes::process_signal_subscription_for_test(SIGNAL); + perry_runtime::agent::retire_agent(agent); + (agent, while_alive) + }) + .join() + .unwrap(); + let (installed, on_turnloop, id, owner) = while_alive.expect("SIGUSR1 is a process signal"); + assert!( + installed && on_turnloop && id != 0 && owner == agent, + "the subscription must be on the worker's loop while it lives \ + (installed={installed} on_turnloop={on_turnloop} id={id} owner={owner} agent={agent})" + ); + let after = probes::process_signal_subscription_for_test(SIGNAL).unwrap(); + // Leave the slot clean for the rest of the process either way. + probes::set_process_signal_listener_count_for_test(SIGNAL, 0); + assert_eq!( + (after.0, after.1, after.2), + (false, false, 0), + "a retired agent's signal subscription was left installed" + ); +} + +#[test] +fn thread_exit_releases_the_threads_typed_feedback_observations() { + use perry_runtime::typed_feedback as tf; + const SITE: u64 = 0x1147_1000_0000_0001; + let observed_while_alive = std::thread::spawn(|| { + let arr = perry_runtime::js_array_alloc(0); + tf::observe_array_address_for_test(SITE, arr as usize); + tf::site_observation_count_for_test(SITE) + }) + .join() + .unwrap(); + assert_eq!( + observed_while_alive, 1, + "the observation must exist while its thread lives" + ); + assert_eq!( + tf::site_observation_count_for_test(SITE), + 0, + "a dead thread's typed-feedback observation outlived its heap" + ); +} diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/stdlib_misc_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/stdlib_misc_tests.rs new file mode 100644 index 00000000000..ad035a99c88 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/stdlib_misc_tests.rs @@ -0,0 +1,204 @@ +//! #11471 thread-exit regression tests (stdlib_misc group). +//! +//! Each test fills one process-global perry-stdlib table from a spawned +//! thread with values allocated in that thread's arena, proves the entry is +//! present while the thread lives, and asserts it is gone once the thread has +//! exited (its `Arena::drop` ran the table's thread-exit range hook). + +use perry_runtime::ClosureHeader; + +extern "C" fn probe_thunk(_closure: *const ClosureHeader) -> f64 { + 0.0 +} + +/// A closure allocated in the calling thread's arena, as a raw address. +fn closure_here() -> i64 { + perry_runtime::closure::js_closure_alloc(probe_thunk as *const u8, 0) as i64 +} + +#[cfg(all( + feature = "tls-runtime", + not(target_os = "ios"), + not(target_os = "android") +))] +#[test] +fn thread_exit_releases_the_threads_tls_server_listener_and_once_entries() { + use crate::tls::thread_exit_probe as tls; + let (id, cb, present) = std::thread::spawn(|| { + let sni = closure_here(); + let cb = closure_here(); + let id = tls::insert_server_for_test(sni, cb); + let present = tls::server_present(id) + && tls::listener_present(id, cb) + && tls::once_flag_present(id, cb); + (id, cb, present) + }) + .join() + .unwrap(); + assert!( + present, + "the TLS entries must exist while their thread lives" + ); + assert!( + !tls::server_present(id), + "a server whose SNICallback lived in a dead thread's heap outlived it" + ); + assert!( + !tls::listener_present(id, cb), + "a dead thread's TLS listener closure outlived its heap" + ); + assert!( + !tls::once_flag_present(id, cb), + "a dead thread's TLS once-flag closure outlived its heap" + ); +} + +#[cfg(feature = "database-sqlite")] +#[test] +fn thread_exit_releases_the_threads_node_sqlite_callbacks_and_authorizer() { + use crate::sqlite::thread_exit_probe as sq; + let (function, aggregate, state, db, present) = std::thread::spawn(|| { + let callbacks: Vec = (0..4) + .map(|_| perry_runtime::value::js_nanbox_pointer(closure_here())) + .collect(); + let function = sq::register_function_for_test(callbacks[0]); + let aggregate = sq::register_aggregate_for_test(callbacks[1]); + let state = sq::register_aggregate_state_for_test(callbacks[2]); + let db = sq::register_db_with_authorizer_for_test(callbacks[3]); + let present = sq::function_registered(function) + && sq::aggregate_registered(aggregate) + && sq::aggregate_state_registered(state) + && sq::authorizer(db).is_some(); + (function, aggregate, state, db, present) + }) + .join() + .unwrap(); + assert!( + present, + "the node:sqlite entries must exist while their thread lives" + ); + assert!( + !sq::function_registered(function), + "a dead thread's db.function() callback is still scanned" + ); + assert!( + !sq::aggregate_registered(aggregate), + "a dead thread's db.aggregate() callbacks are still scanned" + ); + assert!( + !sq::aggregate_state_registered(state), + "a dead thread's running aggregate state is still scanned" + ); + assert!( + sq::authorizer(db).is_none(), + "a dead thread's authorizer closure outlived its heap in HANDLES" + ); + sq::free_boxes_for_test(function, aggregate, state); + crate::common::drop_handle(db); +} + +#[cfg(feature = "crypto")] +#[test] +fn thread_exit_releases_the_threads_crypto_key_entries() { + extern "C" { + fn perry_test_11471_register_crypto_key() -> usize; + fn perry_test_11471_crypto_key_registered(buf_addr: usize) -> bool; + } + let (addr, present) = std::thread::spawn(|| unsafe { + let addr = perry_test_11471_register_crypto_key(); + (addr, perry_test_11471_crypto_key_registered(addr)) + }) + .join() + .unwrap(); + assert!( + present, + "the CryptoKey entry must exist while its thread lives" + ); + assert!( + !unsafe { perry_test_11471_crypto_key_registered(addr) }, + "a dead thread's CryptoKey entry outlived its Buffer" + ); +} + +#[test] +fn thread_exit_releases_the_threads_worker_records() { + use crate::worker_threads::thread_exit_probe as wt; + let (worker_id, present) = std::thread::spawn(|| { + let object = perry_runtime::object::js_object_alloc(0, 0); + let callback = perry_runtime::value::js_nanbox_pointer(closure_here()).to_bits(); + let object_bits = perry_runtime::JSValue::pointer(object as *const u8).bits(); + let worker_id = wt::insert_worker_for_test(object_bits, callback); + (worker_id, wt::worker_present(worker_id)) + }) + .join() + .unwrap(); + assert!( + present, + "the Worker record must exist while its thread lives" + ); + assert!( + !wt::worker_present(worker_id), + "a dead thread's Worker record outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_stdin_listeners() { + const EVENTS: [&str; 4] = ["data", "keypress", "readable", "end"]; + let _serial = crate::readline::thread_exit_test_guard(); + let (callbacks, present) = std::thread::spawn(|| { + let names: Vec<_> = EVENTS + .iter() + .map(|e| perry_runtime::string::js_string_from_bytes(e.as_ptr(), e.len() as u32)) + .collect(); + let callbacks: Vec = EVENTS.iter().map(|_| closure_here()).collect(); + for (name, cb) in names.iter().zip(&callbacks) { + crate::readline::js_readline_stdin_on(*name, *cb); + } + let present = EVENTS + .iter() + .zip(&callbacks) + .all(|(e, cb)| crate::readline::stdin_listener_registered_for_test(e, *cb)); + (callbacks, present) + }) + .join() + .unwrap(); + assert!( + present, + "the stdin listeners must exist while their thread lives" + ); + for (event, cb) in EVENTS.iter().zip(&callbacks) { + assert!( + !crate::readline::stdin_listener_registered_for_test(event, *cb), + "a dead thread's stdin '{event}' listener outlived its heap" + ); + } +} + +#[cfg(feature = "bundled-events")] +#[test] +fn thread_exit_retires_the_threads_event_emitter_payloads() { + use perry_runtime::JSValue; + let (handle, listeners_while_alive) = std::thread::spawn(|| unsafe { + let name = "__perry_11471_emitter_probe"; + let event = perry_runtime::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let event_bits = JSValue::string_ptr(event).bits() as i64; + let handle = crate::events::js_event_emitter_new(); + let listener_bits = + perry_runtime::value::js_nanbox_pointer(closure_here()).to_bits() as i64; + crate::events::js_event_emitter_on(handle, event_bits, listener_bits); + let all = JSValue::undefined().bits() as i64; + let count = crate::events::js_event_emitter_listener_count(handle, event_bits, all); + (handle, count) + }) + .join() + .unwrap(); + assert_eq!( + listeners_while_alive, 1.0, + "the emitter must hold the listener while its thread lives" + ); + assert!( + !crate::common::handle_exists(handle), + "a dead thread's EventEmitter payload (listener closure) outlived its heap" + ); +} diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/streams_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/streams_tests.rs new file mode 100644 index 00000000000..7de8477db62 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/streams_tests.rs @@ -0,0 +1,232 @@ +//! #11471 thread-exit regression tests (streams group). +//! +//! Each test populates a process-global Web Streams / zlib table from a +//! short-lived thread through the real FFI surface, reports whether the entry +//! was present while that thread lived (so the absence asserted after the +//! join is not vacuous), and checks the exiting thread's `Arena::drop` +//! released it. + +extern "C" fn probe_thunk(_closure: *const perry_runtime::ClosureHeader) -> f64 { + 0.0 +} + +fn undef() -> f64 { + f64::from_bits(0x7FFC_0000_0000_0001) +} + +/// A fresh closure on the calling thread's heap, NaN-boxed. +fn closure_value() -> f64 { + let closure = perry_runtime::closure::js_closure_alloc(probe_thunk as *const u8, 0); + f64::from_bits(perry_runtime::JSValue::pointer(closure as *const u8).bits()) +} + +fn string_value(s: &str) -> f64 { + let ptr = perry_runtime::js_string_from_bytes(s.as_ptr(), s.len() as u32); + f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) +} + +#[cfg(feature = "bundled-streams")] +mod web_streams { + use super::*; + use crate::streams as s; + + const READABLE: u32 = 1; + const WRITABLE: u32 = 2; + const TRANSFORM: u32 = 4; + const READER: u32 = 8; + const WRITER: u32 = 16; + + #[test] + fn thread_exit_releases_readable_streams_and_their_readers() { + let ((with_cb, linked, reader, plain), alive) = std::thread::spawn(|| unsafe { + // Seeded directly: holds the thread's cancel closure. + let with_cb = + s::js_readable_stream_new(undef(), undef(), closure_value(), f64::NAN) as usize; + // Holds nothing of the thread's heap itself; dies through the + // link to its reader, whose `closed` promise is the thread's. + let linked = s::js_readable_stream_new(undef(), undef(), undef(), f64::NAN) as usize; + let reader = s::js_readable_stream_get_reader(linked as f64) as usize; + // Holds no heap address and has no links: must survive. + let plain = s::js_readable_stream_new(undef(), undef(), undef(), f64::NAN) as usize; + let alive = s::stream_registry_presence_for_test(with_cb) == READABLE + && s::stream_registry_presence_for_test(linked) == READABLE + && s::stream_registry_presence_for_test(reader) == READER + && s::stream_registry_presence_for_test(plain) == READABLE; + ((with_cb, linked, reader, plain), alive) + }) + .join() + .unwrap(); + + assert!(alive, "the records must exist while their thread lives"); + assert_eq!( + s::stream_registry_presence_for_test(with_cb), + 0, + "a dead thread's readable (cancel closure) outlived its heap" + ); + assert_eq!( + s::stream_registry_presence_for_test(reader), + 0, + "a dead thread's reader (closed promise) outlived its heap" + ); + assert_eq!( + s::stream_registry_presence_for_test(linked), + 0, + "a reader's stream must go with it, or the reader id dangles" + ); + assert_eq!( + s::stream_registry_presence_for_test(plain), + READABLE, + "a record holding no freed address must be left alone" + ); + } + + #[test] + fn thread_exit_releases_writable_streams_and_their_writers() { + let ((writable, writer), alive) = std::thread::spawn(|| unsafe { + let writable = + s::js_writable_stream_new(undef(), closure_value(), undef(), undef(), f64::NAN) + as usize; + let writer = s::js_writable_stream_get_writer(writable as f64) as usize; + let alive = s::stream_registry_presence_for_test(writable) == WRITABLE + && s::stream_registry_presence_for_test(writer) == WRITER; + ((writable, writer), alive) + }) + .join() + .unwrap(); + + assert!(alive, "the records must exist while their thread lives"); + assert_eq!( + s::stream_registry_presence_for_test(writable), + 0, + "a dead thread's writable (callbacks, promises) outlived its heap" + ); + assert_eq!( + s::stream_registry_presence_for_test(writer), + 0, + "a dead thread's writer outlived its heap" + ); + } + + #[test] + fn thread_exit_releases_transform_streams_and_their_side_tables() { + let ((transform, readable, writable), alive) = std::thread::spawn(|| unsafe { + let transform = + s::js_transform_stream_new(undef(), closure_value(), undef(), undef(), undef()); + let readable = s::js_transform_stream_readable(transform) as usize; + let writable = s::js_transform_stream_writable(transform) as usize; + // readableStrategy undefined => highWaterMark 0 => initial + // backpressure: the write job parks in TRANSFORM_BACKPRESSURED_JOBS + // (keyed by the readable) and counts in TRANSFORM_PENDING_WRITES. + let writer = s::js_writable_stream_get_writer(writable as f64); + s::js_writer_write(writer, string_value("chunk-11471")); + let transform = transform as usize; + let alive = s::stream_registry_presence_for_test(transform) == TRANSFORM + && s::stream_registry_presence_for_test(readable) == READABLE + && s::stream_registry_presence_for_test(writable) == WRITABLE + && s::transform_side_tables_hold_for_test(writable) + && s::transform_side_tables_hold_for_test(readable); + ((transform, readable, writable), alive) + }) + .join() + .unwrap(); + + assert!(alive, "the records must exist while their thread lives"); + for (id, what) in [ + (transform, "transform"), + (readable, "readable side"), + (writable, "writable side"), + ] { + assert_eq!( + s::stream_registry_presence_for_test(id), + 0, + "a dead thread's {what} outlived its heap" + ); + assert!( + !s::transform_side_tables_hold_for_test(id), + "a dead thread's transform side-table entry for its {what} outlived its heap" + ); + } + } + + #[test] + fn thread_exit_releases_parked_byob_reads() { + let (stream, alive) = std::thread::spawn(|| unsafe { + let stream = s::js_readable_stream_new_with_source_type( + undef(), + undef(), + undef(), + f64::NAN, + string_value("bytes"), + ); + let reader = s::js_readable_stream_get_byob_reader(stream); + let view = perry_runtime::buffer::js_uint8array_alloc(8); + let view = f64::from_bits(perry_runtime::JSValue::pointer(view as *const u8).bits()); + s::js_reader_read_with_view(reader, view); + let stream = stream as usize; + (stream, s::byob_pending_for_test(stream)) + }) + .join() + .unwrap(); + + assert!(alive, "the BYOB read must be parked while its thread lives"); + assert!( + !s::byob_pending_for_test(stream), + "a dead thread's parked BYOB read (promise, view) outlived its heap" + ); + assert_eq!(s::stream_registry_presence_for_test(stream), 0); + } + + #[test] + fn thread_exit_releases_stream_expando_values() { + const KEY: &str = "__perry_11471_expando_probe"; + let (stream, alive) = std::thread::spawn(|| unsafe { + let stream = s::js_readable_stream_new(undef(), undef(), undef(), f64::NAN) as usize; + let value = f64::from_bits( + perry_runtime::JSValue::pointer(perry_runtime::js_array_alloc(0) as *const u8) + .bits(), + ); + s::stream_expando_set_hook(stream, KEY.as_ptr(), KEY.len(), value); + (stream, s::stream_expando_get(stream, KEY).is_some()) + }) + .join() + .unwrap(); + + assert!(alive, "the expando must exist while its thread lives"); + assert!( + s::stream_expando_get(stream, KEY).is_none(), + "a dead thread's expando value outlived its heap" + ); + assert_eq!( + s::stream_registry_presence_for_test(stream), + READABLE, + "only the offending property goes, not a stream holding nothing freed" + ); + } +} + +#[cfg(feature = "compression-gzip")] +#[test] +fn thread_exit_releases_zlib_streams_listeners_and_events() { + use crate::zlib as z; + let (handle, alive) = std::thread::spawn(|| unsafe { + let handle = z::js_zlib_create_gzip(undef()); + let cb = perry_runtime::js_nanbox_get_pointer(closure_value()); + z::zlib_stream_on(handle, string_value("data"), cb); + // `.end()` queues the stream's Data/End events on the global queue. + z::zlib_stream_end(handle, undef()); + let (stream, listeners, events) = z::zlib_tables_for_test(handle); + (handle, stream && listeners == 1 && events > 0) + }) + .join() + .unwrap(); + + assert!( + alive, + "the zlib records must exist while their thread lives" + ); + assert_eq!( + z::zlib_tables_for_test(handle), + (false, 0, 0), + "a dead thread's zlib stream, listener or queued events outlived its heap" + ); +} diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs new file mode 100644 index 00000000000..10739032d62 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -0,0 +1,351 @@ +//! #11471 thread-exit regression tests (symbols group). +//! +//! Each test populates one of perry-runtime's process-global address-keyed +//! tables from a short-lived thread through the real entry points, proves the +//! entry exists while the thread lives, joins, and asserts the thread's arena +//! release dropped it. Addresses are re-read through handles at the end of the +//! thread body because an allocation may have moved an owner, and the tables +//! follow a moved owner to its new key. + +use perry_runtime::gc::RuntimeHandleScope; +use perry_runtime::value::{js_nanbox_pointer, js_nanbox_string}; + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; +const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; +const ADDR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +extern "C" { + fn js_buffer_mark_as_crypto_key_external( + addr: usize, + algo: u8, + hash: u8, + kind: u8, + extractable: u8, + usages: u32, + bit_length: u32, + ); + fn perry_thread_exit_probe_object_prototype_recorded(owner: usize) -> bool; + fn js_u8_buffer_read_f64(target: *const u8, index: i32) -> f64; + static PERRY_U8_INLINE_CACHE: [std::sync::atomic::AtomicU64; 64]; +} + +extern "C" fn probe_thunk(_closure: *const perry_runtime::ClosureHeader) -> f64 { + 0.0 +} + +fn undefined() -> f64 { + f64::from_bits(TAG_UNDEFINED) +} + +fn string_value(text: &str) -> f64 { + let s = perry_runtime::js_string_from_bytes(text.as_ptr(), text.len() as u32); + js_nanbox_string(s as i64) +} + +fn key(text: &str) -> *const perry_runtime::StringHeader { + perry_runtime::js_string_from_bytes(text.as_ptr(), text.len() as u32) +} + +fn addr_of(value: f64) -> usize { + (value.to_bits() & ADDR_MASK) as usize +} + +fn u8_cache_holds(addr: usize) -> bool { + let slot = (addr >> 3) & 63; + unsafe { PERRY_U8_INLINE_CACHE[slot].load(std::sync::atomic::Ordering::Relaxed) == addr as u64 } +} + +#[test] +fn thread_exit_releases_the_threads_symbol_side_table_entries() { + const STATIC_SYMBOL_CLASS: u32 = 0x0B11_4711; + let ((owner, sym), alive) = std::thread::spawn(|| { + use perry_runtime::symbol as s; + let scope = RuntimeHandleScope::new(); + let sym = scope.root_nanbox_f64(unsafe { s::js_symbol_new(string_value("t11471")) }); + let obj = scope.root_raw_mut_ptr(perry_runtime::object::js_object_alloc(0, 0)); + let obj_value = || js_nanbox_pointer(obj.get_raw_mut_ptr::() as i64); + let value = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let value_value = || js_nanbox_pointer(value.get_raw_mut_ptr::() as i64); + // obj[sym] = [] (SYMBOL_PROPERTIES). + unsafe { + s::js_object_set_symbol_property(obj_value(), sym.get_nanbox_f64(), value_value()) + }; + // Object.defineProperty(obj, sym2, { value: [], writable: false }) + // (SYMBOL_PROPERTY_ATTRS). + let sym2 = scope.root_nanbox_f64(unsafe { s::js_symbol_new(string_value("t11471b")) }); + let desc = scope.root_raw_mut_ptr(perry_runtime::object::js_object_alloc(0, 0)); + perry_runtime::js_object_set_field_by_name( + desc.get_raw_mut_ptr(), + key("value"), + value_value(), + ); + perry_runtime::js_object_set_field_by_name( + desc.get_raw_mut_ptr(), + key("writable"), + f64::from_bits(TAG_FALSE), + ); + perry_runtime::object::js_object_define_property( + obj_value(), + sym2.get_nanbox_f64(), + js_nanbox_pointer(desc.get_raw_mut_ptr::() as i64), + ); + // Object.defineProperty(obj, sym3, { get }) (SYMBOL_ACCESSOR_PROPERTIES). + let sym3 = scope.root_nanbox_f64(unsafe { s::js_symbol_new(string_value("t11471c")) }); + let getter = scope.root_raw_mut_ptr(perry_runtime::closure::js_closure_alloc( + probe_thunk as *const u8, + 0, + )); + let accessor = scope.root_raw_mut_ptr(perry_runtime::object::js_object_alloc(0, 0)); + perry_runtime::js_object_set_field_by_name( + accessor.get_raw_mut_ptr(), + key("get"), + js_nanbox_pointer(getter.get_raw_mut_ptr::() as i64), + ); + perry_runtime::object::js_object_define_property( + obj_value(), + sym3.get_nanbox_f64(), + js_nanbox_pointer(accessor.get_raw_mut_ptr::() as i64), + ); + // static [sym] = [] on a (process-global) class id (CLASS_STATIC_SYMBOLS). + unsafe { + s::js_class_register_static_symbol( + STATIC_SYMBOL_CLASS, + sym.get_nanbox_f64(), + value_value(), + ) + }; + + let owner = obj.get_raw_mut_ptr::() as usize; + let (sym, sym2, sym3) = ( + addr_of(sym.get_nanbox_f64()), + addr_of(sym2.get_nanbox_f64()), + addr_of(sym3.get_nanbox_f64()), + ); + let alive = [ + s::symbol_property_tables_hold_for_test(owner, sym).0, + s::symbol_property_tables_hold_for_test(owner, sym2).1, + s::symbol_accessor_held_for_test(owner, sym3), + s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym), + ]; + ((owner, [sym, sym2, sym3]), alive) + }) + .join() + .unwrap(); + + use perry_runtime::symbol as s; + assert_eq!( + alive, [true; 4], + "every entry must exist while its thread lives" + ); + assert!( + !s::symbol_property_tables_hold_for_test(owner, sym[0]).0, + "a dead thread's obj[sym] record outlived its heap" + ); + assert!( + !s::symbol_property_tables_hold_for_test(owner, sym[1]).1, + "a dead thread's symbol property attrs outlived its heap" + ); + assert!( + !s::symbol_accessor_held_for_test(owner, sym[2]), + "a dead thread's symbol accessor outlived its heap" + ); + assert!( + !s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym[0]), + "a dead thread's class-static symbol member outlived its heap" + ); +} + +/// `SYMBOL_POINTERS`: `Symbol()` headers are `gc_malloc`'d, not arena cells; +/// `MallocState`'s TLS destructor (`gc/malloc.rs`) reports their freed ranges +/// to `arena::thread_exit::release_freed_ranges` before freeing them. +#[test] +fn thread_exit_releases_the_threads_fresh_symbol_pointers() { + let (sym, alive) = std::thread::spawn(|| { + let sym = addr_of(unsafe { perry_runtime::symbol::js_symbol_new(string_value("t11471p")) }); + ( + sym, + perry_runtime::symbol::symbol_pointer_registered_for_test(sym), + ) + }) + .join() + .unwrap(); + assert!(alive, "Symbol() must be registered while its thread lives"); + assert!( + !perry_runtime::symbol::symbol_pointer_registered_for_test(sym), + "a dead thread's Symbol() stayed in SYMBOL_POINTERS" + ); +} + +#[test] +fn thread_exit_releases_the_threads_residual_prototype_entry() { + let (owner, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let arr = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let proto = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + perry_runtime::object::js_object_set_prototype_of( + js_nanbox_pointer(arr.get_raw_mut_ptr::() as i64), + js_nanbox_pointer(proto.get_raw_mut_ptr::() as i64), + ); + let owner = arr.get_raw_mut_ptr::() as usize; + (owner, unsafe { + perry_thread_exit_probe_object_prototype_recorded(owner) + }) + }) + .join() + .unwrap(); + assert!( + alive, + "Object.setPrototypeOf(array) must record a residual entry" + ); + assert!( + !unsafe { perry_thread_exit_probe_object_prototype_recorded(owner) }, + "a dead thread's residual prototype entry outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_buffer_own_props() { + const PROP: &str = "__perry_11471_buffer_own_prop"; + let (addr, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let buf = scope.root_raw_mut_ptr(perry_runtime::buffer::js_buffer_alloc(4, 0)); + let value = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let v = js_nanbox_pointer(value.get_raw_mut_ptr::() as i64); + let addr = buf.get_raw_mut_ptr::() as usize; + perry_runtime::buffer::buffer_set_own_prop(addr, PROP, v); + ( + addr, + perry_runtime::buffer::buffer_get_own_prop(addr, PROP).is_some(), + ) + }) + .join() + .unwrap(); + assert!(alive, "the expando must exist while its thread lives"); + assert!( + perry_runtime::buffer::buffer_get_own_prop(addr, PROP).is_none(), + "a dead thread's buffer expando outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_external_buffer_registrations() { + let (addr, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let buf = scope.root_raw_mut_ptr(perry_runtime::buffer::js_buffer_alloc(16, 0)); + let addr = buf.get_raw_mut_ptr::() as usize; + // webcrypto's CryptoKey registration: all three external registries. + unsafe { js_buffer_mark_as_crypto_key_external(addr, 1, 0, 1, 1, 0, 0) }; + // The codegen inline-read slow arm primes PERRY_U8_INLINE_CACHE. + unsafe { js_u8_buffer_read_f64(addr as *const u8, 0) }; + let cached = u8_cache_holds(addr); + let alive = [ + perry_runtime::buffer::is_external_buffer(addr), + perry_runtime::buffer::is_uint8array_buffer(addr), + perry_runtime::buffer::crypto_key_meta(addr).is_some(), + cached, + ]; + (addr, alive) + }) + .join() + .unwrap(); + assert_eq!( + alive, [true; 4], + "every registration must exist while its thread lives" + ); + assert!( + !perry_runtime::buffer::is_external_buffer(addr), + "EXTERNAL_BUFFER_REGISTRY outlived the thread" + ); + assert!( + !perry_runtime::buffer::is_uint8array_buffer(addr), + "EXTERNAL_UINT8ARRAY_REGISTRY outlived the thread" + ); + assert!( + perry_runtime::buffer::crypto_key_meta(addr).is_none(), + "EXTERNAL_CRYPTO_KEY_META_REGISTRY outlived the thread" + ); + assert!( + !u8_cache_holds(addr), + "PERRY_U8_INLINE_CACHE outlived the thread" + ); +} + +#[test] +fn thread_exit_releases_the_threads_dom_exceptions() { + let (err, alive) = std::thread::spawn(|| { + let err = + perry_runtime::event_target::js_dom_exception_new(undefined(), undefined()) as usize; + ( + err, + perry_runtime::event_target::dom_exception_error_registered_for_test(err), + ) + }) + .join() + .unwrap(); + assert!( + alive, + "new DOMException() must be recorded while its thread lives" + ); + assert!( + !perry_runtime::event_target::dom_exception_error_registered_for_test(err), + "a dead thread's DOMException address outlived its heap" + ); +} + +/// `VM_SCRIPTS` only: `VM_COMPILED_FUNCTION_SOURCES` is filled by +/// `vm.compileFunction`, which needs perry-runtime's `dyn-eval` feature, off in +/// this crate's test build (`default-features = false`). The same hook clears +/// both maps by the same owner-address rule. +#[test] +fn thread_exit_releases_the_threads_vm_script_entry() { + let (script, alive) = std::thread::spawn(|| { + use perry_runtime::node_vm as vm; + let scope = RuntimeHandleScope::new(); + let script = + scope.root_nanbox_f64(vm::js_vm_create_script(string_value("1 + 1"), undefined())); + let script = addr_of(script.get_nanbox_f64()); + (script, vm::vm_owner_entries_for_test(script).0) + }) + .join() + .unwrap(); + assert!( + alive, + "new vm.Script() must be recorded while its thread lives" + ); + assert!( + !perry_runtime::node_vm::vm_owner_entries_for_test(script).0, + "a dead thread's vm.Script entry outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_message_ports() { + let (ports, alive) = std::thread::spawn(|| { + use perry_runtime::messaging as m; + let scope = RuntimeHandleScope::new(); + let channel = scope.root_nanbox_f64(m::same_thread_message_channel_new_for_test()); + let port = |name: &str| { + let obj = addr_of(channel.get_nanbox_f64()) as *const perry_runtime::ObjectHeader; + addr_of(perry_runtime::object::js_object_get_field_by_name_f64( + obj, + key(name), + )) + }; + let ports = [port("port1"), port("port2")]; + let alive = ports.map(m::port_state_registered_for_test); + (ports, alive) + }) + .join() + .unwrap(); + assert_eq!( + alive, + [true, true], + "both ports must be registered while their thread lives" + ); + for port in ports { + assert!( + !perry_runtime::messaging::port_state_registered_for_test(port), + "a dead thread's MessagePort state outlived its heap" + ); + } +} diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/ui_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/ui_tests.rs new file mode 100644 index 00000000000..6099eb6e4aa --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/ui_tests.rs @@ -0,0 +1,219 @@ +//! #11471 thread-exit regression tests (ui group). +//! +//! perry/ui, perry/tui, onFrame and geisterhand keep process-global tables of +//! NaN-boxed JS values and raw closure pointers. None of their entry points +//! is restricted to the main thread, so a thread can leave its own heap +//! values there; each test checks that the thread's exit takes them out. +//! +//! Not covered here: `plugin::REGISTRY` (perry-runtime feature `full`, which +//! perry-stdlib's default test build does not enable) and +//! `media_playback::MEDIA_STATE_INBOX` (feature `ohos-napi`, HarmonyOS only). + +use perry_runtime::closure::js_closure_alloc; +use perry_runtime::gc::RuntimeHandleScope; +use perry_runtime::{ArrayHeader, ClosureHeader, JSValue}; + +extern "C" fn thunk0(_closure: *const ClosureHeader) -> f64 { + 0.0 +} + +extern "C" fn thunk2(_closure: *const ClosureHeader, _a: f64, _b: f64) -> f64 { + 0.0 +} + +const UNDEFINED_BITS: u64 = 0x7FFC_0000_0000_0001; + +fn string_value(s: &str) -> f64 { + let ptr = perry_runtime::js_string_from_bytes(s.as_ptr(), s.len() as u32); + f64::from_bits(JSValue::string_ptr(ptr).bits()) +} + +fn closure_bits(closure: *const ClosureHeader) -> u64 { + JSValue::pointer(closure as *const u8).bits() +} + +fn array_bits(array: *const ArrayHeader) -> u64 { + JSValue::pointer(array as *const u8).bits() +} + +#[test] +fn thread_exit_releases_the_threads_geisterhand_callbacks_and_queued_actions() { + use perry_runtime::geisterhand_registry as g; + const HANDLE: i64 = 0x1147_1001; + + let (queued_arg, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(js_closure_alloc(thunk0 as *const u8, 0)); + let arg = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let closure_f64 = f64::from_bits(closure_bits(closure.get_raw_mut_ptr::())); + let arg_f64 = f64::from_bits(array_bits(arg.get_raw_mut_ptr::())); + g::perry_geisterhand_register( + HANDLE, + g::WIDGET_BUTTON, + g::CB_ON_CLICK, + closure_f64, + std::ptr::null(), + ); + g::perry_geisterhand_queue_action1(closure_f64, arg_f64); + let alive = g::perry_geisterhand_get_closure(HANDLE, g::CB_ON_CLICK).to_bits() + == closure_f64.to_bits() + && g::geisterhand_pending_holds_value_for_test(arg_f64); + (arg_f64, alive) + }) + .join() + .unwrap(); + + assert!(alive, "the entries must exist while their thread lives"); + assert_eq!( + g::perry_geisterhand_get_closure(HANDLE, g::CB_ON_CLICK).to_bits(), + 0.0f64.to_bits(), + "a dead thread's widget callback outlived its heap" + ); + assert!( + !g::geisterhand_pending_holds_value_for_test(queued_arg), + "a dead thread's queued action outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_ui_state_values_and_foreach_bindings() { + use perry_runtime::ui_text_registry as u; + const STATE_ID: &str = "__perry_11471_ui_state_probe"; + const FOREACH_ID: &str = "__perry_11471_ui_foreach_probe"; + + let alive = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(js_closure_alloc(thunk0 as *const u8, 0)); + let value = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let value_bits = array_bits(value.get_raw_mut_ptr::()); + let closure_f64 = f64::from_bits(closure_bits(closure.get_raw_mut_ptr::())); + u::js_state_init(string_value(STATE_ID), f64::from_bits(value_bits)); + // No render handler is registered in this process, so this only records. + u::js_foreach_register(string_value(FOREACH_ID), 0x1147_1002, closure_f64); + let state_bits = u::js_state_get(string_value(STATE_ID)).to_bits(); + (state_bits >> 48) == 0x7FFD && u::foreach_binding_count_for_test(FOREACH_ID) == 1 + }) + .join() + .unwrap(); + + assert!(alive, "the entries must exist while their thread lives"); + assert_eq!( + u::js_state_get(string_value(STATE_ID)).to_bits(), + UNDEFINED_BITS, + "a dead thread's perry/ui state value outlived its heap" + ); + assert_eq!( + u::foreach_binding_count_for_test(FOREACH_ID), + 0, + "a dead thread's ForEach render closure outlived its heap" + ); +} + +#[test] +fn thread_exit_releases_the_threads_frame_callbacks_and_last_fire_entries() { + use perry_runtime::frame as f; + + let (id, closure_addr, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(js_closure_alloc(thunk2 as *const u8, 0)); + let addr = || closure.get_raw_mut_ptr::() as i64; + // Fire once so LAST_FIRE_BY_CLOSURE records this closure, then queue + // it again so a pending callback is left behind. + f::js_on_frame_callback(addr()); + f::js_frame_tick(16.0); + let id = f::js_on_frame_callback(addr()); + let alive = + f::frame_callback_pending_for_test(id) && f::frame_last_fire_recorded_for_test(addr()); + (id, addr(), alive) + }) + .join() + .unwrap(); + + assert!(alive, "the entries must exist while their thread lives"); + assert!( + !f::frame_callback_pending_for_test(id), + "a dead thread's onFrame callback outlived its heap" + ); + assert!( + !f::frame_last_fire_recorded_for_test(closure_addr), + "a dead thread's last-fire entry outlived its heap" + ); +} + +#[test] +fn thread_exit_clears_the_threads_tui_input_handler() { + use perry_runtime::tui::input as i; + + let (handler, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(js_closure_alloc(thunk0 as *const u8, 0)); + let handler = closure.get_raw_mut_ptr::() as i64; + i::js_perry_tui_use_input(handler); + (handler, i::tui_input_handler_for_test() == handler) + }) + .join() + .unwrap(); + + assert!( + alive, + "the handler must be published while its thread lives" + ); + assert_ne!( + i::tui_input_handler_for_test(), + handler, + "a dead thread's useInput handler outlived its heap" + ); +} + +#[test] +fn thread_exit_resets_the_threads_tui_state_slots() { + use perry_runtime::tui::state as s; + + let (bits, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let value = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let handle = s::js_perry_tui_state_alloc(f64::from_bits(array_bits( + value.get_raw_mut_ptr::(), + ))); + // Read back through the handle: the handle allocation may have moved + // the value, and the slot follows it. + let bits = s::js_perry_tui_state_get(handle).to_bits(); + let alive = (bits >> 48) == 0x7FFD && s::tui_state_slots_hold_bits_for_test(bits); + (bits, alive) + }) + .join() + .unwrap(); + + assert!(alive, "the slot must hold the value while its thread lives"); + assert!( + !s::tui_state_slots_hold_bits_for_test(bits), + "a dead thread's perry/tui state value outlived its heap" + ); +} + +#[test] +fn thread_exit_resets_the_threads_tui_hook_slots() { + use perry_runtime::tui::hooks as h; + + let (bits, alive) = std::thread::spawn(|| { + let scope = RuntimeHandleScope::new(); + let value = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); + let bits = h::js_perry_tui_use_state(f64::from_bits(array_bits( + value.get_raw_mut_ptr::(), + ))) + .to_bits(); + let alive = (bits >> 48) == 0x7FFD && h::tui_hook_slots_hold_bits_for_test(bits); + (bits, alive) + }) + .join() + .unwrap(); + + assert!( + alive, + "the hook slot must hold the value while its thread lives" + ); + assert!( + !h::tui_hook_slots_hold_bits_for_test(bits), + "a dead thread's useState value outlived its heap" + ); +} diff --git a/crates/perry-stdlib/src/sqlite.rs b/crates/perry-stdlib/src/sqlite.rs index e0150842ade..94371b632c2 100644 --- a/crates/perry-stdlib/src/sqlite.rs +++ b/crates/perry-stdlib/src/sqlite.rs @@ -245,6 +245,7 @@ pub(crate) fn node_sqlite_active_aggregates() -> &'static Mutex> } pub(crate) fn ensure_node_sqlite_gc_scanner_registered() { + ensure_node_sqlite_thread_exit_hooks_registered(); NODE_SQLITE_GC_SCANNER.with(|registered| { if registered.get() { return; @@ -257,6 +258,202 @@ pub(crate) fn ensure_node_sqlite_gc_scanner_registered() { }); } +/// #11471: the three node:sqlite sets and `NodeSqliteDbHandle::authorizer_callback` +/// hold JS values of the thread that called `db.function()` / `db.aggregate()` / +/// `db.setAuthorizer()`. Registered before any of them is filled: every insert +/// path runs `ensure_node_sqlite_gc_scanner_registered` first. +fn ensure_node_sqlite_thread_exit_hooks_registered() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_node_sqlite_callbacks_in_freed_ranges, + ); + crate::common::handle::register_handle_payload_releaser::( + release_node_sqlite_authorizer_in_freed_ranges, + ); + }); +} + +/// Thread-exit hook (#11471). The boxes named by the three sets are owned by +/// SQLite (freed by xDestroy / xFinal, which tolerate an already-unregistered +/// pointer), so this never frees them: it unregisters every box holding a +/// value in the dying thread's arena (so no surviving thread's scanner visits +/// it) and overwrites those values with `undefined`, so a late SQLite call +/// throws "value is not a function" instead of jumping into reused memory. +fn release_node_sqlite_callbacks_in_freed_ranges( + freed: &perry_runtime::arena::thread_exit::FreedRanges, +) { + fn scrub(slot: &mut f64, freed: &perry_runtime::arena::thread_exit::FreedRanges) -> bool { + if freed.holds_value(*slot) { + *slot = f64::from_bits(TAG_UNDEFINED_BITS); + true + } else { + false + } + } + if let Some(functions) = NODE_SQLITE_CUSTOM_FUNCTIONS.get() { + let mut functions = functions.lock().unwrap_or_else(|p| p.into_inner()); + functions.retain(|raw| { + let func = *raw as *mut NodeSqliteCustomFunction; + // SAFETY: a registered box stays allocated until xDestroy, which + // unregisters it under this lock first. + func.is_null() || !scrub(unsafe { &mut (*func).callback }, freed) + }); + } + if let Some(aggregates) = NODE_SQLITE_CUSTOM_AGGREGATES.get() { + let mut aggregates = aggregates.lock().unwrap_or_else(|p| p.into_inner()); + aggregates.retain(|raw| { + let aggregate = *raw as *mut NodeSqliteCustomAggregate; + if aggregate.is_null() { + return true; + } + // SAFETY: as above (xDestroy unregisters under this lock). + let aggregate = unsafe { &mut *aggregate }; + let mut dead = scrub(&mut aggregate.start, freed); + dead |= scrub(&mut aggregate.step, freed); + if let Some(result) = aggregate.result.as_mut() { + dead |= scrub(result, freed); + } + if let Some(inverse) = aggregate.inverse.as_mut() { + dead |= scrub(inverse, freed); + } + !dead + }); + } + if let Some(states) = NODE_SQLITE_ACTIVE_AGGREGATES.get() { + let mut states = states.lock().unwrap_or_else(|p| p.into_inner()); + states.retain(|raw| { + let state = *raw as *mut NodeSqliteAggregateState; + // SAFETY: xFinal unregisters under this lock before freeing. + state.is_null() || !scrub(unsafe { &mut (*state).state }, freed) + }); + } +} + +/// `HANDLES` payload releaser (#11471): clear a DatabaseSync's authorizer when +/// the closure lives in the dying thread's arena. The authorizer trampoline +/// reads `None` as "allow" (`SQLITE_OK`), so the connection stays usable. The +/// database itself is kept: it holds no other GC value. +fn release_node_sqlite_authorizer_in_freed_ranges( + db: &mut NodeSqliteDbHandle, + freed: &perry_runtime::arena::thread_exit::FreedRanges, +) -> bool { + let mut callback = db + .authorizer_callback + .lock() + .unwrap_or_else(|p| p.into_inner()); + if callback.is_some_and(|value| freed.holds_value(value)) { + *callback = None; + } + false +} + +#[cfg(test)] +pub(crate) mod thread_exit_probe { + //! #11471 test probes over the node:sqlite callback tables. + use super::*; + + /// Register a scalar-function box exactly as `db.function()` does, minus + /// the `sqlite3_create_function_v2` call. Returns the box address. + pub(crate) fn register_function_for_test(callback: f64) -> usize { + let info = Box::into_raw(Box::new(NodeSqliteCustomFunction { + callback, + use_bigint_arguments: false, + })); + register_node_sqlite_custom_function(info); + info as usize + } + + /// Register an aggregate box as `db.aggregate()` does. Returns its address. + pub(crate) fn register_aggregate_for_test(step: f64) -> usize { + let aggregate = Box::into_raw(Box::new(NodeSqliteCustomAggregate { + start: f64::from_bits(TAG_NULL_BITS), + step, + result: None, + inverse: None, + use_bigint_arguments: false, + })); + register_node_sqlite_custom_aggregate(aggregate); + aggregate as usize + } + + /// Register a running aggregate state as xStep does. Returns its address. + pub(crate) fn register_aggregate_state_for_test(state: f64) -> usize { + ensure_node_sqlite_gc_scanner_registered(); + let state = Box::into_raw(Box::new(NodeSqliteAggregateState { state })); + register_node_sqlite_aggregate_state(state); + state as usize + } + + /// A closed DatabaseSync whose authorizer is `callback`, registered in + /// `HANDLES` as `new DatabaseSync()` + `db.setAuthorizer()` leave it. + pub(crate) fn register_db_with_authorizer_for_test(callback: f64) -> Handle { + ensure_node_sqlite_gc_scanner_registered(); + crate::common::register_handle(NodeSqliteDbHandle { + conn: Mutex::new(None), + path: String::new(), + read_only: false, + read_write: true, + create: true, + enable_foreign_keys: true, + enable_dqs: false, + timeout_ms: 0, + read_bigints: false, + return_arrays: false, + allow_bare_named_parameters: true, + allow_unknown_named_parameters: false, + allow_load_extension: false, + enable_load_extension: AtomicBool::new(false), + defensive: AtomicBool::new(true), + authorizer_callback: Mutex::new(Some(callback)), + initial_limits: [None; NODE_SQLITE_LIMIT_COUNT], + limits_handle: Mutex::new(None), + sessions: Mutex::new(HashSet::new()), + statements: Mutex::new(HashSet::new()), + }) + } + + pub(crate) fn function_registered(addr: usize) -> bool { + node_sqlite_custom_functions() + .lock() + .unwrap() + .contains(&addr) + } + + pub(crate) fn aggregate_registered(addr: usize) -> bool { + node_sqlite_custom_aggregates() + .lock() + .unwrap() + .contains(&addr) + } + + pub(crate) fn aggregate_state_registered(addr: usize) -> bool { + node_sqlite_active_aggregates() + .lock() + .unwrap() + .contains(&addr) + } + + pub(crate) fn authorizer(db: Handle) -> Option { + crate::common::with_handle::(db, |db| { + *db.authorizer_callback.lock().unwrap() + }) + .flatten() + } + + /// Free the probe boxes (SQLite's xDestroy / xFinal would in real use). + pub(crate) fn free_boxes_for_test(function: usize, aggregate: usize, state: usize) { + unsafe { + unregister_node_sqlite_custom_function(function as *mut NodeSqliteCustomFunction); + drop(Box::from_raw(function as *mut NodeSqliteCustomFunction)); + unregister_node_sqlite_custom_aggregate(aggregate as *mut NodeSqliteCustomAggregate); + drop(Box::from_raw(aggregate as *mut NodeSqliteCustomAggregate)); + unregister_node_sqlite_aggregate_state(state as *mut NodeSqliteAggregateState); + drop(Box::from_raw(state as *mut NodeSqliteAggregateState)); + } + } +} + pub(crate) fn scan_node_sqlite_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { { let functions = node_sqlite_custom_functions() @@ -354,6 +551,7 @@ pub(crate) fn unregister_node_sqlite_custom_aggregate(ptr: *mut NodeSqliteCustom } pub(crate) fn register_node_sqlite_aggregate_state(ptr: *mut NodeSqliteAggregateState) { + ensure_node_sqlite_thread_exit_hooks_registered(); if !ptr.is_null() { node_sqlite_active_aggregates() .lock() diff --git a/crates/perry-stdlib/src/streams.rs b/crates/perry-stdlib/src/streams.rs index be359155f84..4dcd44f92b6 100644 --- a/crates/perry-stdlib/src/streams.rs +++ b/crates/perry-stdlib/src/streams.rs @@ -378,7 +378,11 @@ pub use self::strategy::{ }; use self::strategy::{read_high_water_mark, read_queuing_strategy_size}; +#[cfg(test)] +pub(crate) use self::byob::byob_pending_for_test; pub(crate) use self::expando::stream_expando_get; +#[cfg(test)] +pub(crate) use self::expando::stream_expando_set_hook; use self::pipe::js_readable_stream_pipe_to; use self::subclass::{box_promise, js_stream_unwrap_handle}; pub(crate) use self::subclass::{dispatch_stream_method, dispatch_stream_property}; @@ -386,6 +390,8 @@ pub use self::subclass::{ drain_readable_into_bytes, js_readable_stream_subclass_init, js_stream_handle_is_registered, js_stream_handle_kind, js_transform_stream_subclass_init, js_writable_stream_subclass_init, }; +#[cfg(test)] +pub(crate) use self::transform::transform_side_tables_hold_for_test; pub use self::transform::{ js_stream_web_compression_stream_new, js_stream_web_decompression_stream_new, js_stream_web_text_decoder_stream_new, js_stream_web_text_encoder_stream_new, @@ -583,6 +589,203 @@ static READERS: std::sync::LazyLock>> = static WRITERS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +/// #11471: register [`release_web_streams_in_freed_ranges`] before the first +/// registry insert. Every stream family starts with a readable or writable +/// (`alloc_readable_with_strategy`, `alloc_writable_with_strategy`, the +/// transform constructor); readers, writers, tee branches, BYOB reads, +/// expandos and the transform side tables all key off an existing stream id. +fn ensure_streams_thread_exit_hook() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_web_streams_in_freed_ranges, + ) + }); +} + +/// #11471: an exiting thread's Web Streams records outlive its heap. The +/// registries are process-global (ids from the shared `idalloc` band) and +/// the `stdlib:streams` root scanner of every surviving thread visits every +/// entry, so a record holding the dead thread's callbacks, queued chunks, +/// promises or error values would have those threads mark and rewrite freed +/// (or reused) memory, and a later call on the id would run a dangling +/// closure. +/// +/// A stream id is DEAD when any of its records (registry entry, BYOB read, +/// transform side-table entry) holds an address in `freed`. Deadness then +/// spreads along the links between registry entries — stream <-> reader / +/// writer, transform <-> its readable and writable sides — so no surviving +/// record names an evicted id (which `idalloc` could otherwise hand to an +/// unrelated stream). Pipe lock ids and the tee sentinel own no registry +/// entry and do not propagate. Every dead id then gets the same footprint +/// removal as a quarantine eviction, plus the transform close/pending-write +/// tables. The ids themselves are not recycled (a bounded band leak per dead +/// stream; `idalloc` is left alone so this hook never takes its lock). +/// +/// Runs in the exiting thread's TLS destructor: registry locks one at a +/// time, poison-tolerant, no allocation on the JS heap, no JS calls. +fn release_web_streams_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + use std::collections::HashSet; + use std::sync::PoisonError; + let promise = |p: *mut Promise| freed.contains(p as usize); + let mut dead: HashSet = HashSet::new(); + let mut known: HashSet = HashSet::new(); + let mut links: Vec<(usize, usize)> = Vec::new(); + { + let g = READABLE_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (&id, s) in g.iter() { + known.insert(id); + if let Some(reader) = s.reader_handle { + links.push((id, reader)); + } + if [s.start_cb, s.pull_cb, s.cancel_cb, s.strategy_size_cb] + .iter() + .any(|&cb| freed.holds_i64(cb)) + || s.chunks.iter().any(|&bits| freed.holds_bits(bits)) + || s.pending_reads.iter().any(|&p| promise(p)) + || freed.holds_bits(s.error_value) + || s.pending_error_after_chunks + .is_some_and(|bits| freed.holds_bits(bits)) + { + dead.insert(id); + } + } + } + { + let g = WRITABLE_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (&id, s) in g.iter() { + known.insert(id); + if let Some(writer) = s.writer_handle { + links.push((id, writer)); + } + if [s.write_cb, s.close_cb, s.abort_cb, s.strategy_size_cb] + .iter() + .any(|&cb| freed.holds_i64(cb)) + || s.write_queue + .iter() + .any(|&(bits, p, _)| freed.holds_bits(bits) || promise(p)) + || freed.holds_bits(s.error_value) + || promise(s.ready_promise) + || promise(s.closed_promise) + || promise(s.close_request_promise) + { + dead.insert(id); + } + } + } + { + let g = TRANSFORM_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (&id, t) in g.iter() { + known.insert(id); + links.push((id, t.readable_handle)); + links.push((id, t.writable_handle)); + if freed.holds_i64(t.transform_cb) || freed.holds_i64(t.flush_cb) { + dead.insert(id); + } + } + } + { + let g = READERS.lock().unwrap_or_else(PoisonError::into_inner); + for (&id, r) in g.iter() { + known.insert(id); + links.push((id, r.stream_handle)); + if promise(r.closed_promise) { + dead.insert(id); + } + } + } + { + let g = WRITERS.lock().unwrap_or_else(PoisonError::into_inner); + for (&id, w) in g.iter() { + known.insert(id); + links.push((id, w.stream_handle)); + if promise(w.closed_promise) || promise(w.ready_promise) { + dead.insert(id); + } + } + } + transform::dead_ids_in_freed_ranges(freed, &mut dead, &mut links); + byob::dead_ids_in_freed_ranges(freed, &mut dead); + if dead.is_empty() { + return; + } + + let mut adjacent: HashMap> = HashMap::new(); + for (a, b) in links { + if known.contains(&a) && known.contains(&b) { + adjacent.entry(a).or_default().push(b); + adjacent.entry(b).or_default().push(a); + } + } + let mut work: Vec = dead.iter().copied().collect(); + while let Some(id) = work.pop() { + if let Some(next) = adjacent.get(&id) { + for &n in next { + if dead.insert(n) { + work.push(n); + } + } + } + } + + READABLE_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + WRITABLE_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + TRANSFORM_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + READERS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + WRITERS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + transform::evict_dead_ids(&dead); + let batch: Vec = dead.into_iter().collect(); + byob::evict_ids(&batch); + tee::evict_ids(&batch); + for &id in &batch { + expando::stream_expando_clear(id); + } +} + +/// #11471 test probe: which of the five registries hold `id`, as a bitmask +/// (readable 1, writable 2, transform 4, reader 8, writer 16). +#[cfg(test)] +pub(crate) fn stream_registry_presence_for_test(id: usize) -> u32 { + let mut mask = 0; + if READABLE_STREAMS.lock().unwrap().contains_key(&id) { + mask |= 1; + } + if WRITABLE_STREAMS.lock().unwrap().contains_key(&id) { + mask |= 2; + } + if TRANSFORM_STREAMS.lock().unwrap().contains_key(&id) { + mask |= 4; + } + if READERS.lock().unwrap().contains_key(&id) { + mask |= 8; + } + if WRITERS.lock().unwrap().contains_key(&id) { + mask |= 16; + } + mask +} + // #1545: ONE id allocator shared across all five Web Streams registries. // Stream handles are raw numeric f64 values, not POINTER_TAG small handles, // so they live just above the runtime's `< 0x100000` small-handle band. That @@ -775,6 +978,7 @@ fn alloc_readable_with_strategy( is_byte_stream: bool, strategy_size_cb: i64, ) -> usize { + ensure_streams_thread_exit_hook(); let id = next_stream_id(); READABLE_STREAMS.lock().unwrap().insert( id, @@ -827,6 +1031,7 @@ fn alloc_writable_with_strategy( hwm: f64, strategy_size_cb: i64, ) -> usize { + ensure_streams_thread_exit_hook(); let id = next_stream_id(); let ready = internal_promise(); let closed = internal_promise(); diff --git a/crates/perry-stdlib/src/streams/byob.rs b/crates/perry-stdlib/src/streams/byob.rs index e6bf3d8c195..14bc0713e8a 100644 --- a/crates/perry-stdlib/src/streams/byob.rs +++ b/crates/perry-stdlib/src/streams/byob.rs @@ -37,13 +37,43 @@ static BYOB_PENDING: std::sync::LazyLock, +) { + let map = BYOB_PENDING + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for (&id, queue) in map.iter() { + if queue + .iter() + .any(|p| freed.contains(p.promise as usize) || freed.holds_bits(p.view_bits)) + { + dead.insert(id); } } } +/// #11471 test probe: is a BYOB read parked on `stream_id`? +#[cfg(test)] +pub(crate) fn byob_pending_for_test(stream_id: usize) -> bool { + has_pending(stream_id) +} + /// True while at least one `read(view)` is parked on this stream — feeds /// the ShouldCallPull check in `maybe_pull`. pub(super) fn has_pending(stream_id: usize) -> bool { diff --git a/crates/perry-stdlib/src/streams/expando.rs b/crates/perry-stdlib/src/streams/expando.rs index d69b327aad3..ce6b8ca40ff 100644 --- a/crates/perry-stdlib/src/streams/expando.rs +++ b/crates/perry-stdlib/src/streams/expando.rs @@ -15,6 +15,34 @@ use std::sync::Mutex; static STREAM_EXPANDO: std::sync::LazyLock>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +/// #11471: register [`release_stream_expandos_in_freed_ranges`] before the +/// first expando insert. +fn ensure_expando_thread_exit_hook() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_stream_expandos_in_freed_ranges, + ) + }); +} + +/// #11471: drop every expando value that lies in an exiting thread's freed +/// blocks. The stream id is process-global but the value lives in the setting +/// thread's heap; left behind it would be returned by `stream_expando_get` and +/// traced by every surviving thread's `scan_expando_roots`. Only the offending +/// properties go (the stream itself may belong to another thread; if it was +/// the dead thread's, `release_web_streams_in_freed_ranges` clears the rest). +/// Runs in a TLS destructor: one poison-tolerant lock, no JS heap access. +fn release_stream_expandos_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + let mut map = STREAM_EXPANDO + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + map.retain(|_, entries| { + entries.retain(|(_, bits)| !freed.holds_bits(*bits)); + !entries.is_empty() + }); +} + /// #5437: store an expando property on a live stream handle. Returns 1 when /// stored. Any live stream-band handle kind accepts expandos (streams, /// readers, writers) — matching JS objects accepting arbitrary properties. @@ -31,6 +59,7 @@ pub(crate) unsafe extern "C" fn stream_expando_set_hook( let Ok(key) = std::str::from_utf8(bytes) else { return 0; }; + ensure_expando_thread_exit_hook(); if let Ok(mut map) = STREAM_EXPANDO.lock() { let entry = map.entry(id).or_default(); if let Some(slot) = entry.iter_mut().find(|(k, _)| k == key) { diff --git a/crates/perry-stdlib/src/streams/transform.rs b/crates/perry-stdlib/src/streams/transform.rs index 31c0712f5a5..5f82b200d9b 100644 --- a/crates/perry-stdlib/src/streams/transform.rs +++ b/crates/perry-stdlib/src/streams/transform.rs @@ -66,6 +66,7 @@ unsafe fn alloc_transform_stream_with_strategies( (r_hwm, r_size_cb): (f64, i64), ) -> f64 { ensure_gc_registered(); + ensure_streams_thread_exit_hook(); // Allocate the readable side empty (controller is its own handle). let readable_id = alloc_readable_with_strategy(0, 0, 0, r_hwm, false, r_size_cb); @@ -405,6 +406,81 @@ pub(super) static TRANSFORM_BACKPRESSURED_JOBS: std::sync::LazyLock< pub(super) static TRANSFORM_PENDING_CLOSE: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +/// #11471: the transform side tables' part of +/// `release_web_streams_in_freed_ranges` (streams.rs). A readable id whose +/// parked write promises or deferred write jobs, or a writable id whose +/// deferred close promise, lie in the exiting thread's freed blocks is dead; +/// `TRANSFORM_PAIRS` contributes its writable <-> transform links so deadness +/// reaches the whole transform. +pub(super) fn dead_ids_in_freed_ranges( + freed: &perry_runtime::arena::thread_exit::FreedRanges, + dead: &mut std::collections::HashSet, + links: &mut Vec<(usize, usize)>, +) { + use std::sync::PoisonError; + links.extend( + TRANSFORM_PAIRS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .map(|(&writable, &transform)| (writable, transform)), + ); + for table in [&TRANSFORM_WRITE_RELEASES, &TRANSFORM_BACKPRESSURED_JOBS] { + let g = table.lock().unwrap_or_else(PoisonError::into_inner); + for (&readable_id, addrs) in g.iter() { + if addrs.iter().any(|&addr| freed.contains(addr)) { + dead.insert(readable_id); + } + } + } + let g = TRANSFORM_PENDING_CLOSE + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (&writable_id, &addr) in g.iter() { + if freed.contains(addr) { + dead.insert(writable_id); + } + } +} + +/// #11471: drop every transform side-table entry keyed by (or, for +/// `TRANSFORM_PAIRS`, naming) a dead id. Unlike quarantine eviction this also +/// clears `TRANSFORM_PENDING_CLOSE` / `TRANSFORM_PENDING_WRITES`: the jobs that +/// would have drained them lived on the dead thread's microtask queue. +pub(super) fn evict_dead_ids(dead: &std::collections::HashSet) { + use std::sync::PoisonError; + TRANSFORM_PAIRS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|writable, transform| !dead.contains(writable) && !dead.contains(transform)); + for table in [&TRANSFORM_WRITE_RELEASES, &TRANSFORM_BACKPRESSURED_JOBS] { + table + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + } + for table in [&TRANSFORM_PENDING_CLOSE, &TRANSFORM_PENDING_WRITES] { + table + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + } +} + +/// #11471 test probe: does any transform side table hold an entry for `id`? +#[cfg(test)] +pub(crate) fn transform_side_tables_hold_for_test(id: usize) -> bool { + TRANSFORM_PAIRS.lock().unwrap().contains_key(&id) + || TRANSFORM_PAIRS.lock().unwrap().values().any(|&t| t == id) + || TRANSFORM_WRITE_RELEASES.lock().unwrap().contains_key(&id) + || TRANSFORM_BACKPRESSURED_JOBS + .lock() + .unwrap() + .contains_key(&id) + || TRANSFORM_PENDING_CLOSE.lock().unwrap().contains_key(&id) + || TRANSFORM_PENDING_WRITES.lock().unwrap().contains_key(&id) +} + /// #6607: a queued transform write job finished delivering its chunk. When the /// last pending job for the writable drains, run any close request that /// arrived while the jobs were still queued (see `transform_close`). diff --git a/crates/perry-stdlib/src/tls.rs b/crates/perry-stdlib/src/tls.rs index a60bca45c35..d0627dd4cee 100644 --- a/crates/perry-stdlib/src/tls.rs +++ b/crates/perry-stdlib/src/tls.rs @@ -354,6 +354,7 @@ fn ensure_crypto_provider_installed() { } fn ensure_tls_gc_scanner_registered() { + ensure_tls_thread_exit_hook_registered(); TLS_GC_REGISTERED.with(|registered| { if registered.get() { return; @@ -363,6 +364,126 @@ fn ensure_tls_gc_scanner_registered() { }); } +/// #11471: `TLS_SERVERS`, `TLS_LISTENERS` and `TLS_ONCE_FLAGS` are +/// process-global but hold raw closure addresses from the thread that created +/// the server or called `.on()`/`.once()`. Registered before the first insert +/// into any of them (`register_listener`, `js_tls_create_server` via +/// `ensure_tls_gc_scanner_registered`). +fn ensure_tls_thread_exit_hook_registered() { + static REGISTER: Once = Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_tls_tables_in_freed_ranges, + ) + }); +} + +/// Thread-exit hook (#11471): an exiting thread's arena is about to be freed, +/// so drop every listener / once-flag closure address inside it, and every +/// server record whose SNICallback / ALPNCallback lives there. Such a server +/// was created by that thread (its turnloop listener was on that thread's +/// loop), so the whole record goes, releasing its keep-alive count exactly as +/// the `'close'` path does. Runs in a TLS destructor: process-global locks +/// only, taken one at a time, poison tolerated. +fn release_tls_tables_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + if let Some(all) = TLS_LISTENERS.get() { + let mut all = all + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for per_handle in all.values_mut() { + per_handle.retain(|_, callbacks| { + let before = callbacks.len(); + callbacks.retain(|cb| !freed.holds_i64(*cb)); + // An event whose listeners all died goes; one that was already + // empty is left exactly as the live paths left it. + before == callbacks.len() || !callbacks.is_empty() + }); + } + } + if let Some(all) = TLS_ONCE_FLAGS.get() { + let mut all = all + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for per_handle in all.values_mut() { + per_handle.retain(|_, callbacks| { + callbacks.retain(|cb| !freed.holds_i64(*cb)); + !callbacks.is_empty() + }); + } + all.retain(|_, per_handle| !per_handle.is_empty()); + } + if let Some(all) = TLS_SERVERS.get() { + let mut all = all + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + all.retain(|_, server| { + let dead = + freed.holds_i64(server.sni_callback) || freed.holds_i64(server.alpn_callback); + if dead { + liveness::step(liveness::server_keeps_alive(server), false); + } + !dead + }); + } +} + +#[cfg(test)] +pub(crate) mod thread_exit_probe { + //! #11471 test probes: populate / observe the TLS tables through the same + //! insert paths the public surface uses. + use super::*; + + /// Insert a server record carrying `sni_callback` (as `js_tls_create_server` + /// does after reading `options.SNICallback`) and register `listener` on it + /// as a `once` listener. Returns the server id. + pub(crate) fn insert_server_for_test(sni_callback: i64, listener: i64) -> i64 { + ensure_tls_gc_scanner_registered(); + let id = next_tls_handle_id(); + servers().lock().unwrap().insert( + id, + TlsServerState { + listener_open: false, + bound_port: 0, + bound_host: String::new(), + listening: false, + active_connections: 0, + closing: false, + close_event_queued: false, + config: None, + ticket_keys: vec![0; 48], + allow_half_open: false, + pause_on_connect: false, + certificate: Vec::new(), + cert_resolver: None, + sni_callback, + alpn_callback: 0, + alpn_protocols: Vec::new(), + sni_errors: HashMap::new(), + }, + ); + listeners().lock().unwrap().insert(id, HashMap::new()); + register_listener(id, "secureConnection".to_string(), listener, true); + id + } + + pub(crate) fn server_present(id: i64) -> bool { + servers().lock().unwrap().contains_key(&id) + } + + pub(crate) fn listener_present(id: i64, cb: i64) -> bool { + listeners_for(id, "secureConnection").contains(&cb) + } + + pub(crate) fn once_flag_present(id: i64, cb: i64) -> bool { + once_flags() + .lock() + .unwrap() + .get(&id) + .and_then(|m| m.get("secureConnection")) + .is_some_and(|set| set.contains(&cb)) + } +} + fn scan_tls_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { if let Ok(mut all) = listeners().lock() { for per_handle in all.values_mut() { @@ -456,6 +577,7 @@ fn register_listener(handle: i64, event: String, cb: i64, once: bool) { if cb == 0 { return; } + ensure_tls_thread_exit_hook_registered(); listeners() .lock() .unwrap() diff --git a/crates/perry-stdlib/src/webcrypto/util.rs b/crates/perry-stdlib/src/webcrypto/util.rs index 57b5326b1fe..98f0d02a019 100644 --- a/crates/perry-stdlib/src/webcrypto/util.rs +++ b/crates/perry-stdlib/src/webcrypto/util.rs @@ -292,6 +292,7 @@ pub(super) fn register_crypto_key_with_bit_length( mat: CryptoKeyMaterial, bit_length: u32, ) { + ensure_crypto_key_thread_exit_hook_registered(); CRYPTO_KEY_REGISTRY.lock().unwrap().insert(buf_addr, mat); unsafe { js_buffer_mark_as_crypto_key_external( @@ -373,6 +374,52 @@ pub(crate) extern "C" fn crypto_key_buffer_died(buf_addr: usize) { } } +/// #11471: a CryptoKey's backing Buffer lives in the arena of the thread that +/// generated / imported it. The GC sweep reports its death +/// (`crypto_key_buffer_died`), but a thread exit frees the whole arena without +/// a sweep, so the entry would survive and a Buffer later allocated at the same +/// address on another thread would read as that dead key. +fn ensure_crypto_key_thread_exit_hook_registered() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_crypto_keys_in_freed_ranges, + ) + }); +} + +/// Thread-exit hook (#11471): drop every CryptoKey entry keyed by a Buffer in +/// the exiting thread's arena. Only removes map entries, like the sweep hook. +fn release_crypto_keys_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + let mut registry = CRYPTO_KEY_REGISTRY + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + registry.retain(|buf_addr, _| !freed.contains(*buf_addr)); +} + +/// #11471 test probe: register a fresh secret HMAC key on a Buffer allocated in +/// the calling thread's arena, as `subtle.importKey` does. Returns its address. +/// Exported by symbol because `webcrypto::util` is private to `webcrypto`. +#[cfg(test)] +#[no_mangle] +pub extern "C" fn perry_test_11471_register_crypto_key() -> usize { + let buf = unsafe { alloc_uint8array_from_slice(&[7u8; 32]) }; + let addr = buf as usize; + register_crypto_key( + addr, + CryptoKeyMaterial::new(KeyAlgo::Hmac, HashAlgo::Sha256, KeyKind::Secret, true, 0), + ); + addr +} + +/// #11471 test probe: is `buf_addr` in the stdlib CryptoKey registry itself +/// (not the runtime metadata fallback `lookup_crypto_key` also consults)? +#[cfg(test)] +#[no_mangle] +pub extern "C" fn perry_test_11471_crypto_key_registered(buf_addr: usize) -> bool { + CRYPTO_KEY_REGISTRY.lock().unwrap().contains_key(&buf_addr) +} + pub(super) fn lookup_crypto_key(buf_addr: usize) -> Option { CRYPTO_KEY_REGISTRY .lock() diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index e3f6b1165b4..e3d56bca3e3 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -325,6 +325,96 @@ fn scan_environment_data_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootV }); } +/// #11471: `WORKERS` is process-global, but each record holds the Worker +/// object, listener closures, terminate promise and async-resource objects of +/// the thread that constructed the Worker (nested Workers are supported, so +/// that need not be the main thread). Registered before the first insert. +fn ensure_worker_thread_exit_hook_registered() { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_worker_records_in_freed_ranges, + ) + }); +} + +/// Thread-exit hook (#11471): drop every Worker record whose JS values live in +/// the exiting thread's arena. Such a record belongs to the dying thread (the +/// only one that can observe that Worker), so the whole record goes: its +/// `LIVE_REFED_WORKERS` share is released first, and dropping its command +/// `Sender` disconnects the child's receive loop, which ends it as a parent +/// exit should. Runs in a TLS destructor: one process-global lock, no JS. +fn release_worker_records_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + let mut workers = WORKERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + workers.retain(|_, worker| { + let dead = freed.holds_bits(worker.object_bits) + || worker + .listeners + .values() + .flatten() + .any(|listener| freed.holds_bits(listener.callback_bits)) + || worker + .terminate_promise + .is_some_and(|promise| freed.contains(promise)) + || worker + .async_resource_bits + .iter() + .any(|bits| freed.holds_bits(*bits)); + if dead { + worker.set_liveness(false, false); + } + !dead + }); +} + +/// #11471 test probes over `WORKERS`. +#[cfg(test)] +pub(crate) mod thread_exit_probe { + use super::*; + + /// Insert a live, refed record for `object_bits` exactly as + /// `js_worker_threads_worker_new` does (minus spawning the thread), with + /// `callback_bits` as a `message` listener. Returns the worker id. + pub(crate) fn insert_worker_for_test(object_bits: u64, callback_bits: u64) -> u64 { + ensure_worker_thread_exit_hook_registered(); + let worker_id = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed); + let (tx, _rx) = mpsc::channel::(); + let undefined = perry_runtime::JSValue::undefined().bits(); + let mut listeners = HashMap::new(); + listeners.insert( + "message".to_string(), + vec![WorkerListener { + callback_bits, + once: false, + web_event: false, + }], + ); + let mut record = WorkerRecord { + sender: tx, + object_bits, + listeners, + alive: false, + refed: false, + terminate_promise: None, + async_resources: [perry_runtime::async_hooks::AsyncResourceIds { + async_id: 0, + trigger_async_id: 0, + }; 3], + async_resource_bits: [undefined; 3], + }; + let mut workers = WORKERS.lock().unwrap(); + record.set_liveness(true, true); + workers.insert(worker_id, record); + worker_id + } + + pub(crate) fn worker_present(worker_id: u64) -> bool { + WORKERS.lock().unwrap().contains_key(&worker_id) + } +} + fn scan_worker_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { if let Ok(mut workers) = WORKERS.lock() { for worker in workers.values_mut() { @@ -1301,6 +1391,7 @@ pub extern "C" fn js_worker_threads_message_channel_new() -> f64 { #[no_mangle] pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) -> f64 { ensure_worker_gc_scanner(); + ensure_worker_thread_exit_hook_registered(); worker_pump::ensure_parent_event_retire_hook(); crate::worker_threads::async_shim::ensure_pump_registered(); diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 3b75c2b922e..43e1b2eef3e 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -751,6 +751,14 @@ thread_local! { /// rooting them a GC between `.on()` and the deferred dispatch would free the /// closure body (the same hazard net.Socket guards against — issue #35). fn ensure_zlib_gc_scanner() { + // #11471: every zlib table insert (stream creation, `.on()`, the one-shot + // `zlib.gzip(data, cb)` path) passes through here first. + static THREAD_EXIT_HOOK: std::sync::Once = std::sync::Once::new(); + THREAD_EXIT_HOOK.call_once(|| { + perry_runtime::arena::thread_exit::register_thread_exit_range_hook( + release_zlib_in_freed_ranges, + ) + }); ZLIB_GC_REGISTERED.with(|registered| { if registered.get() { return; @@ -760,6 +768,88 @@ fn ensure_zlib_gc_scanner() { }); } +/// #11471: an exiting thread's zlib records outlive its heap. The tables are +/// process-global and not agent-tagged: `.pipe(dest)` destinations and +/// `.on(event, cb)` listeners are the setting thread's heap objects, and +/// `.flush(cb)` / `zlib.gzip(data, cb)` queue raw closure pointers that +/// `js_zlib_process_pending` would later call. Left behind, surviving +/// threads' `scan_zlib_roots` mark and rewrite freed (or reused) memory and a +/// drained event dispatches onto it. +/// +/// A zlib stream whose pipes or listeners lie in `freed` is dropped whole +/// (state, listeners, and its queued Data/End/Error events); a queued +/// callback event whose closure lies in `freed` is dropped. Handle ids are +/// monotonic (never reused), so no other record can come to name a dropped +/// id. `js_zlib_has_active_handles` reads the queue's emptiness directly, so +/// shrinking it keeps the loop-liveness answer consistent. The dropped +/// streams' async-hooks `destroy` is not emitted (that would run JS). +/// +/// Runs in a TLS destructor: one lock at a time, poison-tolerant, no JS heap +/// access. +fn release_zlib_in_freed_ranges(freed: &perry_runtime::arena::thread_exit::FreedRanges) { + use std::sync::PoisonError; + let mut dead: std::collections::HashSet = std::collections::HashSet::new(); + { + let g = ZLIB_STREAMS.lock().unwrap_or_else(PoisonError::into_inner); + for (&id, s) in g.iter() { + if s.pipes.iter().any(|&bits| freed.holds_bits(bits)) { + dead.insert(id); + } + } + } + { + let g = ZLIB_LISTENERS + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (&id, per_event) in g.iter() { + if per_event.values().flatten().any(|&cb| freed.holds_i64(cb)) { + dead.insert(id); + } + } + } + if !dead.is_empty() { + ZLIB_STREAMS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + ZLIB_LISTENERS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| !dead.contains(id)); + } + ZLIB_PENDING_EVENTS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|ev| match ev { + ZlibEvent::Data(id, _) | ZlibEvent::End(id) | ZlibEvent::Error(id, _) => { + !dead.contains(id) + } + ZlibEvent::Callback(cb) | ZlibEvent::OneShotCallback(cb, _, _) => !freed.holds_i64(*cb), + }); +} + +/// #11471 test probe: (stream registered, listener count, queued events +/// naming `id`). +#[cfg(test)] +pub(crate) fn zlib_tables_for_test(id: i64) -> (bool, usize, usize) { + let stream = ZLIB_STREAMS.lock().unwrap().contains_key(&id); + let listeners = ZLIB_LISTENERS + .lock() + .unwrap() + .get(&id) + .map(|m| m.values().map(Vec::len).sum()) + .unwrap_or(0); + let events = ZLIB_PENDING_EVENTS + .lock() + .unwrap() + .iter() + .filter(|ev| { + matches!(ev, ZlibEvent::Data(i, _) | ZlibEvent::End(i) | ZlibEvent::Error(i, _) if *i == id) + }) + .count(); + (stream, listeners, events) +} + fn scan_zlib_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { if let Ok(mut listeners) = ZLIB_LISTENERS.lock() { for per_stream in listeners.values_mut() { diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 0f605eb49b1..426bbddbc34 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -33,6 +33,12 @@ "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "CONSOLE_CHANNEL_IDS", + "verdict": "not_a_gc_pointer", + "why": "Per-thread cache of five diagnostics_channel ids (i64 keys into the thread-local DIAG_CHANNELS table, minted by NEXT_DIAG_ID) for console.log/info/debug/error/warn; integers, never a NaN-boxed value. #11471 made these per-thread." + }, { "file": "crates/perry-ext-http/src/lib.rs", "name": "HTTP_PENDING_EVENTS", diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json new file mode 100644 index 00000000000..70f2b8572db --- /dev/null +++ b/scripts/thread_exit_address_globals.json @@ -0,0 +1,4094 @@ +{ + "_doc": "Thread-exit verdicts for process-global statics that can hold a heap address (#11471). Checked by scripts/thread_exit_address_globals.py; see its docstring for the verdicts.", + "type_verdicts": [ + { + "type": "RealmAtomicI64", + "verdict": "per_thread", + "why": "The static is a handle to a crate::tls_hot::HotKey; every load/store resolves the calling thread's own slot (object/mod.rs), so no address is shared across threads." + }, + { + "type": "RealmAtomicU64", + "verdict": "per_thread", + "why": "u64 twin of RealmAtomicI64: a handle to a per-thread HotKey slot, resolved on every access." + }, + { + "type": "ImageTable", + "verdict": "no_heap_address", + "why": "Selects a table of the calling thread's class image (object/class_image.rs). The tables are keyed by codegen class ids and hold code addresses (func_ptr), counts, flags and Rust-owned strings; no GC heap address." + } + ], + "entries": [ + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGNAL_ASYNC_IDS" + ], + "verdict": "no_heap_address", + "why": "Maps signal name String -> u64 async_id minted from async_hooks NEXT_ASYNC_ID (process-global per_test_global in production) and resolved in the process-global RESOURCES table; the SIGNALWRAP resource object itself is handed to async_hooks::init_resource, not stored here (signal.rs:765-785)." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGHUP_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGHUP deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGHUP_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGHUP copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGINT_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGINT deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGINT_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGINT copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGQUIT_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGQUIT deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGQUIT_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGQUIT copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGUSR1_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGUSR1 deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGUSR1_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGUSR1 copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGUSR2_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGUSR2 deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGUSR2_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGUSR2 copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGABRT_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGABRT deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGABRT_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGABRT copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGBUS_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGBUS deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGBUS_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGBUS copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGPIPE_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGPIPE deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGPIPE_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGPIPE copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGTERM_PENDING" + ], + "verdict": "no_heap_address", + "why": "Count of undelivered SIGTERM deliveries, bumped by the sigaction handler / on_signal_completion and swapped to 0 by take_pending_process_signals; a plain counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "SIGTERM_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Listener count for SIGTERM copied from the (thread-local) PROCESS_EMITTER by set_process_signal_listener_count (signal.rs:737); an integer count, never an address." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "PROCESS_SIGNAL_SLOTS" + ], + "verdict": "no_heap_address", + "why": "Immutable table of ProcessSignalSlot {name, signum, &'static AtomicUsize/AtomicBool} referencing only other statics; addresses of statics." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "TURNLOOP_SIGNAL_IDS" + ], + "verdict": "thread_exit_invalidated", + "why": "Holds a per-agent turnloop subscription id, not an address; the owning agent (TURNLOOP_SIGNAL_OWNERS) is checked at agent retirement and the slot reset so the next listener installs on a live loop.", + "hook": "release_signal_subscriptions_of_agent" + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGINT_PENDING" + ], + "verdict": "no_heap_address", + "why": "Windows count of undelivered SIGINT console-control events, bumped from the console ctrl-handler thread and drained by take_pending_process_signals; a counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGINT_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Windows listener count for SIGINT, stored by set_process_signal_listener_count; an integer count." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGBREAK_PENDING" + ], + "verdict": "no_heap_address", + "why": "Windows count of undelivered SIGBREAK console-control events, bumped from the console ctrl-handler thread and drained by take_pending_process_signals; a counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGBREAK_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Windows listener count for SIGBREAK, stored by set_process_signal_listener_count; an integer count." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGHUP_PENDING" + ], + "verdict": "no_heap_address", + "why": "Windows count of undelivered SIGHUP console-control events, bumped from the console ctrl-handler thread and drained by take_pending_process_signals; a counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGHUP_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Windows listener count for SIGHUP, stored by set_process_signal_listener_count; an integer count." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGTERM_PENDING" + ], + "verdict": "no_heap_address", + "why": "Windows count of undelivered SIGTERM console-control events, bumped from the console ctrl-handler thread and drained by take_pending_process_signals; a counter." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGTERM_LISTENERS" + ], + "verdict": "no_heap_address", + "why": "Windows listener count for SIGTERM, stored by set_process_signal_listener_count; an integer count." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "WIN_SIGNAL_SLOTS" + ], + "verdict": "no_heap_address", + "why": "Immutable table of WinSignalSlot {name, ctrl_event, &'static AtomicUsize} referencing only other statics; addresses of statics." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "WORKER_THREADS_WORKER_DATA_GETTER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib extern \"C\" getter for worker_threads.workerData, stored by js_register_worker_threads_namespace_getters (native_module.rs:414-418); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "WORKER_THREADS_IS_MAIN_THREAD_GETTER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib extern \"C\" getter for worker_threads.isMainThread, stored by js_register_worker_threads_namespace_getters (native_module.rs:414-418); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "WORKER_THREADS_PARENT_PORT_GETTER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib extern \"C\" getter for worker_threads.parentPort, stored by js_register_worker_threads_namespace_getters (native_module.rs:414-418); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "WORKER_THREADS_THREAD_NAME_GETTER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib extern \"C\" getter for worker_threads.threadName, stored by js_register_worker_threads_namespace_getters (native_module.rs:414-418); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "WORKER_THREADS_RESOURCE_LIMITS_GETTER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib extern \"C\" getter for worker_threads.resourceLimits, stored by js_register_worker_threads_namespace_getters (native_module.rs:414-418); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "NATIVE_MODULE_VTABLE_PTR" + ], + "verdict": "no_heap_address", + "why": "Address of the static NATIVE_MODULE_VTABLE_IMPL, stored by install_native_module_vtable (native_module.rs:544); address of a static." + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "names": [ + "MODULE_CJS_CONSTRUCTOR_HOOK" + ], + "verdict": "no_heap_address", + "why": "Code address of module_constructor_identity_value, stored by arm_module_cjs_constructor_hook (native_module.rs:1093); function pointer." + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "NEXT_TLS_HANDLE_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic i64 counter (starting 0x70000) minting TLS server/socket handle ids for the process-global TLS_* tables; a counter." + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "TLS_SERVERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Server records hold the creating thread's raw SNICallback/ALPNCallback closure addresses; a record with a freed callback is dropped whole at thread exit after releasing its keep-alive count.", + "hook": "release_tls_tables_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "TLS_SOCKETS" + ], + "verdict": "no_heap_address", + "why": "Handle id -> TlsSocketState, whose fields are Rust-owned (bools, Strings, Vec certs, SocketAddr) plus server_handle, an id from the process-global NEXT_TLS_HANDLE_ID; no heap addresses." + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "TLS_LISTENERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Per-handle, per-event lists of raw listener closure addresses from whichever thread called .on()/.once(); freed addresses are removed at thread exit.", + "hook": "release_tls_tables_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "TLS_ONCE_FLAGS" + ], + "verdict": "thread_exit_invalidated", + "why": "Sets of raw once-listener closure addresses matched by address; freed addresses are removed and empty maps pruned at thread exit.", + "hook": "release_tls_tables_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "names": [ + "TLS_PENDING_EVENTS" + ], + "verdict": "no_heap_address", + "why": "Queue of PendingTlsEvent variants carrying only handle ids (i64 from NEXT_TLS_HANDLE_ID), Vec data and Strings; no heap addresses." + }, + { + "file": "crates/perry-runtime/src/builtins/fn_metadata.rs", + "names": [ + "REGISTRY" + ], + "verdict": "no_heap_address", + "why": "function_name_registry: compiled function code address (func_ptr) -> &'static name bytes in the image, written only by js_register_function_name_static from codegen module init; keys are code addresses. | (second declaration) function_source_registry (fn_metadata.rs:343): func_ptr code address -> &'static source bytes in the image, written by js_register_function_source_static from codegen module init." + }, + { + "file": "crates/perry-runtime/src/builtins/fn_metadata.rs", + "names": [ + "OVERRIDES" + ], + "verdict": "no_heap_address", + "why": "function_name_overrides: func_ptr -> Arc<[u8]> owned name, written by js_register_function_name / register_function_name_if_absent; all callers key by ClosureHeader.func_ptr or thunk addresses (symbol/properties.rs:278, keys_array.rs:692, util_promisify.rs:168, populate.rs:658) — code addresses, not the closure. | (second declaration) function_source_overrides (fn_metadata.rs:358): func_ptr code address -> Arc<[u8]> copied source + non-strict flag, written by js_register_function_source; keys are code addresses." + }, + { + "file": "crates/perry-runtime/src/tls_hot.rs", + "names": [ + "HOT" + ], + "verdict": "per_thread", + "why": "Android-only tls_os_pool::LocalKey>; the static is a key and each thread resolves its own HotTls cache via the pthread-key pool." + }, + { + "file": "crates/perry-runtime/src/tls_hot.rs", + "names": [ + "PERRY_HOT_TSD_KEY" + ], + "verdict": "no_heap_address", + "why": "pthread TSD key number under which each thread publishes its own HotTls address, written only by ensure_key/disable; an OS key id." + }, + { + "file": "crates/perry-runtime/src/tls_hot.rs", + "names": [ + "STORAGE" + ], + "verdict": "per_thread", + "why": "Android arm of __perry_thread_local_storage! (const form): a tls_os_pool::LocalKey, storage resolves per thread (thread_local! on other targets). | (second declaration) Android arm of __perry_thread_local_storage! (expr form): a tls_os_pool::LocalKey, storage resolves per thread (thread_local! on other targets)." + }, + { + "file": "crates/perry-runtime/src/closure/alloc.rs", + "names": [ + "CLOSURE_ALLOC_COUNT" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter bumped via promise::bump on each closure allocation (alloc.rs:437,555), read by promise/mod.rs:281 stats." + }, + { + "file": "crates/perry-runtime/src/closure/alloc.rs", + "names": [ + "CLOSURE_CAP_SINGLETON_HIT" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of singleton-closure cache hits (alloc.rs:814)." + }, + { + "file": "crates/perry-runtime/src/closure/alloc.rs", + "names": [ + "CLOSURE_CAP_SINGLETON_MISS" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of singleton-closure cache misses (alloc.rs:782,822)." + }, + { + "file": "crates/perry-runtime/src/object/attr_census.rs", + "names": [ + "SINK" + ], + "verdict": "no_heap_address", + "why": "PERRY_ATTR_DIAG output sink resolved once from the environment (hot_diag::sink_from_env); Rust-owned config." + }, + { + "file": "crates/perry-runtime/src/object/attr_census.rs", + "names": [ + "TABLE" + ], + "verdict": "no_heap_address", + "why": "Census counts keyed by (&'static event name, &'static caller file, line) from #[track_caller]; static strings and counters." + }, + { + "file": "crates/perry-runtime/src/object/attr_census.rs", + "names": [ + "KINDS" + ], + "verdict": "no_heap_address", + "why": "Census counts keyed by (&'static event, &'static owner-kind name); owner address is only classified, never stored." + }, + { + "file": "crates/perry-runtime/src/event_pump.rs", + "names": [ + "WAKE_CALLBACK" + ], + "verdict": "no_heap_address", + "why": "Code address of the host's wake callback set by perry_set_wake_callback (e.g. perry-ui-windows app.rs:790 post_runtime_wake); function pointer." + }, + { + "file": "crates/perry-runtime/src/event_pump.rs", + "names": [ + "WAKE_CONTEXT" + ], + "verdict": "process_global_allocation", + "why": "Opaque host-owned context pointer passed to perry_set_wake_callback (perry-ui-windows passes the main HWND, app.rs:790-792; cleared at :877); never a Perry arena allocation." + }, + { + "file": "crates/perry-runtime/src/node_submodules/consumers.rs", + "names": [ + "STREAM_CONSUMER_GET_READER" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib getReader callback registered by js_register_stream_consumer_callbacks (consumers.rs:124); function pointer." + }, + { + "file": "crates/perry-runtime/src/node_submodules/consumers.rs", + "names": [ + "STREAM_CONSUMER_READER_READ" + ], + "verdict": "no_heap_address", + "why": "Code address of the stdlib reader.read callback registered by js_register_stream_consumer_callbacks (consumers.rs:125); function pointer." + }, + { + "file": "crates/perry-runtime/src/object/prop_plan.rs", + "names": [ + "HITS" + ], + "verdict": "no_heap_address", + "why": "PLAN-DIAG counter of load-plan cache hits (prop_plan.rs:205), only under plan_diag_enabled." + }, + { + "file": "crates/perry-runtime/src/object/prop_plan.rs", + "names": [ + "RECORDS" + ], + "verdict": "no_heap_address", + "why": "PLAN-DIAG counter of store-plan records (prop_plan.rs:238); key_ptr is only printed, not stored here." + }, + { + "file": "crates/perry-stdlib/src/framework/server.rs", + "names": [ + "QUEUES" + ], + "verdict": "no_heap_address", + "why": "listener id -> VecDeque; PendingRequest holds Rust-owned method/path/headers/body plus conn_id/seq ids from perry-http-server's process-global id domain (listeners()/conns() are process-global maps)." + }, + { + "file": "crates/perry-stdlib/src/framework/server.rs", + "names": [ + "PENDING_RESPONSES" + ], + "verdict": "no_heap_address", + "why": "Request id -> ResponseSlot {conn_id, seq}, plain ids into perry-http-server's process-global conns table; no heap addresses." + }, + { + "file": "crates/perry-runtime/src/builtins/console.rs", + "names": [ + "CONSOLE_LOG_SINGLETON" + ], + "verdict": "thread_exit_invalidated", + "why": "console.log-as-value closure allocated in the first caller's arena and cached process-wide; cleared at thread exit when that arena is freed.", + "hook": "release_console_log_singleton_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch_fsevents.rs", + "names": [ + "API" + ], + "verdict": "no_heap_address", + "why": "dlsym'd CoreFoundation/CoreServices function pointers plus &kCFTypeArrayCallBacks from a system image (load_api), or a WatchError; code/image addresses." + }, + { + "file": "crates/perry-runtime/src/node_api_host/async_work.rs", + "names": [ + "COMPLETIONS" + ], + "verdict": "no_heap_address", + "why": "Queue of Arc (Rust-heap) whose env_address is the Rust Box of the owning thread's NODE_API_ENV and data/execute/complete are native addon pointers; drained only when work.owner == current ThreadId and env serial matches via with_env (async_work.rs:270-288, mod.rs:421-433), so no GC arena address and a dead owner's entries can never be dereferenced (ThreadIds are never reused)." + }, + { + "file": "crates/perry-runtime/src/object/field_get_set/for_in_stable.rs", + "names": [ + "HITS" + ], + "verdict": "no_heap_address", + "why": "FOR-IN-DIAG counter of stable single-shape for-in hits (for_in_stable.rs:223)." + }, + { + "file": "crates/perry-runtime/src/perf_hooks.rs", + "names": [ + "PERF_CLOCK" + ], + "verdict": "no_heap_address", + "why": "Process-start Instant plus time-origin milliseconds for performance.now()/timeOrigin; plain data." + }, + { + "file": "crates/perry-runtime/src/shared_sab.rs", + "names": [ + "SHARED_SAB_REGISTRY" + ], + "verdict": "process_global_allocation", + "why": "Set of BufferHeader addresses of SharedArrayBuffer backings allocated with std::alloc::alloc_zeroed in shared_sab.rs (never freed, not in any thread arena), so thread exit cannot free them." + }, + { + "file": "crates/perry-runtime/src/tui/input.rs", + "names": [ + "INPUT_HANDLER" + ], + "verdict": "thread_exit_invalidated", + "why": "Single atomic raw-closure slot called by drain_input; compare-exchanged to 0 at thread exit only if it still holds a freed closure.", + "hook": "release_tui_input_handler_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/common/handle.rs", + "names": [ + "HANDLES" + ], + "verdict": "thread_exit_invalidated", + "why": "Type-erased handle payloads; each payload type that holds GC values registers a releaser (register_handle_payload_releaser), run at thread exit to clear freed slots or drop the payload.", + "hook": "release_handle_payloads_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/webcrypto/util.rs", + "names": [ + "CRYPTO_KEY_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by the address of a CryptoKey's backing Buffer in the creating thread's arena; entries keyed inside freed blocks are removed at thread exit (the GC death hook never runs there).", + "hook": "release_crypto_keys_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_METHOD_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a stdlib/extension extern \"C\" dispatch function pointer, stored once by js_register_handle_method_dispatch (class_handles.rs:458); code address, never a heap pointer." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_PROPERTY_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a stdlib/extension extern \"C\" dispatch function pointer, stored once by the handle-property dispatch registrar (class_handles.rs:766); code address, never a heap pointer." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_PROPERTY_SET_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a stdlib/extension extern \"C\" dispatch function pointer, stored once by the handle-property-set registrar (class_handles.rs:779); code address, never a heap pointer." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_METHOD_EXTENSION_DISPATCH_PTRS" + ], + "verdict": "no_heap_address", + "why": "ExtensionTable of extension-crate dispatcher function pointers registered via register_extension (class_handles.rs:467); inline AtomicPtr slots plus a leaked Box<[*mut ()]> overflow list of code pointers, no GC heap addresses." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_PROPERTY_EXTENSION_DISPATCH_PTRS" + ], + "verdict": "no_heap_address", + "why": "ExtensionTable of extension-crate dispatcher function pointers registered via register_extension (class_handles.rs:773); inline AtomicPtr slots plus a leaked Box<[*mut ()]> overflow list of code pointers, no GC heap addresses." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_PROPERTY_SET_EXTENSION_DISPATCH_PTRS" + ], + "verdict": "no_heap_address", + "why": "ExtensionTable of extension-crate dispatcher function pointers registered via register_extension (class_handles.rs:786); inline AtomicPtr slots plus a leaked Box<[*mut ()]> overflow list of code pointers, no GC heap addresses." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_OWN_PROPERTY_NAMES_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`HANDLE_OWN_PROPERTY_NAMES_DISPATCH_PTR.store(f as *mut ())`, class_handles.rs:794); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HANDLE_PROTOTYPE_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`HANDLE_PROTOTYPE_DISPATCH_PTR.store(f as *mut ())`, class_handles.rs:800); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "STREAM_HANDLE_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`STREAM_HANDLE_PROBE_PTR.store(f as *mut ())`, class_handles.rs:484); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "STREAM_EXPANDO_SET_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`STREAM_EXPANDO_SET_PTR.store(f as *mut ())`, class_handles.rs:501); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "STREAM_HANDLE_KIND_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`STREAM_HANDLE_KIND_PROBE_PTR.store(f as *mut ())`, class_handles.rs:518); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "FETCH_HANDLE_KIND_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`FETCH_HANDLE_KIND_PROBE_PTR.store(f as *mut ())`, class_handles.rs:535); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_HANDLE_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_HANDLE_PROBE_PTR.store(f as *mut ())`, class_handles.rs:550); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_ASYNC_RESOURCE_HANDLE_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_ASYNC_RESOURCE_HANDLE_PROBE_PTR.store(f as *mut ())`, class_handles.rs:568); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_ASYNC_RESOURCE_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_ASYNC_RESOURCE_DISPATCH_PTR.store(f as *mut ())`, class_handles.rs:585); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_GET_DOMAIN_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_GET_DOMAIN_PTR.store(f as *mut ())`, class_handles.rs:600); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_SET_DOMAIN_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_SET_DOMAIN_PTR.store(f as *mut ())`, class_handles.rs:615); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "NET_SOCKET_HANDLE_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`NET_SOCKET_HANDLE_PROBE_PTR.store(f as *mut ())`, class_handles.rs:630); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "HTTP_AGENT_HANDLE_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`HTTP_AGENT_HANDLE_PROBE_PTR.store(f as *mut ())`, class_handles.rs:655); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "TLS_HANDLE_KIND_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`TLS_HANDLE_KIND_PROBE_PTR.store(f as *mut ())`, class_handles.rs:669); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "FFI_HANDLE_EXISTS_PROBE_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`FFI_HANDLE_EXISTS_PROBE_PTR.store(f as *mut ())`, class_handles.rs:695); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_ON_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_ON_PTR.store(f as *mut ())`, class_handles.rs:718); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_METHOD_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_METHOD_DISPATCH_PTR.store(f as *mut ())`, class_handles.rs:742); code address." + }, + { + "file": "crates/perry-runtime/src/object/class_handles.rs", + "names": [ + "EVENT_EMITTER_PROPERTY_DISPATCH_PTR" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding a single extern \"C\" probe/dispatch function pointer, written only by its js_register_* entry point (`EVENT_EMITTER_PROPERTY_DISPATCH_PTR.store(f as *mut ())`, class_handles.rs:760); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Widget-callback registry of NaN-boxed closures (a GC root the pump invokes); widget creation is main-thread-only on macOS alone, so entries whose closure lies in freed blocks are dropped at thread exit.", + "hook": "release_geisterhand_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "PENDING_ACTIONS" + ], + "verdict": "thread_exit_invalidated", + "why": "Queued InvokeCallback/SetState actions carry NaN-boxed closures and args copied from REGISTRY; those holding a freed value are dropped at thread exit.", + "hook": "release_geisterhand_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_STATE_SET_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_state_set during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_SCREENSHOT_CAPTURE_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_screenshot_capture during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_TEXTFIELD_SET_STRING_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_textfield_set_string during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_SCROLL_SET_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_scroll_set during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_READ_VALUE_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_read_value during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_QUERY_TREE_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_query_tree during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-runtime/src/geisterhand_registry.rs", + "names": [ + "UI_APPLY_STYLE_FN" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the platform UI crate's extern \"C\" function pointer, stored by perry_geisterhand_register_apply_style during UI app init (e.g. perry-ui-windows/src/app.rs:761); code address." + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "FETCH_RESPONSES" + ], + "verdict": "thread_exit_invalidated", + "why": "HashMap keyed by process-global Fetch handle ids (alloc_fetch_handle_id) holding Rust-owned response data plus other handle ids (cached_headers_id, body_stream_id); no raw heap address. Ids are owned per-thread in lifecycle::OWNED and removed by OwnedHandles::drop -> release() (fetch/lifecycle.rs:262-275) when the owning thread exits.", + "hook": "release", + "hook_file": "crates/perry-stdlib/src/fetch/lifecycle.rs" + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "FREE_FETCH_HANDLE_IDS" + ], + "verdict": "no_heap_address", + "why": "Vec free list of recycled Fetch handle ids from the process-global band [FETCH_HANDLE_BAND_START, END); written by lifecycle::release; ids, not addresses." + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "NEXT_FETCH_HANDLE_ID" + ], + "verdict": "no_heap_address", + "why": "Mutex bump counter for the process-global Fetch handle id band (alloc_fetch_handle_id, fetch/mod.rs:241); a counter." + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "STREAM_HANDLES" + ], + "verdict": "no_heap_address", + "why": "HashMap keyed by NEXT_STREAM_ID counter; StreamState holds only status, Vec lines, String partial/error and u16 status (fetch/mod.rs:286); Rust-owned data." + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "NEXT_STREAM_ID" + ], + "verdict": "no_heap_address", + "why": "Mutex counter minting STREAM_HANDLES ids (fetch/mod.rs:988); a counter." + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "HEADERS_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "HashMap whose method_values hold NaN-boxed bound-method closure bits (GC heap values) created on the owning thread; each thread scans only ids it owns (fetch/gc.rs), and OwnedHandles::drop -> release() removes the exiting thread's records (fetch/lifecycle.rs:262-275, 216-220).", + "hook": "release", + "hook_file": "crates/perry-stdlib/src/fetch/lifecycle.rs" + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "REQUEST_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "HashMap whose `signal: f64` is a NaN-boxed AbortSignal GC object allocated on the constructing thread; per-thread ownership via lifecycle::OWNED, and the thread-local OwnedHandles::drop calls release() which removes the exiting thread's records (fetch/lifecycle.rs:222-226, 262-275).", + "hook": "release", + "hook_file": "crates/perry-stdlib/src/fetch/lifecycle.rs" + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "names": [ + "BLOB_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "HashMap where BlobData is Vec body, String content_type, Option/Option File fields (fetch/mod.rs:1101); Rust-owned. Also released per owning thread by OwnedHandles::drop except blobs an object URL still names." + }, + { + "file": "crates/perry-stdlib/src/turnloop_client/mod.rs", + "names": [ + "REUSED" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 lifetime statistics counter (reused fetches/connections) incremented by the turnloop fetch engine and printed by PERRY_LOOP_STATS; a counter." + }, + { + "file": "crates/perry-stdlib/src/turnloop_client/mod.rs", + "names": [ + "CONNECTED" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 lifetime statistics counter (connected fetches/connections) incremented by the turnloop fetch engine and printed by PERRY_LOOP_STATS; a counter." + }, + { + "file": "crates/perry-stdlib/src/turnloop_client/mod.rs", + "names": [ + "REDIRECTS" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 lifetime statistics counter (redirects fetches/connections) incremented by the turnloop fetch engine and printed by PERRY_LOOP_STATS; a counter." + }, + { + "file": "crates/perry-stdlib/src/turnloop_client/mod.rs", + "names": [ + "DECODED" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 lifetime statistics counter (decoded fetches/connections) incremented by the turnloop fetch engine and printed by PERRY_LOOP_STATS; a counter." + }, + { + "file": "crates/perry-stdlib/src/turnloop_client/mod.rs", + "names": [ + "TUNNELS" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 lifetime statistics counter (tunnels fetches/connections) incremented by the turnloop fetch engine and printed by PERRY_LOOP_STATS; a counter." + }, + { + "file": "crates/perry-runtime/src/timer/store.rs", + "names": [ + "PRIMARY_REFED_TIMERS" + ], + "verdict": "no_heap_address", + "why": "AtomicUsize count/flag republished from the primary agent's timer partition by publish_primary (timer/store.rs:828-832) for the event loop's liveness check; a counter." + }, + { + "file": "crates/perry-runtime/src/timer/store.rs", + "names": [ + "PRIMARY_REFED_CHECK" + ], + "verdict": "no_heap_address", + "why": "AtomicUsize count/flag republished from the primary agent's timer partition by publish_primary (timer/store.rs:828-832) for the event loop's liveness check; a counter." + }, + { + "file": "crates/perry-runtime/src/timer/store.rs", + "names": [ + "PRIMARY_CHECK_PENDING" + ], + "verdict": "no_heap_address", + "why": "AtomicUsize count/flag republished from the primary agent's timer partition by publish_primary (timer/store.rs:828-832) for the event loop's liveness check; a counter." + }, + { + "file": "crates/perry-runtime/src/timer/store.rs", + "names": [ + "PRIMARY_ANY_PENDING" + ], + "verdict": "no_heap_address", + "why": "AtomicUsize count/flag republished from the primary agent's timer partition by publish_primary (timer/store.rs:828-832) for the event loop's liveness check; a counter." + }, + { + "file": "crates/perry-runtime/src/arena/quarantine.rs", + "names": [ + "CACHED" + ], + "verdict": "no_heap_address", + "why": "OnceLock caching the parsed PERRY_GC_PROTECT_FROMSPACE_DEPTH env knob in quarantine_depth() (quarantine.rs:207); config value. | (second declaration) OnceLock caching sysconf(_SC_PAGESIZE) in page_size() (quarantine.rs:220); OS config value." + }, + { + "file": "crates/perry-runtime/src/arena/quarantine.rs", + "names": [ + "REGISTRY" + ], + "verdict": "process_global_allocation", + "why": "Vec of from-space arena blocks that copying_quarantine_from_spaces_and_flip has DETACHED (tombstoned) from the retiring thread's arena, so that arena's Arena::drop no longer owns or frees them; they stay owned by this registry until evicted and reinstalled into the evicting thread's Eden (push_set_and_evict, quarantine.rs:477-510)." + }, + { + "file": "crates/perry-runtime/src/error_stack_frames.rs", + "names": [ + "IMAGE_BASE" + ], + "verdict": "no_heap_address", + "why": "OnceLock caching the executable's load base (dladdr dli_fbase) for symbolizing stack frames; image address, not heap." + }, + { + "file": "crates/perry-runtime/src/error_stack_frames.rs", + "names": [ + "INDEX" + ], + "verdict": "no_heap_address", + "why": "OnceLock> of relocated text-symbol start addresses and demangled String names loaded from the executable image (error_stack_frames.rs:429-438); code addresses. | (second declaration) OnceLock>> of (generated function start address, Arc<[u8]> display name) snapshotted from the codegen function registry (error_stack_frames.rs:581-592); code addresses." + }, + { + "file": "crates/perry-stdlib/src/sqlite.rs", + "names": [ + "NODE_SQLITE_CUSTOM_FUNCTIONS" + ], + "verdict": "thread_exit_invalidated", + "why": "Addresses of SQLite-owned boxes whose callback is a closure from the thread that called db.function(); a freed callback is set to undefined and the box unregistered at thread exit (xDestroy still frees it).", + "hook": "release_node_sqlite_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/sqlite.rs", + "names": [ + "NODE_SQLITE_CUSTOM_AGGREGATES" + ], + "verdict": "thread_exit_invalidated", + "why": "Boxes holding db.aggregate() start/step/result/inverse closures; freed slots become undefined and the box is unregistered at thread exit.", + "hook": "release_node_sqlite_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/sqlite.rs", + "names": [ + "NODE_SQLITE_ACTIVE_AGGREGATES" + ], + "verdict": "thread_exit_invalidated", + "why": "Running-aggregate state boxes; a freed state becomes undefined and the box is unregistered at thread exit, xFinal frees it as before.", + "hook": "release_node_sqlite_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/net.rs", + "names": [ + "TCP_SERVERS" + ], + "verdict": "no_heap_address", + "why": "HashMap keyed by the process-global NEXT_HANDLE counter, holding std TcpListener + SocketAddr (net.rs:17-25); OS resources, no heap addresses." + }, + { + "file": "crates/perry-runtime/src/net.rs", + "names": [ + "TCP_SOCKETS" + ], + "verdict": "no_heap_address", + "why": "HashMap keyed by the process-global NEXT_HANDLE counter, holding std TcpStream + Option (net.rs:19-30); OS resources." + }, + { + "file": "crates/perry-runtime/src/object/nm_namespace_hooks.rs", + "names": [ + "NM_NAMESPACE_OPS" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr to a `&'static NmNamespaceOps` function-pointer table, stored by arm_nm_namespace_ops (nm_namespace_hooks.rs:51); address of a static." + }, + { + "file": "crates/perry-runtime/src/object/nm_namespace_hooks.rs", + "names": [ + "NM_EE_OPS" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr to a `&'static NmEeOps` function-pointer table, stored by arm_nm_ee_ops (nm_namespace_hooks.rs:83); address of a static." + }, + { + "file": "crates/perry-stdlib/src/fetch_blob.rs", + "names": [ + "OBJECT_URL_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "HashMap mapping blob: URLs to process-global Fetch Blob handle ids (fetch_blob.rs:306); blobs it names are deliberately kept by OwnedHandles::drop and BlobData is Rust-owned bytes." + }, + { + "file": "crates/perry-stdlib/src/fetch_blob.rs", + "names": [ + "NEXT_OBJECT_URL_ID" + ], + "verdict": "no_heap_address", + "why": "Mutex counter used to mint the uuid-shaped object URL (fetch_blob.rs:299); a counter." + }, + { + "file": "crates/perry-runtime/src/arkts_callbacks.rs", + "names": [ + "CALLBACKS" + ], + "verdict": "main_thread_only", + "why": "Mutex> of NaN-boxed GC closures written by perry_arkts_register_callback (arkts_callbacks.rs:131), which codegen emits only for the synthetic perry/arkts.registerCallback calls that bootstrap.rs:1150-1180 splices into the ENTRY module's init (module-init-only path, run from EntryAbility.onCreate on the main thread)." + }, + { + "file": "crates/perry-runtime/src/child_process/registry.rs", + "names": [ + "PROCESS_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "HashMap keyed by the process-global NEXT_HANDLE_ID counter; OS process handles, no heap addresses." + }, + { + "file": "crates/perry-runtime/src/fs/filehandle.rs", + "names": [ + "READABLE_WEB_STREAM_FACTORY" + ], + "verdict": "no_heap_address", + "why": "AtomicPtr holding the perry-stdlib extern \"C\" Web Streams factory function pointer, stored by js_register_filehandle_readable_web_stream_factory (filehandle.rs:34); code address." + }, + { + "file": "crates/perry-runtime/src/node_api_host/lifecycle.rs", + "names": [ + "NODE_VERSION" + ], + "verdict": "no_heap_address", + "why": "LazyLock of u32 major/minor/patch parsed from CARGO_PKG_VERSION plus `release` pointing at the c\"perry\" .rodata literal; no heap address." + }, + { + "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_guard_fast.rs", + "names": [ + "PRIVATE_TEMPLATE_EVALUATED" + ], + "verdict": "no_heap_address", + "why": "Monotonic hashed bitset of class ids (class_id & 0xFFFF) set by note_private_template_evaluated when a class object is minted (private_guard_fast.rs:108-111); class ids, not addresses." + }, + { + "file": "crates/perry-runtime/src/plugin.rs", + "names": [ + "REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "(feature full) Plugin hook/tool/service/route/event closures and config values registrable from any thread; registrations holding a freed value are dropped at thread exit.", + "hook": "release_plugin_registry_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/string/concat.rs", + "names": [ + "MEMO_GOV" + ], + "verdict": "no_heap_address", + "why": "AtomicU64 packing the concat-memo governor's enabled bit, hit count and window candidate count (concat.rs:310-363); counters/flags." + }, + { + "file": "crates/perry-runtime/src/tui/state.rs", + "names": [ + "SLOTS" + ], + "verdict": "thread_exit_invalidated", + "why": "Index-addressed NaN-boxed TUI state slots (GC roots); freed values are reset to undefined in place at thread exit because indices are handles.", + "hook": "release_tui_state_slots_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/container/mod.rs", + "names": [ + "BACKEND" + ], + "verdict": "no_heap_address", + "why": "OnceLock> holding the Rust-owned container runtime backend detected once by get_global_backend (container/mod.rs:63-90); Rust heap object, no GC addresses." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_ARRAY_GET" + ], + "verdict": "no_heap_address", + "why": "Stores the handle-array element getter installed by value/handle.rs:14 (js_register_handle_array_get); every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_ARRAY_LENGTH" + ], + "verdict": "no_heap_address", + "why": "Stores the handle-array length hook installed from value/handle.rs; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_OBJECT_GET_PROPERTY" + ], + "verdict": "no_heap_address", + "why": "Stores the handle object property-get hook installed from value/handle.rs; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_TO_STRING" + ], + "verdict": "no_heap_address", + "why": "Stores the handle toString hook installed from value/handle.rs; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_CALL_METHOD" + ], + "verdict": "no_heap_address", + "why": "Stores the handle method-call dispatcher registered by perry-stdlib at startup; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_MODULE_JS_LOADER" + ], + "verdict": "no_heap_address", + "why": "Stores the native-module JS loader hook registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NEW_FROM_HANDLE_V8" + ], + "verdict": "no_heap_address", + "why": "Stores the V8-handle `new` constructor hook registered by the V8 bridge; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_HANDLE_TYPEOF" + ], + "verdict": "no_heap_address", + "why": "Stores the handle typeof hook registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_CRYPTO_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:crypto dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_WEBCRYPTO_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the WebCrypto dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_ZLIB_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:zlib dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_QUERYSTRING_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:querystring dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_BUN_TCP_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the Bun TCP dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_NET_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:net dispatch fn registered by perry-stdlib (reset to null only in object/native_module_dispatch/dispatch_m_p.rs:924); every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_SQLITE_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the sqlite dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_DOMAIN_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:domain dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_TLS_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:tls dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_HTTP_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the node:http dispatch fn registered by perry-stdlib; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_EVENTS_CONSTRUCT" + ], + "verdict": "no_heap_address", + "why": "Stores the events `new` constructor fn (JsNativeEventsConstructFn) registered by the events impl; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_EVENTS_DISPATCH" + ], + "verdict": "no_heap_address", + "why": "Stores the module-level events.* helper dispatch fn registered by the events impl; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_ASYNC_HOOKS_CONSTRUCT" + ], + "verdict": "no_heap_address", + "why": "Stores the dynamic async_hooks constructor fn registered by perry-stdlib at startup; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/value/tags.rs", + "names": [ + "JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT" + ], + "verdict": "no_heap_address", + "why": "Stores js_async_local_storage_subclass_init's fn pointer, stored by value/handle.rs:159; every store in the tree is `func as *mut ()` (or null), so it is a code address, never a heap value." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "MARK_BARRIER_ARMED_SINCE_US" + ], + "verdict": "no_heap_address", + "why": "Microseconds since the process epoch at which the current mark-barrier armed window opened (0 = closed); a timestamp set/cleared by the collector's arm/disarm instruments (instruments.rs:145/150)." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "SKIP_REENTRANT" + ], + "verdict": "no_heap_address", + "why": "Telemetry counter of budgeted GC steps skipped for the reentrant reason, bumped by note_budgeted_step_skip; a plain count." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "SKIP_NO_TRIGGER" + ], + "verdict": "no_heap_address", + "why": "Telemetry counter of budgeted GC steps skipped for the no-trigger reason, bumped by note_budgeted_step_skip; a plain count." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "SKIP_START_BLOCKED" + ], + "verdict": "no_heap_address", + "why": "Telemetry counter of budgeted GC steps skipped for the start-blocked reason, bumped by note_budgeted_step_skip; a plain count." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "SKIP_RESUME_BLOCKED" + ], + "verdict": "no_heap_address", + "why": "Telemetry counter of budgeted GC steps skipped for the resume-blocked reason, bumped by note_budgeted_step_skip; a plain count." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "SKIP_NURSERY_CAP_UNDISCHARGEABLE" + ], + "verdict": "no_heap_address", + "why": "Telemetry counter of budgeted GC steps skipped for the nursery-cap-undischargeable reason, bumped by note_budgeted_step_skip; a plain count." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "STEP_MAX_US" + ], + "verdict": "no_heap_address", + "why": "High-water mark of a single budgeted GC step's duration in microseconds (bump_max telemetry); a duration." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "REMARK_MAX_US" + ], + "verdict": "no_heap_address", + "why": "High-water mark of the atomic final-remark duration in microseconds; a duration." + }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "names": [ + "WEAK_MAX_RECORDS_PER_STEP" + ], + "verdict": "no_heap_address", + "why": "Most FinalizationRegistry records charged to one weak-processing step (bump_max at instruments.rs:399, reset at :464); a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "SINK" + ], + "verdict": "no_heap_address", + "why": "Lazily resolved diagnostic output sink from PERRY_SHAPE_MINT_DIAG (hot_diag::sink_from_env); Rust-owned file/stream config." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "CENSUS" + ], + "verdict": "no_heap_address", + "why": "Diagnostic-only shape-mint tallies (HashMaps of causes, call sites, ShapeIds, counts). It does record keys-array addresses (keys_addrs, :452) and prototype bits (proto_div_pairs, :283) but only as opaque distinct-count keys that are never dereferenced or used for lookup of an object." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_EMPTY" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind empty, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_COLLIDE" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind collide, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_PLACES" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind places, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_UNSHARED" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind unshared, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_TARGET_LEN" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind target_len, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/object/shape_mint_census.rs", + "names": [ + "TC_MISS_UNSTABLE" + ], + "verdict": "no_heap_address", + "why": "Diagnostic counter of transition-cache misses of kind unstable, bumped by note_transition_miss when the census is armed; a count." + }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "names": [ + "SAMPLE_INTERVAL" + ], + "verdict": "no_heap_address", + "why": "Allocation-census sampling interval in bytes, parsed from PERRY_ALLOC_CENSUS_INTERVAL via getenv at first allocation; config value." + }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "names": [ + "CLASS_ALLOC_BYTES" + ], + "verdict": "no_heap_address", + "why": "Per-size-class cumulative allocated-byte counters updated by the CensusAlloc GlobalAlloc wrapper; counts." + }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "names": [ + "CLASS_ALLOC_COUNT" + ], + "verdict": "no_heap_address", + "why": "Per-size-class allocation-count counters updated by the CensusAlloc wrapper; counts." + }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "names": [ + "CLASS_LIVE_BYTES" + ], + "verdict": "no_heap_address", + "why": "Per-size-class live-byte gauges (alloc minus free) updated by the CensusAlloc wrapper; counts." + }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "names": [ + "SITES" + ], + "verdict": "process_global_allocation", + "why": "Sampled allocation-site table; `live` maps pointers returned by the process GlobalAlloc (CensusAlloc wrapping the system/mimalloc allocator, alloc_census.rs:232) to site ids, and record_free (:265) removes the entry on every dealloc — including arena blocks released through the allocator — so addresses are never stale." + }, + { + "file": "crates/perry-runtime/src/closure/dynamic_props.rs", + "names": [ + "CLOSURE_PROPS" + ], + "verdict": "thread_exit_invalidated", + "why": "Closure-address -> dynamic props map written by closure property sets on any thread; entries whose owner lies in the exiting arena's blocks are dropped by release_closure_side_table_owners_in_ranges (dynamic_props.rs:465), called from Arena::drop (arena/block.rs:539).", + "hook": "release_closure_side_table_owners_in_ranges" + }, + { + "file": "crates/perry-runtime/src/closure/dynamic_props.rs", + "names": [ + "WASM_FUNCREF_EXTERNALS" + ], + "verdict": "thread_exit_invalidated", + "why": "(wasm-host feature) closure-address -> host funcref handle map; entries in the exiting arena's ranges are removed and their handles dropped by release_closure_side_table_owners_in_ranges (dynamic_props.rs:474-491) from Arena::drop.", + "hook": "release_closure_side_table_owners_in_ranges" + }, + { + "file": "crates/perry-runtime/src/closure/dynamic_props.rs", + "names": [ + "CLOSURE_DELETED_KEYS" + ], + "verdict": "thread_exit_invalidated", + "why": "Closure-address -> deleted property names, written by closure_mark_key_deleted; purged over the exiting arena's ranges by release_closure_side_table_owners_in_ranges (dynamic_props.rs:471) from Arena::drop.", + "hook": "release_closure_side_table_owners_in_ranges" + }, + { + "file": "crates/perry-runtime/src/closure/dynamic_props.rs", + "names": [ + "CLOSURE_STATIC_PROTOTYPES" + ], + "verdict": "thread_exit_invalidated", + "why": "Closure-address -> NaN-boxed prototype bits from Object.setPrototypeOf(fn, proto); purged by owner range in release_closure_side_table_owners_in_ranges (dynamic_props.rs:468, via get_closure_prototypes) from Arena::drop.", + "hook": "release_closure_side_table_owners_in_ranges" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "names": [ + "PERRY_TA_KIND_CACHE" + ], + "verdict": "thread_exit_invalidated", + "why": "Direct-mapped process-global cache of (typed-array address << 8 | kind); Arena::drop clears every slot naming a freed block with a compare-exchange (#11463/#11467).", + "hook": "invalidate_caches_in_range" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "names": [ + "INLINE_OWNING_U32_CACHE" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global cache of owning-Uint32Array addresses admitted by the codegen inline path; cleared for freed blocks by the same Arena::drop call (#11467).", + "hook": "invalidate_caches_in_range" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "names": [ + "PERRY_TA_VIEW_GUARD" + ], + "verdict": "no_heap_address", + "why": "#[no_mangle] count of live non-owning typed-array views, inc/dec by the view register/unregister paths; a counter (a worker exiting with views live only leaves it high, which disables the inline fast path, never makes it unsound)." + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "names": [ + "TYPED_ARRAY_ADDR_WINDOW" + ], + "verdict": "conservative_filter", + "why": "Min/max address ever passed to register_typed_array (widened at typedarray/mod.rs:404); a stale window only answers 'maybe', after which the authoritative per-thread TYPED_ARRAY_REGISTRY (thread-local, :180) decides." + }, + { + "file": "crates/perry-runtime/src/dgram_reactor.rs", + "names": [ + "LIVE" + ], + "verdict": "thread_exit_invalidated", + "why": "(feature mod-dgram) Entries root the binding thread's socket object and pending send callbacks; entries naming freed memory are dropped at thread exit with LIVE/REFED/TURNLOOP counts adjusted as drop_entry does.", + "hook": "release_dgram_sockets_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/dgram_reactor.rs", + "names": [ + "QUEUE" + ], + "verdict": "no_heap_address", + "why": "Received datagrams awaiting pump(): each is a socket id (u64), an owned Vec payload and a SocketAddr; no heap addresses (a stale id just misses or hits LIVE, whose own staleness is reported separately)." + }, + { + "file": "crates/perry-runtime/src/dgram_reactor.rs", + "names": [ + "NEXT_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic process-global counter minting dgram socket ids for the process-global LIVE table." + }, + { + "file": "crates/perry-runtime/src/thread.rs", + "names": [ + "FS_THREAD_CODEC" + ], + "verdict": "no_heap_address", + "why": "Pointer to the `static FS_THREAD_CODEC_IMPL` (fs/filehandle.rs:1470) holding two fn pointers, armed by arm_fs_thread_codec; address of a static." + }, + { + "file": "crates/perry-runtime/src/thread.rs", + "names": [ + "PENDING_THREAD_RESULTS" + ], + "verdict": "thread_exit_invalidated", + "why": "Queue of spawn results: owner AgentId, raw promise address in the owner's heap, and a SerializedValue; drained only by the owning agent, and agent::retire_agent (agent.rs:164) calls thread::purge_agent_thread_results (thread.rs:1926) to drop every entry owned by an exiting worker agent.", + "hook": "purge_agent_thread_results" + }, + { + "file": "crates/perry-runtime/src/thread.rs", + "names": [ + "PENDING_THREAD_RESULTS_LEN" + ], + "verdict": "no_heap_address", + "why": "Mirror of PENDING_THREAD_RESULTS.len() republished under its lock for the lock-free has-pending check; a count." + }, + { + "file": "crates/perry-runtime/src/gc/copying.rs", + "names": [ + "PREVIOUS_SURVIVOR_ESTIMATE" + ], + "verdict": "no_heap_address", + "why": "Survivor count of the previous copying minor, used only to pre-size worklist vectors (capped at SURVIVOR_ESTIMATE_CAP); a count." + }, + { + "file": "crates/perry-runtime/src/gc/copying.rs", + "names": [ + "PREVIOUS_DIRTY_COVERED_ESTIMATE" + ], + "verdict": "no_heap_address", + "why": "Previous minor's dirty-scan covered-set size used to pre-size the next one; a capped count." + }, + { + "file": "crates/perry-runtime/src/node_vm.rs", + "names": [ + "VM_COMPILED_FUNCTION_SOURCES" + ], + "verdict": "thread_exit_invalidated", + "why": "vm.compileFunction source text keyed by closure address; owners in freed ranges are dropped at thread exit.", + "hook": "release_vm_owner_entries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/node_vm.rs", + "names": [ + "VM_SCRIPTS" + ], + "verdict": "thread_exit_invalidated", + "why": "vm.Script metadata keyed by owner address; the death prune keeps other threads' owners, so owners in freed ranges are dropped at thread exit.", + "hook": "release_vm_owner_entries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/promise/native_async.rs", + "names": [ + "REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Native-async tokens hold promise/payload/handle bits from the creating thread; tokens naming freed memory are completed and removed at thread exit, and a retire hook drops a dead agent's idle tokens.", + "hook": "release_native_async_tokens_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/promise/native_async.rs", + "names": [ + "ACTIVE_ENTRIES" + ], + "verdict": "no_heap_address", + "why": "Mirror of tokens.len()+pending.len() republished under the registry lock for the event loop's keep-alive check; a count." + }, + { + "file": "crates/perry-stdlib/src/streams/idalloc.rs", + "names": [ + "IDALLOC" + ], + "verdict": "no_heap_address", + "why": "Web Streams handle-id allocator state (next id, free/retired VecDeques, pooled/pipe HashSets of ids) in the fixed STREAM_HANDLE_ID band; ids index the process-global READABLE_STREAMS/WRITERS/... tables (streams.rs:575), no addresses." + }, + { + "file": "crates/perry-stdlib/src/streams/idalloc.rs", + "names": [ + "QUARANTINE" + ], + "verdict": "no_heap_address", + "why": "Stream-id quarantine length limit read once from PERRY_STREAM_ID_QUARANTINE; a config value." + }, + { + "file": "crates/perry-runtime/src/cluster_sched.rs", + "names": [ + "KEYS" + ], + "verdict": "no_heap_address", + "why": "Primary-side cluster listen keys: String key -> Arc holding the bound port, key id, rr flag and a Vec of worker child-process handle numbers (__cpHandle, cluster.rs:1220); no heap addresses." + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "names": [ + "GENERATED_WRITE_BARRIERS_EMITTED" + ], + "verdict": "no_heap_address", + "why": "Flag/count recording whether codegen emitted write barriers (per_test_global); a counter, not an address." + }, + { + "file": "crates/perry-runtime/src/node_api_host/tsfn.rs", + "names": [ + "TSFN_REGISTRY" + ], + "verdict": "process_global_allocation", + "why": "Keyed by N-API threadsafe-function token addresses from Box::into_raw at tsfn.rs:146, which are intentionally never freed (permanent tombstones), so keys cannot be reused. Values are Arc whose JS function_bits are only touched on the owner thread (owner ThreadId check in drain_threadsafe_functions :401 / owner_env :62) and rooted through the per-thread NODE_API_ENV env.tsfns." + }, + { + "file": "crates/perry-runtime/src/object/field_get_set/ic_slot.rs", + "names": [ + "PIC_ARENA" + ], + "verdict": "process_global_allocation", + "why": "Bump cursor/end over 64 KiB chunks from std::alloc::alloc_zeroed (ic_slot.rs:73) holding inline-cache words; never freed and not a GC arena." + }, + { + "file": "crates/perry-runtime/src/pointer_event.rs", + "names": [ + "CELL" + ], + "verdict": "main_thread_only", + "why": "Three NaN-boxed interned strings ('mouse'/'touch'/'pen') from js_string_from_bytes_longlived (per-thread longlived arena), created on first js_pointer_event_new." + }, + { + "file": "crates/perry-runtime/src/symbol/properties.rs", + "names": [ + "CACHED" + ], + "verdict": "process_global_allocation", + "why": "Caches the SymbolHeader* for Symbol.for('NextInternalRequestMeta'); registered symbols are Box::into_raw leaked allocations (symbol/constructors.rs:~104), never in a thread arena." + }, + { + "file": "crates/perry-runtime/src/tui/tree.rs", + "names": [ + "REGISTRY" + ], + "verdict": "no_heap_address", + "why": "TUI widget table of (i64 handle from NEXT_HANDLE, Node) where Node holds child handle ids, Strings, colors and styles; Rust-owned data, no addresses." + }, + { + "file": "crates/perry-stdlib/src/crypto/hash_handles.rs", + "names": [ + "CRYPTO_STREAM_PENDING_EVENTS" + ], + "verdict": "no_heap_address", + "why": "Queue of crypto Hash/Hmac stream events: a handle id into the process-global common::handle HANDLES DashMap plus owned bytes/encoding; the static itself holds no addresses." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_RUN_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_DRAIN_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_EMPTY_DRAIN_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_THENABLE_PROBE_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_PROMISE_NEW_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_PROMISE_THEN_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_PROMISE_RESOLVED_COUNT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_FAST_PATH_HIT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_FAST_PATH_MISS" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_STEP_CHAIN_REUSE_HIT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_STEP_CHAIN_REUSE_MISS" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_STEP_DONE_REUSE_HIT" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_STEP_DONE_REUSE_MISS" + ], + "verdict": "no_heap_address", + "why": "Instrumentation counter (PERRY_MT_PROFILE); only ever fetch_add'd, printed at exit — never an address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "FOREIGN_PROMISE_ADAPTER_FN" + ], + "verdict": "no_heap_address", + "why": "Stores the extern \"C\" fn pointer registered by perry-jsruntime via js_register_foreign_promise_adapter (promise/mod.rs:194); code address." + }, + { + "file": "crates/perry-runtime/src/promise/mod.rs", + "names": [ + "MT_ITER_RESULT_SET_COUNT" + ], + "verdict": "no_heap_address", + "why": "Counter of js_iter_result_set calls; fetch_add only, never an address." + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "READABLE_STREAMS" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold the creating thread's source callbacks, queued chunks, pending read promises and error values; dropped when any lies in the freed blocks or a linked reader/transform is dead.", + "hook": "release_web_streams_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "WRITABLE_STREAMS" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold sink callbacks, queued chunk bits with write promises, and ready/closed promises from the creating thread's arena; dropped with the dead stream family.", + "hook": "release_web_streams_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "TRANSFORM_STREAMS" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold transform/flush closure pointers; dropped together with their readable and writable sides.", + "hook": "release_web_streams_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "READERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold the closed promise from the thread that called getReader(); released with their stream.", + "hook": "release_web_streams_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "WRITERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold closed/ready promises from the writing thread; released with their stream.", + "hook": "release_web_streams_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams.rs", + "names": [ + "N" + ], + "verdict": "no_heap_address", + "why": "Function-local debug-print rate limiter at streams.rs:1244 (PERRY_JSON_PATH=1); fetch_add counter only. | (second declaration) Function-local debug-print rate limiter at streams.rs:1782 (PERRY_JSON_PATH=1); fetch_add counter only. | (second declaration) Function-local debug-print rate limiter at streams.rs:1864 (PERRY_JSON_PATH=1); fetch_add counter only. | (second declaration) Function-local debug-print rate limiter at streams.rs:1968 (PERRY_JSON_PATH=1); fetch_add counter only. | (second declaration) Function-local debug-print rate limiter at streams.rs:2013 (PERRY_JSON_PATH=1); fetch_add counter only." + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "TOP_LEVEL_RESOURCE" + ], + "verdict": "thread_exit_invalidated", + "why": "Lazily cached JS object allocated on the first caller's thread; compare-exchanged to empty at thread exit if it names freed memory.", + "hook": "release_async_hooks_in_freed_ranges", + "hook_file": "crates/perry-runtime/src/async_hooks/thread_exit.rs" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "HOOKS" + ], + "verdict": "thread_exit_invalidated", + "why": "createHook records hold raw closure pointers; a dead record is disabled and its callbacks nulled at thread exit, with the active counts adjusted.", + "hook": "release_async_hooks_in_freed_ranges", + "hook_file": "crates/perry-runtime/src/async_hooks/thread_exit.rs" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "RESOURCES" + ], + "verdict": "thread_exit_invalidated", + "why": "Async resource entries whose resource value or captured ALS store lies in freed memory are dropped at thread exit without a destroy event.", + "hook": "release_async_hooks_in_freed_ranges", + "hook_file": "crates/perry-runtime/src/async_hooks/thread_exit.rs" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "GC_DESTROY_QUEUE" + ], + "verdict": "no_heap_address", + "why": "VecDeque of async ids queued by enqueue_gc_destroy (async_hooks.rs:1118); ids come from the process-global NEXT_ASYNC_ID counter, not addresses." + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "CONTEXT_SNAPSHOTS" + ], + "verdict": "thread_exit_invalidated", + "why": "ALS snapshots holding a freed store are dropped at thread exit.", + "hook": "release_async_hooks_in_freed_ranges", + "hook_file": "crates/perry-runtime/src/async_hooks/thread_exit.rs" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "ASYNC_WRAP_PROVIDERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Lazily cached providers object allocated on the first caller's thread; compare-exchanged to empty at thread exit if it names freed memory.", + "hook": "release_async_hooks_in_freed_ranges", + "hook_file": "crates/perry-runtime/src/async_hooks/thread_exit.rs" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "ASYNC_RESOURCE_HANDLES" + ], + "verdict": "process_global_allocation", + "why": "Set of AsyncResource handles, each a Box::into_raw(AsyncResourceHandle) pointer (async_hooks.rs:1161-1169) that is never freed — malloc, not arena." + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "ASYNC_HOOK_HANDLES" + ], + "verdict": "process_global_allocation", + "why": "Set of AsyncHook handles, each a never-freed Box::into_raw(AsyncHookHandle{index}) pointer (async_hooks.rs:740-741) — std alloc, not arena." + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "EXTERNAL_BUFFER_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Addresses of externally-registered buffers, removed only by the dead-buffer finalizer or re-registration; freed addresses are dropped at thread exit.", + "hook": "release_external_buffer_registries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "EXTERNAL_UINT8ARRAY_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Addresses of externally-marked Uint8Arrays; same removal gap, freed addresses dropped at thread exit.", + "hook": "release_external_buffer_registries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "EXTERNAL_CRYPTO_KEY_META_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "CryptoKey metadata keyed by buffer address; freed addresses dropped at thread exit.", + "hook": "release_external_buffer_registries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "CRYPTO_KEY_DEATH_HOOK" + ], + "verdict": "no_heap_address", + "why": "Stores the extern \"C\" fn pointer installed once by perry-stdlib via js_set_crypto_key_death_hook (header.rs:80); code address." + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "BUFFER_LIKE_ADDR_WINDOW" + ], + "verdict": "conservative_filter", + "why": "Monotone lo/hi address window over every buffer-like registration; a stale bound only admits more addresses to the slow path. Authoritative: BUFFER_REGISTRY (thread-local), EXTERNAL_BUFFER_REGISTRY, shared_sab's registry via is_registered_buffer_slow." + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "UINT8ARRAY_ADDR_WINDOW" + ], + "verdict": "conservative_filter", + "why": "Monotone lo/hi window over Uint8Array marks; staleness only widens admission. Authoritative: UINT8ARRAY_FROM_CTOR (thread-local) and EXTERNAL_UINT8ARRAY_REGISTRY via is_uint8array_buffer_slow." + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "names": [ + "PERRY_U8_INLINE_CACHE" + ], + "verdict": "thread_exit_invalidated", + "why": "Inline Uint8Array address cache read by codegen guards; each slot is compare-exchanged to 0 at thread exit if it names freed memory.", + "hook": "release_external_buffer_registries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/gc/heap_budget.rs", + "names": [ + "CACHED" + ], + "verdict": "no_heap_address", + "why": "OnceLock> caching PERRY_GC_HEAP_LIMIT / platform heap budget in bytes (gc_heap_budget_bytes); config value. | (second declaration) Macro-generated (budget_scaled_accessor!) OnceLock caching a budget-scaled byte threshold; config value. | (second declaration) OnceLock caching gc_block_pool_cap_bytes (recycled arena-block allowance in bytes); config value. | (second declaration) OnceLock caching gc_rss_pressure_dyn_bytes threshold; config value. | (second declaration) OnceLock caching gc_rss_hard_pressure_dyn_bytes threshold; config value." + }, + { + "file": "crates/perry-runtime/src/event_pump/loop_stats.rs", + "names": [ + "NOTIFY_AT_NS" + ], + "verdict": "no_heap_address", + "why": "Monotonic timestamp (ns) of the last main-thread notify, set by notifier/cleared by waiter for wake-latency stats (loop_stats.rs:221/237/259)." + }, + { + "file": "crates/perry-runtime/src/event_pump/loop_stats.rs", + "names": [ + "TURNLOOP" + ], + "verdict": "no_heap_address", + "why": "Kind{count,total_ns,max_ns} atomic statistics for turnloop parked waits; counters only." + }, + { + "file": "crates/perry-runtime/src/event_pump/loop_stats.rs", + "names": [ + "CONDVAR" + ], + "verdict": "no_heap_address", + "why": "Kind{count,total_ns,max_ns} atomic statistics for condvar parks; counters only." + }, + { + "file": "crates/perry-runtime/src/event_pump/loop_stats.rs", + "names": [ + "WAKE_BUCKETS" + ], + "verdict": "no_heap_address", + "why": "Wake-latency histogram bucket counters; counters only." + }, + { + "file": "crates/perry-stdlib/src/common/async_bridge.rs", + "names": [ + "PENDING_RESOLUTIONS" + ], + "verdict": "thread_exit_invalidated", + "why": "Queue of (owner AgentId, promise_ptr arena address, result_bits) queued by native work; purged for the dying agent by purge_agent_resolutions, registered via register_retire_hook and run from perry-runtime/src/agent.rs:retire_agent (called at worker exit: thread.rs:1220/1473/1708, worker_threads.rs:1482).", + "hook": "purge_agent_resolutions" + }, + { + "file": "crates/perry-stdlib/src/common/async_bridge.rs", + "names": [ + "PENDING_DEFERRED" + ], + "verdict": "thread_exit_invalidated", + "why": "Queue of (owner AgentId, promise_ptr, converter) — purged per agent by async_bridge.rs purge_agent_resolutions from agent.rs:retire_agent at worker exit.", + "hook": "purge_agent_resolutions" + }, + { + "file": "crates/perry-stdlib/src/common/async_bridge.rs", + "names": [ + "PENDING_RESOLUTIONS_LEN" + ], + "verdict": "no_heap_address", + "why": "Length mirror of PENDING_RESOLUTIONS republished under its lock for the keep-alive check; counter." + }, + { + "file": "crates/perry-stdlib/src/common/async_bridge.rs", + "names": [ + "PENDING_DEFERRED_LEN" + ], + "verdict": "no_heap_address", + "why": "Length mirror of PENDING_DEFERRED; counter." + }, + { + "file": "crates/perry-runtime/src/frame.rs", + "names": [ + "FRAME_CALLBACKS" + ], + "verdict": "thread_exit_invalidated", + "why": "Pending onFrame raw closure pointers from js_on_frame_callback (no thread check); freed ones are dropped at thread exit so js_frame_tick never calls a dead closure.", + "hook": "release_frame_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/frame.rs", + "names": [ + "NEXT_FRAME_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic i64 id counter for onFrame registrations." + }, + { + "file": "crates/perry-runtime/src/frame.rs", + "names": [ + "LAST_FIRE_BY_CLOSURE" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by closure address; keys in freed blocks are removed so a closure reusing the address does not inherit a stale deltaMs timestamp.", + "hook": "release_frame_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/bun_ffi/callback.rs", + "names": [ + "CALLBACKS" + ], + "verdict": "per_thread", + "why": "Fixed pool of bun:ffi JSCallback records with callback_bits (JS closure) tagged by owner std::thread::ThreadId; only the owner thread scans (callback.rs:692-698) or invokes it (:602, drain :646-656); ThreadIds are never reused, so an exited owner's record is never dereferenced." + }, + { + "file": "crates/perry-runtime/src/bun_ffi/callback.rs", + "names": [ + "PENDING_CALLBACKS" + ], + "verdict": "no_heap_address", + "why": "Queue of foreign-thread callback requests: slot index, copied native ABI register images, and an Arc completion; no GC heap addresses." + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "names": [ + "CACHED" + ], + "verdict": "no_heap_address", + "why": "OnceLock caching PERRY_GC_SCAVENGE_NURSERY_MB nursery cap bytes; config value." + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "names": [ + "CONFIG" + ], + "verdict": "no_heap_address", + "why": "OnceLock<(usize,usize)> caching major-GC pacing floor bytes and growth numerator from env; config." + }, + { + "file": "crates/perry-runtime/src/object/descriptor_state.rs", + "names": [ + "DECLARED_FIELD_NAME_HASHES" + ], + "verdict": "no_heap_address", + "why": "Set of FNV hashes of declared instance-field names, written at class registration; hashes, not addresses." + }, + { + "file": "crates/perry-runtime/src/object/descriptor_state.rs", + "names": [ + "PROTO_DESCRIPTOR_KEY_HASHES" + ], + "verdict": "no_heap_address", + "why": "Set of FNV key-name hashes installed on prototype descriptor targets; hashes, not addresses." + }, + { + "file": "crates/perry-runtime/src/pty/reactor.rs", + "names": [ + "PTY_EVENT_QUEUE" + ], + "verdict": "no_heap_address", + "why": "Events from pty reader/waiter OS threads: registry handle u64 plus owned byte Vec / exit code; no heap addresses." + }, + { + "file": "crates/perry-runtime/src/pty/reactor.rs", + "names": [ + "PTY_LIVE" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold the spawning thread's IPty object; entries whose object is freed are removed at thread exit with counts adjusted (the master fd is deliberately leaked).", + "hook": "release_ptys_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "names": [ + "WORKERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Records hold the constructing thread's Worker object, listeners, terminate promise and resource objects (nested Workers exist); any freed slot drops the record after releasing its refed share.", + "hook": "release_worker_records_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "names": [ + "PARENT_EVENTS" + ], + "verdict": "thread_exit_invalidated", + "why": "Queue of (parent AgentId, WorkerEvent) with owned SerializedValue payloads (no arena addresses); entries for a dying agent are purged by worker_threads/worker_pump.rs:purge_parent_events via agent.rs:retire_agent.", + "hook": "purge_parent_events", + "hook_file": "crates/perry-stdlib/src/worker_threads/worker_pump.rs" + }, + { + "file": "crates/perry-runtime/src/dgram.rs", + "names": [ + "DGRAM_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "(feature mod-dgram) Deterministic-mode map from bound address to a NaN-boxed socket object; bindings whose socket is freed are removed at thread exit.", + "hook": "release_dgram_bindings_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/gc/idle_reclaim.rs", + "names": [ + "COMPLETIONS" + ], + "verdict": "no_heap_address", + "why": "Counter of completed idle-reclaim passes (fetch_add at idle_reclaim.rs:474)." + }, + { + "file": "crates/perry-runtime/src/node_submodules/hono_jsx.rs", + "names": [ + "JSX_RENDER_STREAM_FN" + ], + "verdict": "no_heap_address", + "why": "Stores the RenderStreamFn fn pointer registered by perry-stdlib via js_register_jsx_render_stream (hono_jsx.rs:81); code address." + }, + { + "file": "crates/perry-runtime/src/object/inherited_read_cache.rs", + "names": [ + "HITS" + ], + "verdict": "no_heap_address", + "why": "Hit counter for the inherited-read inline cache; fetch_add / reset only." + }, + { + "file": "crates/perry-runtime/src/process.rs", + "names": [ + "PROCESS_STDIO_HOOK" + ], + "verdict": "no_heap_address", + "why": "Stores the address of the fn process_stdio_value (process.rs:761-764); code address." + }, + { + "file": "crates/perry-runtime/src/timer/mock.rs", + "names": [ + "MOCK_TIMERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Mock-timer callbacks and args (raw closure pointers, JS values) from any thread while mocking is on; entries naming freed memory are dropped at thread exit.", + "hook": "release_mock_timers_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/typed_feedback.rs", + "names": [ + "REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "(PERRY_TYPED_FEEDBACK) Observations record object and shape addresses; those naming freed memory are removed at thread exit.", + "hook": "release_typed_feedback_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/fetch/body_metadata.rs", + "names": [ + "FORM_DATA_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "Map fetch handle id -> FormDataStore (method_values NaN-box bits); ids are registered in the thread-local OwnedHandles (fetch/lifecycle.rs register, via alloc_fetch_handle_id fetch/mod.rs:252), whose Drop at thread exit calls release() removing them from FORM_DATA_REGISTRY (lifecycle.rs:235-238, 264-274).", + "hook": "release", + "hook_file": "crates/perry-stdlib/src/fetch/lifecycle.rs" + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_WITH_OPTIONS" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_with_options (global_fetch.rs:212) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_BLOB_NEW" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:258) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_FILE_NEW" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:259) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_HEADERS_NEW" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:260) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_HEADERS_INIT_FROM_VALUE" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:261) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_REQUEST_NEW" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:263) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_RESPONSE_NEW" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:264) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_RESPONSE_STATIC_JSON" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:265) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_RESPONSE_STATIC_REDIRECT" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:266) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_RESPONSE_STATIC_ERROR" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_constructors (global_fetch.rs:268) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_BODY_INIT_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_body_init_ptr (global_fetch.rs:194) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_HEADERS_ENTRIES_JSON" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by its register fn (global_fetch.rs:464) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_HEADERS_OBJECT_JSON" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by its register fn (global_fetch.rs:480) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/object/global_fetch.rs", + "names": [ + "GLOBAL_FETCH_NOTIFY_ABORT" + ], + "verdict": "no_heap_address", + "why": "Holds an extern \"C\" fn pointer to a perry-stdlib fetch helper, stored only by js_register_global_fetch_notify_abort (global_fetch.rs:231) and transmuted back to a fn type at call sites; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READABLE_NEW_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READABLE_ENQUEUE_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READABLE_CLOSE_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READABLE_ERROR_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_WRITABLE_NEW_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READABLE_GET_READER_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_READER_READ_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_WRITABLE_GET_WRITER_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_WRITER_WRITE_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_WRITER_CLOSE_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors/web_adapter.rs", + "names": [ + "WEB_WRITER_ABORT_PTR" + ], + "verdict": "no_heap_address", + "why": "Holds a stdlib Web Streams callback fn pointer, stored only by js_register_node_stream_web_adapter_callbacks (web_adapter.rs:60-70) and loaded via load_web_callback!; code address." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "REGEX_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock of Option (Sink::Stderr | Sink::File(String)) resolved once from an env var by the regex diagnostic sink getter; Rust-owned config, no addresses." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "IC_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock of Option (Sink::Stderr | Sink::File(String)) resolved once from an env var by the IC diagnostic sink getter; Rust-owned config, no addresses." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "LAYOUT_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock of Option (Sink::Stderr | Sink::File(String)) resolved once from an env var by the layout diagnostic sink getter; Rust-owned config, no addresses." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "ENUM_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock of Option (Sink::Stderr | Sink::File(String)) resolved once from an env var by the enum diagnostic sink getter; Rust-owned config, no addresses." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "BUFFER_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock of Option (Sink::Stderr | Sink::File(String)) resolved once from an env var by the buffer diagnostic sink getter; Rust-owned config, no addresses." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "BUF_WIN_LO" + ], + "verdict": "no_heap_address", + "why": "Diagnostic-only lower bound of the buffer-registry min/max window, stored by buffer_note_probe (hot_diag.rs:1462) and only ever formatted into the [buffer-diag] stderr dump; it is a number never dereferenced nor used as a lookup key, so staleness can only misreport a statistic." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "BUF_WIN_HI" + ], + "verdict": "no_heap_address", + "why": "Diagnostic-only upper bound of the buffer-registry window, stored by buffer_note_probe (hot_diag.rs:1463) and only printed by buffer_dump; never dereferenced nor used for a lookup." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "names": [ + "RECV_ROUTES" + ], + "verdict": "no_heap_address", + "why": "Array of 8 relaxed counters incremented by js_recv_route_note (only emitted under compile-time PERRY_RECV_ROUTE_COUNT=1) and printed at exit; plain counts." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "EXT_PUMP_FN" + ], + "verdict": "no_heap_address", + "why": "Extension process_pending fn pointer stored by js_register_ext_pump (lib.rs:416) at extension init; code address." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "STDLIB_PUMP_FN" + ], + "verdict": "no_heap_address", + "why": "perry-stdlib process_pending fn pointer stored by js_register_stdlib_pump (lib.rs:729); code address." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "STDLIB_NEXT_WAKE_FN" + ], + "verdict": "no_heap_address", + "why": "perry-stdlib nearest-deadline provider fn pointer stored by js_register_stdlib_next_wake (lib.rs:742); code address." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "RUNTIME_PUMP_FNS" + ], + "verdict": "no_heap_address", + "why": "Fixed slot array of runtime pump fn pointers written by register_runtime_pump (lib.rs:560, black_box'd fn address); code addresses." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "RUNTIME_HAS_ACTIVE_FNS" + ], + "verdict": "no_heap_address", + "why": "Fixed slot array of runtime has-active fn pointers written by register_runtime_has_active (lib.rs:592); code addresses." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "AUX_HAS_ACTIVE_LEN" + ], + "verdict": "no_heap_address", + "why": "Length of the auxiliary has-active fn list, stored at lib.rs:686 and checked for zero at lib.rs:706; a count." + }, + { + "file": "crates/perry-runtime/src/lib.rs", + "names": [ + "STDLIB_HAS_ACTIVE_FN" + ], + "verdict": "no_heap_address", + "why": "perry-stdlib has_active_handles fn pointer stored by js_register_stdlib_has_active (lib.rs:812); code address." + }, + { + "file": "crates/perry-runtime/src/object/native_module_registry.rs", + "names": [ + "NM_DISPATCH_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Per-bucket native-module dispatch fn pointers (NmDispatchFn) stored by the per-module install fns (e.g. native_module_registry.rs:185-460); code addresses." + }, + { + "file": "crates/perry-runtime/src/object/native_module_registry.rs", + "names": [ + "NM_INSTALL_ALL_HOOK" + ], + "verdict": "no_heap_address", + "why": "Holds the black_box'd address of js_nm_install_all, stored at native_module_registry.rs:664; code address." + }, + { + "file": "crates/perry-runtime/src/object/native_module_registry.rs", + "names": [ + "NM_CTOR_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Per-bucket NmCtorFn fn pointers stored at native_module_registry.rs:718; code addresses." + }, + { + "file": "crates/perry-runtime/src/object/native_module_registry.rs", + "names": [ + "NM_ATTACH_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Per-bucket NmAttachFn fn pointers stored at native_module_registry.rs:759; code addresses." + }, + { + "file": "crates/perry-runtime/src/object/native_module_registry.rs", + "names": [ + "NM_CONST_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Per-bucket NmConstFn fn pointers stored at native_module_registry.rs:797; code addresses." + }, + { + "file": "crates/perry-runtime/src/gc/schedule.rs", + "names": [ + "CACHED" + ], + "verdict": "no_heap_address", + "why": "(schedule.rs:261, fn resolved) OnceLock> parsed once from PERRY_GC_SCHEDULE_SEED/RATE; env config. | (second declaration) (schedule.rs:496, fn schedule_poll_stride_bytes) OnceLock parsed once from PERRY_GC_SCHEDULE_ALLOC_KB; env config." + }, + { + "file": "crates/perry-runtime/src/gc/schedule.rs", + "names": [ + "PREVIOUS_HANDLERS" + ], + "verdict": "no_heap_address", + "why": "Previously installed sigaction sa_sigaction addresses for 5 fatal signals, stored by install_signal_reporter_inner (schedule.rs:803) for chaining; code addresses of signal handlers." + }, + { + "file": "crates/perry-runtime/src/gc/schedule.rs", + "names": [ + "REPORTED_SEED" + ], + "verdict": "no_heap_address", + "why": "The GC schedule seed (u64) published at schedule.rs:920 for failure-path reporting; a number." + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "names": [ + "DATA_CALLBACKS" + ], + "verdict": "thread_exit_invalidated", + "why": "Shared list of raw stdin data closure pointers any thread can add to; freed ones are removed at thread exit as removeListener would.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "names": [ + "KEYPRESS_CALLBACKS" + ], + "verdict": "thread_exit_invalidated", + "why": "Shared list of raw keypress closure pointers; freed ones are removed at thread exit.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "names": [ + "READABLE_CALLBACKS" + ], + "verdict": "thread_exit_invalidated", + "why": "Shared list of raw readable closure pointers; freed ones are removed at thread exit and pull mode cleared if the list empties.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "names": [ + "STDIN_END_CALLBACKS" + ], + "verdict": "thread_exit_invalidated", + "why": "Shared list of raw stdin end closure pointers; freed ones are removed at thread exit.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/gc/layout_tables.rs", + "names": [ + "PERRY_LAYOUT_ADDR_FILTER" + ], + "verdict": "conservative_filter", + "why": "Monotone 4096-bit hashed sketch of user_ptr addresses that ever got a per-object layout record, OR'd by layout_addr_filter_note (layout_tables.rs:754) and tested inline by codegen; a stale bit only yields a 'maybe' that falls through to the authoritative thread-local LAYOUT_SLOT_MASKS / TYPED_LAYOUTS maps (layout_tables.rs:39-43)." + }, + { + "file": "crates/perry-runtime/src/gc/layout_tables.rs", + "names": [ + "N" + ], + "verdict": "no_heap_address", + "why": "(layout_tables.rs:964, layout_mask_min_slots) cached PERRY_LAYOUT_MASK_MIN_SLOTS env value (default 4); config. | (second declaration) (layout_tables.rs:1018, layout_object_mask_min_slots) cached PERRY_LAYOUT_OBJECT_MASK_MIN_SLOTS env value (default 8); config." + }, + { + "file": "crates/perry-runtime/src/bun_ffi/dlopen.rs", + "names": [ + "LIBS" + ], + "verdict": "no_heap_address", + "why": "Vec of dlopen handles (OS loader handles as usize), owned path Strings and closed flags, pushed by bun:ffi dlopen (dlopen.rs:749); no GC-heap addresses, indices are process-global." + }, + { + "file": "crates/perry-runtime/src/bun_ffi/dlopen.rs", + "names": [ + "SYMS" + ], + "verdict": "no_heap_address", + "why": "Vec of dlsym'd / CFunction native fn pointers, arg type codes and a leaked &'static str name (dlopen.rs:765, 819); JS stubs capture only the SYMS index, no GC-heap addresses." + }, + { + "file": "crates/perry-runtime/src/gc/roots/stack_maps.rs", + "names": [ + "STACK_MAPS" + ], + "verdict": "no_heap_address", + "why": "StackMapIndexStore: generation counter plus an RwLock'd StackMapIndex built from the loaded images' LLVM stack-map sections (keyed by code return addresses); rebuilt at module load, no heap addresses." + }, + { + "file": "crates/perry-runtime/src/gc/roots/stack_maps.rs", + "names": [ + "VECTOR_LENGTH" + ], + "verdict": "no_heap_address", + "why": "Cached SVE vector length in bytes from prctl(PR_SVE_GET_VL) (aarch64-linux only); a number." + }, + { + "file": "crates/perry-runtime/src/object/dictionary_counters.rs", + "names": [ + "LATCH_MIN_KEYS" + ], + "verdict": "no_heap_address", + "why": "per_test_global AtomicU64 key-count threshold for the dictionary latch, set from default/env in object/dictionary.rs:140/213/229; a number." + }, + { + "file": "crates/perry-runtime/src/object/dictionary_counters.rs", + "names": [ + "LAYOUT_ID_BUDGET" + ], + "verdict": "no_heap_address", + "why": "per_test_global AtomicU64 remaining layout-id budget, stored in object/dictionary.rs:138/160; a counter." + }, + { + "file": "crates/perry-runtime/src/symbol/accessors.rs", + "names": [ + "SYMBOL_ACCESSOR_PROPERTIES" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by (owner, symbol) addresses with get/set closure bits; entries whose owner, symbol or closures are freed are dropped at thread exit.", + "hook": "release_symbol_accessors_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol/accessors.rs", + "names": [ + "SYMBOL_ACCESSOR_IDS" + ], + "verdict": "conservative_filter", + "why": "Monotone 256-bit filter indexed by SymbolHeader::id (a counter, not an address), set by note_symbol_accessor_key before the table insert; a stale bit only means 'maybe', resolved by the authoritative SYMBOL_ACCESSOR_PROPERTIES table / prototype walk." + }, + { + "file": "crates/perry-runtime/src/agent.rs", + "names": [ + "RETIRE_HOOKS" + ], + "verdict": "no_heap_address", + "why": "Vec of Rust fn(AgentId) pointers registered once per process by register_retire_hook (agent.rs:125) and run from retire_agent; code addresses." + }, + { + "file": "crates/perry-runtime/src/array/subclass.rs", + "names": [ + "DENSE_SUBCLASS_CACHE" + ], + "verdict": "no_heap_address", + "why": "Seqlock cache keyed by (class_id<<32 | parent/shape id) with values packing slot indices and prefix lengths (array/subclass.rs:66-76, 124); integer ids and offsets, no addresses." + }, + { + "file": "crates/perry-runtime/src/eh_walker.rs", + "names": [ + "WALKER" + ], + "verdict": "no_heap_address", + "why": "Lazily built unwinder over loaded images' .eh_frame sections plus a rows cache keyed by code PC; addresses are of mapped image sections/code, not the GC heap." + }, + { + "file": "crates/perry-runtime/src/gc/layout/typed_shape.rs", + "names": [ + "REGISTERED_TYPED_SHAPES" + ], + "verdict": "no_heap_address", + "why": "Module-init registry of typed ShapeIds: layout keys (class id, proto id from class_proto_id = tag|class id, mask words), descriptors, and ImportedShapeSlot addresses of executable data-section globals (typed_shape.rs:106-118); no GC-heap addresses." + }, + { + "file": "crates/perry-runtime/src/object/chain_store.rs", + "names": [ + "CHAIN_STORE_SKIPPED" + ], + "verdict": "no_heap_address", + "why": "Six stats counters incremented by note_skip when stats are enabled; counts." + }, + { + "file": "crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs", + "names": [ + "LAST_CALL" + ], + "verdict": "no_heap_address", + "why": "#[cfg(test)]-only recorder of (method name String, args_len) written by recording_provider in net_provider_dispatch_tests; owned Rust data, not in production builds." + }, + { + "file": "crates/perry-runtime/src/promise/then_probe.rs", + "names": [ + "OUTCOME_COUNTS" + ], + "verdict": "no_heap_address", + "why": "Twelve diagnostic outcome counters for the then-probe; counts." + }, + { + "file": "crates/perry-runtime/src/timer/ref_states.rs", + "names": [ + "TIMER_REF_STATES" + ], + "verdict": "no_heap_address", + "why": "Map from monotonic process-global timer id (i64) to {has_ref, scheduled} plus a retired-id FIFO (ref_states.rs:33-39); ids and booleans only." + }, + { + "file": "crates/perry-runtime/src/update_notify.rs", + "names": [ + "CONFIG" + ], + "verdict": "no_heap_address", + "why": "OnceLock of the embedded update-notifier config (owned Strings / u64s) parsed from the compiled-in blob at startup; Rust-owned data." + }, + { + "file": "crates/perry-stdlib/src/streams/byob.rs", + "names": [ + "BYOB_PENDING" + ], + "verdict": "thread_exit_invalidated", + "why": "Parked read(view) entries hold the reading thread's promise and view; a freed one kills its stream and the queue is evicted with it.", + "hook": "release_web_streams_in_freed_ranges", + "hook_file": "crates/perry-stdlib/src/streams.rs" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_DATA_LISTENERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_READABLE_LISTENERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_DATA_ONCE" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_READABLE_ONCE" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_END_LISTENERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_END_ONCE" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global list of raw stdin closure addresses filled by process.stdin.on/once from any thread; the dead thread's closures are dropped at thread exit so the pump never calls them.", + "hook": "release_stdin_listeners_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_READER_DATA_FN" + ], + "verdict": "no_heap_address", + "why": "extern \"C\" fn(*const u8,usize) code address stored once by js_register_stdin_reader_consumer (os_process_streams.rs:322), installed by perry-stdlib readline." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_READER_EOF_FN" + ], + "verdict": "no_heap_address", + "why": "extern \"C\" fn() code address stored by js_register_stdin_reader_consumer (os_process_streams.rs:323) for readline's EOF callback." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_LISTENERS_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of perry-stdlib readline's listeners provider, stored only by js_register_stdin_listeners_provider (os_process_streams.rs:522)." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_ON_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of readline's `on` registration op, stored only by js_register_stdin_listener_ops (os_process_streams.rs:674)." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_OFF_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of readline's `off` registration op, stored only by js_register_stdin_listener_ops (os_process_streams.rs:675)." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_REMOVE_ALL_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of readline's removeAllListeners op, stored only by js_register_stdin_listener_ops (os_process_streams.rs:676)." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_FLOW_PAUSE_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of readline's pause() flow op, stored only by js_register_stdin_flow_ops (os_process_streams.rs:700)." + }, + { + "file": "crates/perry-runtime/src/os_process_streams.rs", + "names": [ + "STDIN_FLOW_RESUME_FN" + ], + "verdict": "no_heap_address", + "why": "Code address of readline's resume() flow op, stored only by js_register_stdin_flow_ops (os_process_streams.rs:701)." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_PROMISE_TIMER_REGISTRATIONS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_CALLBACK_TIMER_REGISTRATIONS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_INTERVAL_TIMER_REGISTRATIONS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_PROMISE_TIMER_TICKS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_CALLBACK_TIMER_TICKS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_INTERVAL_TIMER_TICKS" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_PROMISE_TIMERS_FIRED" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_CALLBACK_TIMERS_FIRED" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "PROFILE_INTERVAL_TIMERS_FIRED" + ], + "verdict": "no_heap_address", + "why": "Opt-in PERRY_MT_PROFILE event counter (fetch_add on timer registration/tick/fire), printed at exit; plain count." + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "names": [ + "NEXT_TIMER_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic i64 timer-id counter shared by setTimeout/setInterval (timer.rs:481 increments under the mutex); an id, not an address." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "SHOW_TOAST_HANDLER" + ], + "verdict": "no_heap_address", + "why": "Code address of the platform UI crate's toast handler, stored by js_register_show_toast_handler." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "SET_TEXT_HANDLER" + ], + "verdict": "no_heap_address", + "why": "Code address of the platform UI crate's setText handler, stored by js_register_set_text_handler." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "REGISTER_TEXT_ID_HANDLER" + ], + "verdict": "no_heap_address", + "why": "Code address of the platform UI crate's register-text-id handler, stored by its js_register_* FFI." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "PENDING_REGISTER_IDS" + ], + "verdict": "no_heap_address", + "why": "Queue of (widget handle i64, owned String id) buffered before the UI handler registers; widget handles are small ids into the platform UI crate's process-global widget registry, strings are Rust-owned." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "STATE_VALUES" + ], + "verdict": "thread_exit_invalidated", + "why": "perry/ui state values are raw JS values settable from any thread (GC root); entries holding a freed value are removed at thread exit.", + "hook": "release_ui_text_registry_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "NAVSTACK_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Map of state id String -> Vec; widget handles index the platform UI crate's widget registry, not the GC heap." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "SET_WIDGET_HIDDEN_HANDLER" + ], + "verdict": "no_heap_address", + "why": "Code address of the platform UI set-hidden handler, stored by js_register_widget_hidden_handler." + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "FOREACH_REGISTRY" + ], + "verdict": "thread_exit_invalidated", + "why": "ForEach render closures registered without a thread check; bindings with a freed closure are dropped at thread exit.", + "hook": "release_ui_text_registry_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/ui_text_registry.rs", + "names": [ + "FOREACH_RENDER_HANDLER" + ], + "verdict": "no_heap_address", + "why": "Code address of the platform UI ForEach render handler, stored by js_register_foreach_render_handler." + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "TLS_CLIENT_METADATA" + ], + "verdict": "thread_exit_invalidated", + "why": "tls.connect records keep the raw checkServerIdentity closure address and are never removed on close; records whose closure is freed are dropped at thread exit.", + "hook": "release_tls_client_metadata_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "TLS_CONNECT_PROVIDER" + ], + "verdict": "no_heap_address", + "why": "Code address of perry-ext-net's js_tls_connect, stored only by js_set_tls_connect_provider (tls.rs:42)." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/census.rs", + "names": [ + "SUBMITS" + ], + "verdict": "no_heap_address", + "why": "Per-op-class turnloop census counters (submits/completions), atomic increments only." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/census.rs", + "names": [ + "COMPLETIONS" + ], + "verdict": "no_heap_address", + "why": "Per-op-class turnloop census counters (submits/completions), atomic increments only." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/census.rs", + "names": [ + "TIMER_FIRED" + ], + "verdict": "no_heap_address", + "why": "turnloop census count of expired deadlines delivered; atomic counter." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/census.rs", + "names": [ + "TIMER_CANCELLED" + ], + "verdict": "no_heap_address", + "why": "turnloop census count of Cancelled completions for closed deadline handles; atomic counter." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/census.rs", + "names": [ + "TIMER_CLOSED" + ], + "verdict": "no_heap_address", + "why": "turnloop census count of Closed completions for deadline handles; atomic counter." + }, + { + "file": "crates/perry-runtime/src/media_playback.rs", + "names": [ + "MEDIA_CREATE_QUEUE" + ], + "verdict": "no_heap_address", + "why": "ohos-napi drain queue of MediaCreateIntent{handle: i64 from process-global HANDLE_SEQ, url: owned String}." + }, + { + "file": "crates/perry-runtime/src/media_playback.rs", + "names": [ + "MEDIA_CONTROL_QUEUE" + ], + "verdict": "no_heap_address", + "why": "ohos-napi drain queue of MediaCommand variants carrying a HANDLE_SEQ handle and plain f64 numbers (seconds/volume/rate)." + }, + { + "file": "crates/perry-runtime/src/media_playback.rs", + "names": [ + "MEDIA_NOW_PLAYING_QUEUE" + ], + "verdict": "no_heap_address", + "why": "ohos-napi drain queue of MediaNowPlaying{handle, owned title/artist/album/artwork Strings}." + }, + { + "file": "crates/perry-runtime/src/media_playback.rs", + "names": [ + "MEDIA_STATE_INBOX" + ], + "verdict": "thread_exit_invalidated", + "why": "(feature ohos-napi) onStateChange/onTimeUpdate closures settable from any thread; callbacks in freed blocks are set to None at thread exit.", + "hook": "release_media_callbacks_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams/tee.rs", + "names": [ + "TEE_SOURCE_BRANCHES" + ], + "verdict": "no_heap_address", + "why": "Map source stream id -> (branch id, branch id); ids minted by process-global streams IDALLOC (next_stream_id) indexing the process-global READABLE_STREAMS table." + }, + { + "file": "crates/perry-stdlib/src/streams/tee.rs", + "names": [ + "TEE_BRANCH_SOURCE" + ], + "verdict": "no_heap_address", + "why": "Map branch stream id -> source stream id; process-global stream ids into the process-global READABLE_STREAMS table." + }, + { + "file": "crates/perry-stdlib/src/streams/tee.rs", + "names": [ + "TEE_PULLING" + ], + "verdict": "no_heap_address", + "why": "Set of tee source stream ids with a pull job queued; process-global stream ids." + }, + { + "file": "crates/perry-stdlib/src/streams/tee.rs", + "names": [ + "TEE_STARTED" + ], + "verdict": "no_heap_address", + "why": "Set of tee source stream ids whose pull pipeline delivered once; process-global stream ids." + }, + { + "file": "crates/perry-runtime/src/messaging.rs", + "names": [ + "WORKER_THREADS_MESSAGE_CHANNEL_FACTORY" + ], + "verdict": "no_heap_address", + "why": "Code address of perry-stdlib worker_threads' MessageChannel factory, stored by js_register_worker_threads_messaging_constructors (messaging.rs:24)." + }, + { + "file": "crates/perry-runtime/src/messaging.rs", + "names": [ + "WORKER_THREADS_BROADCAST_CHANNEL_FACTORY" + ], + "verdict": "no_heap_address", + "why": "Code address of perry-stdlib worker_threads' BroadcastChannel factory, stored by js_register_worker_threads_messaging_constructors (messaging.rs:25)." + }, + { + "file": "crates/perry-runtime/src/messaging.rs", + "names": [ + "PORT_STATES" + ], + "verdict": "thread_exit_invalidated", + "why": "MessagePort state keyed by port address, removed only by close(); ports whose key, partner, onmessage, queue or listener values are freed are dropped at thread exit.", + "hook": "release_port_states_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/child_process/reactor.rs", + "names": [ + "CP_EVENT_QUEUE" + ], + "verdict": "no_heap_address", + "why": "Queue of CpEvent produced by child reader/waiter threads: numeric live-child handles (CP_NEXT_LIVE_ID), fds, owned byte Vecs/JSON Strings, exit codes, signals; no heap addresses." + }, + { + "file": "crates/perry-runtime/src/child_process/reactor.rs", + "names": [ + "CP_LIVE" + ], + "verdict": "thread_exit_invalidated", + "why": "Entries hold the spawning thread's ChildProcess/stdio objects and callbacks emitted on by the global pump; entries naming freed memory are removed at thread exit with live/refed counts adjusted.", + "hook": "release_cp_children_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/hot_diag/receiver_repr.rs", + "names": [ + "COUNTERS" + ], + "verdict": "no_heap_address", + "why": "FamilyCounters struct of per-receiver-family AtomicU64 arrays (constructed/observed_old/observed_wrapped), increments only." + }, + { + "file": "crates/perry-runtime/src/hot_diag/receiver_repr.rs", + "names": [ + "RECEIVER_REPR_SINK" + ], + "verdict": "no_heap_address", + "why": "OnceLock> parsed once from PERRY_RECEIVER_REPR_DIAG (hot_diag::Sink output target: stderr/file); Rust-owned config." + }, + { + "file": "crates/perry-runtime/src/object/global_this_webassembly.rs", + "names": [ + "REG" + ], + "verdict": "thread_exit_invalidated", + "why": "(feature wasm-host) WebAssembly wrapper registries keyed by wrapper object address; move/death hooks do not run at thread exit, so freed addresses are dropped (host handles only unmapped). | (second declaration) (feature wasm-host) WebAssembly wrapper registries keyed by wrapper object address; move/death hooks do not run at thread exit, so freed addresses are dropped (host handles only unmapped).", + "hook": "release_wasm_wrapper_registries_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/tui/hooks.rs", + "names": [ + "SLOTS" + ], + "verdict": "thread_exit_invalidated", + "why": "Index-addressed TUI hook slots; freed State/Ref values become undefined, Memos uncomputed, Ref handles and Effect cleanups 0 at thread exit.", + "hook": "release_tui_hook_slots_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/tui/hooks.rs", + "names": [ + "FOCUS_CURRENT" + ], + "verdict": "no_heap_address", + "why": "Currently focused useFocus id (u32 minted by FOCUS_ID_COUNTER), 0 = none; set by use_focus/focus_next/focus_prev/focus." + }, + { + "file": "crates/perry-runtime/src/arena/alloc_sample.rs", + "names": [ + "INTERVAL" + ], + "verdict": "no_heap_address", + "why": "Allocation-sampling interval in bytes (0=off), written once at gc_init from the env knob." + }, + { + "file": "crates/perry-runtime/src/atomics_futex.rs", + "names": [ + "WAIT_TABLE" + ], + "verdict": "process_global_allocation", + "why": "Futex table keyed by absolute slot address of an Atomics.wait/waitAsync/notify target, values Arc (Rust-owned); callers require a shared backing, which lives in crate::shared_sab's process-global never-freed allocation." + }, + { + "file": "crates/perry-runtime/src/embedded.rs", + "names": [ + "EMBEDDED_ASSETS" + ], + "verdict": "no_heap_address", + "why": "Registry of embedded assets: owned name String + bytes that are &'static rodata or Box::leak'd decompressed buffers, plus a text_module flag; no GC-heap addresses." + }, + { + "file": "crates/perry-runtime/src/gc/poll_arm.rs", + "names": [ + "ARM_EVENTS" + ], + "verdict": "no_heap_address", + "why": "Count of times a GC deferral armed the back-edge poll; diagnostic counter." + }, + { + "file": "crates/perry-runtime/src/object/class_image.rs", + "names": [ + "PRIMARY_IMAGE" + ], + "verdict": "no_heap_address", + "why": "Never-dropped Arc plus owner ThreadId; the tables are class-id-keyed metadata (vtables, fn-pointer hooks such as has_instance/to_string_tag stored as func_ptr code addresses, names, lengths, parent edges) owned by Rust allocations, not the GC arena." + }, + { + "file": "crates/perry-runtime/src/object/prototype_chain.rs", + "names": [ + "OBJECT_PROTOTYPES" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by owner address; death pruning only sees the collecting thread, so entries whose owner or prototype is freed are dropped at thread exit.", + "hook": "release_object_prototypes_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/proxy/put_value/packed_add.rs", + "names": [ + "PERRY_STORE_CENSUS" + ], + "verdict": "no_heap_address", + "why": "#[no_mangle] array of 32 store-path census counters incremented by codegen-emitted code (indices <16) and runtime (16..32); counts only." + }, + { + "file": "crates/perry-runtime/src/timer/tests_inline.rs", + "names": [ + "SELF_ID" + ], + "verdict": "no_heap_address", + "why": "cfg(test)-only: records the mock timer's own i64 timer id for the self-eviction test." + }, + { + "file": "crates/perry-runtime/src/v8.rs", + "names": [ + "PROMISE_HOOKS" + ], + "verdict": "thread_exit_invalidated", + "why": "v8.promiseHooks records hold raw closure pointers from any thread; a record with a freed callback is disabled at thread exit (kept in place, its stop function indexes it).", + "hook": "release_promise_hooks_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/streams/expando.rs", + "names": [ + "STREAM_EXPANDO" + ], + "verdict": "thread_exit_invalidated", + "why": "Expando values live in the setting thread's heap though the stream id is process-global; freed values are removed property by property.", + "hook": "release_stream_expandos_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "PERRY_SYMBOL_PROPERTY_IC_EPOCH" + ], + "verdict": "no_heap_address", + "why": "#[no_mangle] monotonic epoch counter bumped on every symbol-data mutation and completed collection (symbol_property_ic_epoch_bump); generated ICs compare it, it never holds an address." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SYMBOL_REGISTRY" + ], + "verdict": "process_global_allocation", + "why": "Maps Symbol.for key String -> SymbolHeader address; the only inserter (symbol/constructors.rs:86-124) stores a Box::into_raw'd (never freed) SymbolHeader, so no thread arena owns the value." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SYMBOL_POINTERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Set of fresh Symbol() header addresses (gc_malloc); death pruning only sees the collecting thread's heap, so addresses in the exiting thread's freed arena/malloc ranges are dropped at thread exit.", + "hook": "release_symbol_tables_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "REGISTERED_SYMBOL_DESCRIPTIONS" + ], + "verdict": "process_global_allocation", + "why": "Keyed by addresses of Box::leak'd registered/well-known/Intl-fallback SymbolHeaders only (inserters symbol.rs:398, 709, symbol/constructors.rs:122); values are Rust-owned Arc." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "WELL_KNOWN_SYMBOLS" + ], + "verdict": "process_global_allocation", + "why": "Short name -> address of a Box::into_raw'd well-known SymbolHeader created in well_known_symbol_slow (symbol.rs:352-402); never freed, never reset." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SLOTS" + ], + "verdict": "process_global_allocation", + "why": "Lock-free fast cache (well_known_fast_slot) of the same Box-leaked well-known SymbolHeader addresses returned by well_known_symbol_slow; published once, never cleared." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SYMBOL_ADDR_FILTER" + ], + "verdict": "conservative_filter", + "why": "Bloom-style RegistryAddrFilter admitted with every symbol address before insertion; a stale bit only yields 'maybe', which is resolved against the authoritative SYMBOL_POINTERS set (is_registered_symbol, symbol.rs:738-781). Note the authoritative table itself is a BUG in this audit." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "INTL_FALLBACK_SYMBOL" + ], + "verdict": "process_global_allocation", + "why": "Holds the address of the %Intl%.[[FallbackSymbol]] SymbolHeader, Box::into_raw'd once in intl_legacy_constructed_symbol (symbol.rs:688-712); never freed." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SYMBOL_PROPERTIES" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by owner object address with strongly-rooted values; owner records in freed ranges, and entries whose symbol or value is freed, are dropped at thread exit.", + "hook": "release_symbol_tables_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "SYMBOL_PROPERTY_ATTRS" + ], + "verdict": "thread_exit_invalidated", + "why": "Keyed by (owner, symbol) addresses; entries where either lies in freed ranges are dropped at thread exit.", + "hook": "release_symbol_tables_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "NEXT_SYMBOL_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic u64 id counter handed out by next_id() to every new SymbolHeader." + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "CLASS_STATIC_SYMBOLS" + ], + "verdict": "thread_exit_invalidated", + "why": "Class ids are process-global but members hold symbol addresses and JS values from the writing thread; members with a freed symbol or value are dropped at thread exit.", + "hook": "release_symbol_tables_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "names": [ + "CLASS_STATIC_SYMBOL_ORDER" + ], + "verdict": "no_heap_address", + "why": "class_id -> Vec of SymbolHeader::id (monotonic u64 ids, stable across moves) recording creation order (symbol.rs:1162-1172); holds no addresses." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "OPENS" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&OPENS) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_NOT_SEGMENTER" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_NOT_SEGMENTER) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_NOT_GRAPHEME" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_NOT_GRAPHEME) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_SEGMENT_PATCHED" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_SEGMENT_PATCHED) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_NOT_STRING" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_NOT_STRING) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_NOT_UTF8" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_NOT_UTF8) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "DECLINE_EMPTY" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&DECLINE_EMPTY) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "NEXTS" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&NEXTS) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "CODE_POINT_ATS" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&CODE_POINT_ATS) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "MATERIALISE_SEGMENT" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(&MATERIALISE_SEGMENT) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "REGEXP_TEST_ACCEPTED" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(®EXP_TEST_ACCEPTED) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "names": [ + "REGEXP_TEST_DECLINED" + ], + "verdict": "no_heap_address", + "why": "Diagnostic AtomicU64 counter incremented via bump(®EXP_TEST_DECLINED) in the Intl.Segmenter segments-view fast path and read only for the diag report." + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "names": [ + "DIAG_GLOBAL_ACTIVE_COUNTS" + ], + "verdict": "no_heap_address", + "why": "Channel-name key (DiagChannelKey::String; symbol keys are excluded by cross_thread_key) -> subscriber count, adjusted in adjust_global_active_count_for_name; Rust-owned Strings and usizes only." + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "names": [ + "DIAG_PENDING_PUBLISHES" + ], + "verdict": "no_heap_address", + "why": "Queue of worker publishes: String channel key + SerializedValue deep copy (serialize_nanbox_for_thread) + ThreadId, pushed in enqueue_cross_thread_publish and deserialized on the main pump; arena-independent payload." + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "names": [ + "DIAG_PENDING_PUBLISHES_LEN" + ], + "verdict": "no_heap_address", + "why": "Mirror of DIAG_PENDING_PUBLISHES.len() republished under its lock; a counter." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "CONTAINER_HANDLES" + ], + "verdict": "no_heap_address", + "why": "u64 handle (from process-global NEXT_HANDLE_ID) -> perry_container_compose ContainerHandle {id: String, name: Option}; Rust-owned." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "COMPOSE_HANDLES" + ], + "verdict": "no_heap_address", + "why": "u64 handle (process-global NEXT_HANDLE_ID) -> Arc Rust object; perry-container-compose does not depend on perry-runtime, so no JS values." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "WORKLOAD_HANDLES" + ], + "verdict": "no_heap_address", + "why": "u64 handle (process-global NEXT_HANDLE_ID) -> Arc Rust object with no JS values." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "CONTAINER_INFO_LIST_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "u64 handle -> Vec (Strings/Vec/HashMap of Strings); Rust-owned result data." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "CONTAINER_INFO_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "u64 handle -> ContainerInfo (Strings); Rust-owned result data." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "CONTAINER_LOGS_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "u64 handle -> ContainerLogs {stdout, stderr: String}; Rust-owned." + }, + { + "file": "crates/perry-stdlib/src/container/types.rs", + "names": [ + "IMAGE_INFO_LIST_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "u64 handle -> Vec (Strings + u64 size); Rust-owned." + }, + { + "file": "crates/perry-stdlib/src/streams/transform.rs", + "names": [ + "TRANSFORM_PAIRS" + ], + "verdict": "no_heap_address", + "why": "Writable stream id -> transform stream id (transform.rs:126); both ids come from the process-global IDALLOC band allocator (streams/idalloc.rs) indexing process-global stream registries." + }, + { + "file": "crates/perry-stdlib/src/streams/transform.rs", + "names": [ + "TRANSFORM_WRITE_RELEASES" + ], + "verdict": "thread_exit_invalidated", + "why": "Raw addresses of write promises parked on backpressure; a freed one kills the transform family and its side-table rows.", + "hook": "release_web_streams_in_freed_ranges", + "hook_file": "crates/perry-stdlib/src/streams.rs" + }, + { + "file": "crates/perry-stdlib/src/streams/transform.rs", + "names": [ + "TRANSFORM_PENDING_WRITES" + ], + "verdict": "no_heap_address", + "why": "Writable stream id -> count of queued transform write jobs (transform.rs:261-265, 413); ids and counts only." + }, + { + "file": "crates/perry-stdlib/src/streams/transform.rs", + "names": [ + "TRANSFORM_BACKPRESSURED_JOBS" + ], + "verdict": "thread_exit_invalidated", + "why": "Raw closure addresses of deferred write jobs from the writing thread's arena; evicted with the dead transform family.", + "hook": "release_web_streams_in_freed_ranges", + "hook_file": "crates/perry-stdlib/src/streams.rs" + }, + { + "file": "crates/perry-stdlib/src/streams/transform.rs", + "names": [ + "TRANSFORM_PENDING_CLOSE" + ], + "verdict": "thread_exit_invalidated", + "why": "Raw close-request promise address from the calling thread's arena; evicted with the dead transform family.", + "hook": "release_web_streams_in_freed_ranges", + "hook_file": "crates/perry-stdlib/src/streams.rs" + }, + { + "file": "crates/perry-runtime/src/registry_latch.rs", + "names": [ + "WINDOW" + ], + "verdict": "no_heap_address", + "why": "#[cfg(test)] function-local fixture (concurrent_admits_never_drop_an_address) admitting synthetic integers 0x1_0000+i; never holds a real heap address and does not exist in production. | (second declaration) #[cfg(test)] function-local RegistryAddrWindow fixture (window_admit_is_visible_to_another_thread) admitting the synthetic constant 0x4000; test-only." + }, + { + "file": "crates/perry-runtime/src/registry_latch.rs", + "names": [ + "FILTER" + ], + "verdict": "no_heap_address", + "why": "#[cfg(test)] function-local RegistryAddrFilter fixture (concurrent_filter_admits_never_drop_an_address) fed synthetic 0x5_0000_0000-range integers; test-only. | (second declaration) #[cfg(test)] function-local RegistryAddrFilter fixture (filter_admit_is_visible_to_another_thread) admitting the synthetic constant 0x9_0000_1000; test-only." + }, + { + "file": "crates/perry-stdlib/src/zlib.rs", + "names": [ + "ZLIB_STREAMS" + ], + "verdict": "thread_exit_invalidated", + "why": "pipes holds NaN-boxed .pipe(dest) targets from the owning thread's heap; a stream with a freed destination or listener is dropped whole.", + "hook": "release_zlib_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/zlib.rs", + "names": [ + "ZLIB_LISTENERS" + ], + "verdict": "thread_exit_invalidated", + "why": "Raw .on(event, cb) closure pointers from the registering thread's arena; dropped with their stream.", + "hook": "release_zlib_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/zlib.rs", + "names": [ + "ZLIB_PENDING_EVENTS" + ], + "verdict": "thread_exit_invalidated", + "why": "Queued callback closures whose closure is freed are dropped, as are Data/End/Error events of dropped streams.", + "hook": "release_zlib_in_freed_ranges" + }, + { + "file": "crates/perry-stdlib/src/zlib.rs", + "names": [ + "NEXT_ZLIB_ID" + ], + "verdict": "no_heap_address", + "why": "Monotonic i64 handle counter in the ZLIB handle band (next_zlib_id, zlib.rs:786-797)." + }, + { + "file": "crates/perry-runtime/src/node_submodules/mod.rs", + "names": [ + "SUBMOD_REGISTRY" + ], + "verdict": "no_heap_address", + "why": "Per-bucket pointers to `static` SubmoduleSpec descriptors (e.g. &SUBMOD_VM) stored by js_node_submod_install_* (mod.rs:860-970); addresses of statics." + }, + { + "file": "crates/perry-runtime/src/node_submodules/mod.rs", + "names": [ + "SUBMOD_INSTALL_ALL_HOOK" + ], + "verdict": "no_heap_address", + "why": "Function pointer to js_node_submod_install_all stored by js_node_submod_enable_install_all; code address." + }, + { + "file": "crates/perry-runtime/src/node_submodules/mod.rs", + "names": [ + "SYS_DEPRECATION_WARNED" + ], + "verdict": "no_heap_address", + "why": "One-shot 0/1 flag compare_exchange'd by emit_sys_deprecation_warning_once." + }, + { + "file": "crates/perry-runtime/src/event_pump/agent_loop.rs", + "names": [ + "ROUTES" + ], + "verdict": "no_heap_address", + "why": "Vec of Rust-owned Route {AgentId, ThreadId, loop_id, Arc, Notifier, Poster}; no JS/arena addresses, and a thread's route is removed at its thread exit by ClaimGuard::drop (agent_loop.rs:345-354) held in a perry_thread_local." + }, + { + "file": "crates/perry-runtime/src/event_pump/agent_loop.rs", + "names": [ + "EXTRA_STATS" + ], + "verdict": "no_heap_address", + "why": "Function pointer to a perry-stdlib StatsReporter installed by register_stats_reporter; code address." + }, + { + "file": "crates/perry-runtime/src/native_handle.rs", + "names": [ + "MAIN_THREAD_ID" + ], + "verdict": "no_heap_address", + "why": "Hash of the main thread's std ThreadId, set once by compare_exchange in runtime_main_thread_id." + }, + { + "file": "crates/perry-runtime/src/native_handle.rs", + "names": [ + "MAIN_OS_THREAD_ID" + ], + "verdict": "no_heap_address", + "why": "OS thread id (pthread_self / GetCurrentThreadId) of the main thread; not a heap address." + }, + { + "file": "crates/perry-runtime/src/object/mod.rs", + "names": [ + "GLOBAL_THIS_PTR" + ], + "verdict": "thread_exit_invalidated", + "why": "Process-global root slot each thread overwrites with its own globalThis; cleared at thread exit when the last writer's heap is freed.", + "hook": "release_global_this_ptr_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/object/mod.rs", + "names": [ + "PERRY_TRANSITION_IC_HITS" + ], + "verdict": "no_heap_address", + "why": "#[no_mangle] probe counter bumped by generated transition-IC hit paths under PERRY_TRANSITION_IC_PROBE=1 and printed by js_transition_ic_probe_report." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/sink.rs", + "names": [ + "SINKS" + ], + "verdict": "no_heap_address", + "why": "Per-subsystem completion-sink function pointers (SinkFn) installed once by register_sink; code addresses." + }, + { + "file": "crates/perry-runtime/src/turnloop_net/sink.rs", + "names": [ + "ALLOCS" + ], + "verdict": "no_heap_address", + "why": "Per-subsystem accepted-connection id allocator function pointers (AllocFn) installed by register_sink; code addresses." + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "names": [ + "BLOCK_POOL_PROCESS_BYTES" + ], + "verdict": "no_heap_address", + "why": "Process-wide byte total of pooled free arena blocks (a size counter for the pool cap), not an address." + }, + { + "file": "crates/perry-runtime/src/buffer/own_props.rs", + "names": [ + "PROPS" + ], + "verdict": "thread_exit_invalidated", + "why": "Buffer expando records keyed by buffer address; records whose buffer or any stored value is freed are dropped at thread exit.", + "hook": "release_buffer_own_props_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/event_target.rs", + "names": [ + "DOM_EXCEPTION_ERRORS" + ], + "verdict": "thread_exit_invalidated", + "why": "Addresses of DOMException error objects, removed only by the error finalizer; freed addresses are dropped at thread exit.", + "hook": "release_dom_exception_errors_in_freed_ranges" + }, + { + "file": "crates/perry-runtime/src/ios_game_loop.rs", + "names": [ + "CONNECTED_SCENE" + ], + "verdict": "no_heap_address", + "why": "UIWindowScene ObjC object pointer stored by the UIKit scene-delegate callback fallback_scene_will_connect (main-thread UIKit); a UIKit-owned object, not a GC arena address." + }, + { + "file": "crates/perry-runtime/src/object/class_registry/dispatch.rs", + "names": [ + "VTABLE_GEN" + ], + "verdict": "no_heap_address", + "why": "Generation counter bumped on class method/getter registration; caches compare it to invalidate." + }, + { + "file": "crates/perry-runtime/src/ohos_napi.rs", + "names": [ + "NAPI_MODULE_DESC" + ], + "verdict": "no_heap_address", + "why": "static mut NapiModule descriptor holding the napi_init fn pointer and nm_modname pointing into a static buffer filled by derive_modname at .so load; no heap addresses." + }, + { + "file": "crates/perry-runtime/src/pty/mod.rs", + "names": [ + "EXIT_SINK" + ], + "verdict": "no_heap_address", + "why": "#[cfg(all(test, unix))] test sink recording the onExit payload's exitCode and signal fields (numbers / undefined, per the assertions at pty/mod.rs:514-545); test-only." + }, + { + "file": "crates/perry-runtime/src/tls_os_pool.rs", + "names": [ + "NEXT_ID" + ], + "verdict": "no_heap_address", + "why": "Counter assigning each TLS-pool declaration a slot index (tls_os_pool.rs:210-222); storage itself is per-thread via a pthread key." + }, + { + "file": "crates/perry-runtime/src/weakref/test_support.rs", + "names": [ + "DELIVERED" + ], + "verdict": "no_heap_address", + "why": "Test-only static in a spawned test thread recording the held value delivered to a FinalizationRegistry cleanup callback (asserted to be the number 41.0)." + }, + { + "file": "crates/perry-stdlib/src/tls/socket_api.rs", + "names": [ + "NAMES" + ], + "verdict": "no_heap_address", + "why": "TLS server handle id (i64 from the stdlib handle registry) -> queue of original SNI servername Strings (record_original_servername at tls.rs:1215, taken at tls/turnloop_server.rs:295); Rust-owned." + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "HOOKS_ACTIVE" + ], + "verdict": "no_heap_address", + "why": "Count of enabled createHook records, adjusted by enable/disable and by the thread-exit hook; a count, never an address." + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "names": [ + "PROMISE_HOOKS_ACTIVE" + ], + "verdict": "no_heap_address", + "why": "Count of enabled hooks with promise callbacks, adjusted by enable/disable and the thread-exit hook; a count." + }, + { + "file": "crates/perry-runtime/src/v8.rs", + "names": [ + "PROMISE_HOOKS_ACTIVE" + ], + "verdict": "no_heap_address", + "why": "Count of enabled v8.promiseHooks records, adjusted by enable/disable and the thread-exit hook; a count." + }, + { + "file": "crates/perry-stdlib/src/common/handle.rs", + "names": [ + "PAYLOAD_RELEASERS" + ], + "verdict": "no_heap_address", + "why": "Registry of (TypeId, releaser fn) pairs for HANDLES payload types (#11471); the dyn Fn wraps a plain fn pointer and captures nothing." + }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "names": [ + "TURNLOOP_SIGNAL_OWNERS" + ], + "verdict": "no_heap_address", + "why": "Agent ids owning each turnloop signal subscription (#11471), read by the retire hook; agent ids are integers, never addresses." + }, + { + "file": "crates/perry-runtime/src/arena/thread_exit.rs", + "names": [ + "RANGE_HOOKS" + ], + "verdict": "no_heap_address", + "why": "Registry of thread-exit range-hook function pointers (#11471); code addresses only, never a heap address." + } + ] +} diff --git a/scripts/thread_exit_address_globals.py b/scripts/thread_exit_address_globals.py new file mode 100644 index 00000000000..3de03220490 --- /dev/null +++ b/scripts/thread_exit_address_globals.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +"""Thread-exit custody of process-global address-keyed state (#11471). + +The bug class +------------- + +A process-global table keyed by (or holding) a heap address outlives the +thread whose arena owned that address. When the thread exits, `Arena::drop` +hands its blocks back to the allocator; another thread's arena reuses the +same addresses; the stale entry now describes a DIFFERENT object. Three +instances shipped before this gate existed, each found by chasing a flake: + +* #11319 / #11422 — the closure side tables (`CLOSURE_PROPS` and friends) + kept a dead thread's entries, so a closure allocated at a reused address + inherited them. Fixed by `release_closure_side_table_owners_in_ranges`, + called from `Arena::drop`. +* #11462 — `tls.rs` parked `tls.rootCertificates` in process-global + `AtomicU64` GC roots, which pinned whatever later occupied the address. + Fixed by making the roots per-thread. +* #11470 — `PERRY_TA_KIND_CACHE` / `INLINE_OWNING_U32_CACHE` kept "this + address is a typed array" after the owning thread exited, so a Promise at + the reused address read as a `Uint8Array`. Fixed by + `invalidate_kind_caches_in_ranges`, called from `Arena::drop`. + +`scripts/gc_runtime_root_holders.py` enumerates statics that hold heap +pointers as GC ROOTS. A lookup cache keyed by an address is not a root — the +collector never needs to see it — so that census is blind to this class by +construction. This gate is the enumeration for the other half. + +What it does +------------ + +1. **Enumerate** every process-global `static` in `perry-runtime` and + `perry-stdlib` that (i) can change at run time — `static mut`, or a type + that names an atomic, a lock, a once-cell, a lazy, a `DashMap`, or a + crate-local wrapper that does — and (ii) can hold a 64-bit word: `u64`, + `i64`, `usize`, `isize`, `f64`, an atomic of those, `AtomicPtr`, a raw + pointer, `NonNull`, `JSValue`, or a `dyn` payload. Map KEYS count: an + address-keyed table is exactly the shape of #11319 and #11470. Type aliases + and crate-local struct/enum fields are resolved (bounded depth), so a + `Mutex>` whose `Entry` has a `ptr: usize` field is + found. + + Declarations inside `thread_local!` / `perry_thread_local!` are NOT + enumerated: they die with their thread. Declarations inside + `per_test_global!` ARE: that macro is a plain process-global `static` in + every non-test build, and the closure side tables of #11319 were exactly + such statics. + +2. **Discharge counter-only atomics mechanically.** A scalar + `AtomicU64`/`AtomicI64`/`AtomicUsize`/`AtomicIsize` whose every mention in + the two crates is a `load`, an arithmetic `fetch_*`, or a `store`/`swap` of + an integer literal cannot hold an address. Any other mention — a computed + `store`, a `compare_exchange`, a borrow (`&NAME`), an exported symbol + (`#[no_mangle]` / `export_name`, which codegen may write directly) — makes + it a manual candidate. The rule reads the code; it does not read names. + +3. **Require a verdict** for everything else, in + `scripts/thread_exit_address_globals.json`: + + * `type_verdicts` — one verdict for every static whose OUTER type is a + named wrapper that resolves per thread (`RealmAtomicI64`, `ImageTable`). + * `entries` — `{file, names, verdict, why}` groups. Verdicts: + - `no_heap_address`: counters, flags, config, code addresses, handle + ids from a process-global counter, Rust-owned data. + - `process_global_allocation`: holds only addresses of allocations that + no thread's arena owns (leaked `Box`, `shared_sab`, malloc outside + the arena), so thread exit cannot free them. + - `per_thread`: the static is a handle whose storage resolves per + thread. + - `conservative_filter`: address bounds/bits whose staleness can only + turn a definite "no" into a "maybe" that an authoritative table + (itself per-thread or invalidated) then answers. + - `main_thread_only`: holds arena addresses, but every writer is + enforced to run on the main thread, whose arena is never dropped + before process exit. The `why` must cite the enforcement. + - `thread_exit_invalidated`: cleared over the freed ranges on thread + exit. Must name its `hook`; the gate checks that the hook is called + in `impl Drop for Arena`'s body AND is defined in the static's own + file, so deleting the call reddens the gate. + - `pending_fix`: a known instance with an open fix PR (`pr`). Allowed + so the gate can land before the fix, but it cannot outlive the fix: + with a `hook`, it fails the moment the hook appears in `Arena::drop` + (promote it to `thread_exit_invalidated`); a fix that makes the + static per-thread deletes the declaration, so the entry goes stale. + - `open_bug`: a stale-capable static with an `issue`. Always fails. + +How it fails +------------ + +* a candidate with no verdict -> exit 1 +* an entry name (or a `type_verdicts` type) matching no candidate -> exit 1 +* `open_bug`, or a `thread_exit_invalidated` whose hook is not called by + `Arena::drop` or not defined in the static's file -> exit 1 +* a `pending_fix` whose hook IS now called by `Arena::drop` -> exit 1 +* fewer than MIN_CANDIDATES candidates or MIN_COUNTER_ONLY counter-only + discharges -> exit 2 (the regexes stopped matching; an empty green run + would otherwise look like success) + +`--self-test` plants each shape into a temp tree and requires the scanner to +reject it; run it before trusting a green scan. + +What this gate CANNOT see +------------------------- + +* `RuntimeState` fields (`crates/perry-runtime/src/state.rs`) — they are + per-thread (reached through `state()`), so out of subject anyway. +* A static whose TYPE cannot hold a 64-bit word but whose meaning is an + address-derived value (a `u32` hash of an address). None is known. +* Whether a `thread_exit_invalidated` hook clears the RIGHT entries. The + gate proves the call exists; the regression tests in + `crates/perry-stdlib/src/runtime_thread_exit_tests.rs` prove what it does. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from gc_runtime_root_holders import ( # noqa: E402 + crate_source_files, + crate_type_index, + declarations, + declarations_in_tls, + function_bodies, + strip_comments, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +INVENTORY_PATH = REPO_ROOT / "scripts" / "thread_exit_address_globals.json" +CRATES = ("crates/perry-runtime/src", "crates/perry-stdlib/src") +ARENA_FILE = "crates/perry-runtime/src/arena/block.rs" +ARENA_DROP = re.compile(r"impl\s+Drop\s+for\s+Arena\s*\{") +THREAD_EXIT_FILE = "crates/perry-runtime/src/arena/thread_exit.rs" +AGENT_FILE = "crates/perry-runtime/src/agent.rs" + +MIN_CANDIDATES = 300 +MIN_COUNTER_ONLY = 100 + +MUTABLE = re.compile( + r"\b(?:Atomic\w*|Mutex|RwLock|OnceLock|OnceCell|LazyLock|Lazy|Cell|RefCell|" + r"UnsafeCell|DashMap|DashSet|ArcSwap\w*)\b" +) +WORD = re.compile( + r"\b(?:u64|i64|usize|isize|f64|AtomicU64|AtomicI64|AtomicUsize|AtomicIsize|" + r"AtomicPtr|NonNull|JSValue)\b|\*\s*(?:mut|const)\b|\bdyn\b" +) +FN_POINTER_TYPE = re.compile(r'^(?:unsafe\s+)?(?:extern\s*(?:"[^"]*"\s*)?)?fn\s*\(') +SCALAR_ATOMIC = re.compile(r"^(?:(?:std|core)::sync::atomic::)?Atomic(?:U64|I64|Usize|Isize)$") +COUNTER_USE = re.compile( + r"\s*\.\s*(?:load|fetch_add|fetch_sub|fetch_max|fetch_min|fetch_or|fetch_and|fetch_xor)\s*\(" + r"|\s*\.\s*(?:store|swap)\s*\(\s*(?:-?\d[\w_]*|true|false)\s*," +) +EXPORTED = re.compile(r"#\[\s*(?:unsafe\s*\(\s*)?(?:no_mangle|export_name)") +TYPE_ALIAS = re.compile( + r"^\s*(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)(?:<[^=]*>)?\s*=\s*(.+?);", re.M | re.S +) +UPPER_IDENT = re.compile(r"\b[A-Z][A-Z0-9_]*\b") +TYPE_IDENT = re.compile(r"\b[A-Z]\w*\b") +OUTER_NAME = re.compile(r"^(?:[\w$]+::)*(\w+)") +MAX_EXPANSION_DEPTH = 4 + +VERDICTS = { + "no_heap_address", + "process_global_allocation", + "per_thread", + "conservative_filter", + "main_thread_only", + "thread_exit_invalidated", + "pending_fix", + "open_bug", +} + + +def _expand(type_text: str, aliases: dict, types: dict, depth: int = 0, seen=frozenset()) -> str: + """The type text plus every alias target / crate-local field type it names.""" + parts = [type_text] + if depth >= MAX_EXPANSION_DEPTH: + return type_text + for ident in set(TYPE_IDENT.findall(type_text)): + if ident in seen: + continue + if ident in aliases: + parts.append(_expand(aliases[ident], aliases, types, depth + 1, seen | {ident})) + for field in types.get(ident, ()): + parts.append(_expand(field, aliases, types, depth + 1, seen | {ident})) + return " ".join(parts) + + +def scan(root: Path) -> list[dict]: + """Every candidate static, with `counter_only` set where rule 2 discharges it.""" + files = crate_source_files(root, CRATES) + texts = {path: path.read_text(encoding="utf-8", errors="replace") for path in files} + codes = {path: strip_comments(text) for path, text in texts.items()} + types = crate_type_index(list(texts.values())) + aliases: dict[str, str] = {} + for code in codes.values(): + for match in TYPE_ALIAS.finditer(code): + aliases.setdefault(match.group(1), match.group(2)) + all_code = "\n".join(codes.values()) + # One pass over every upper-case identifier, instead of one full-text + # regex scan per candidate (hundreds of them over ~30 MB of source). + mentions: dict[str, list[int]] = {} + for match in UPPER_IDENT.finditer(all_code): + mentions.setdefault(match.group(0), []).append(match.end()) + + candidates: list[dict] = [] + for path, text in texts.items(): + rel = path.relative_to(root).as_posix() + in_tls = declarations_in_tls(text) + lines = codes[path].splitlines() + raw_lines = text.splitlines() + for name, line, type_text in declarations(rel, text): + if (name, line) in in_tls: + continue + type_text = type_text.strip() + if FN_POINTER_TYPE.match(type_text): + continue + decl_line = lines[line - 1] + expanded = _expand(type_text, aliases, types) + if not (re.search(r"\bstatic\s+mut\b", decl_line) or MUTABLE.search(expanded)): + continue + if not WORD.search(expanded): + continue + attrs = " ".join(raw_lines[max(0, line - 4) : line]) + counter_only = ( + bool(SCALAR_ATOMIC.match(type_text)) + and not EXPORTED.search(attrs) + and _uses_are_counter_only(name, all_code, mentions.get(name, [])) + ) + outer = OUTER_NAME.match(type_text) + candidates.append( + { + "file": rel, + "name": name, + "line": line, + "type": type_text, + "outer": outer.group(1) if outer else type_text, + "counter_only": counter_only, + } + ) + return candidates + + +def _uses_are_counter_only(name: str, all_code: str, ends: list[int]) -> bool: + uses = 0 + for match_end in ends: + start = all_code.rfind("\n", 0, match_end) + 1 + end = all_code.find("\n", match_end) + line = all_code[start : end if end >= 0 else len(all_code)] + if re.search(r"\bstatic\s+(?:mut\s+)?%s\s*:" % re.escape(name), line): + continue + if re.match(r"\s*(?:pub(?:\([^)]*\))?\s+)?use\b", line): + continue + uses += 1 + if not COUNTER_USE.match(all_code[match_end : match_end + 120]): + return False + return uses > 0 + + +def _brace_body(code: str, open_at: int) -> str: + """Text between the `{` at `open_at` and its matching `}`.""" + depth = 0 + for index in range(open_at, len(code)): + if code[index] == "{": + depth += 1 + elif code[index] == "}": + depth -= 1 + if depth == 0: + return code[open_at + 1 : index] + return "" + + +CALL = re.compile(r"\b(\w+)\s*\(") +REGISTERED_HOOK = re.compile( + r"\b(?:register_thread_exit_range_hook|register_retire_hook)\s*\(\s*(?:[\w:]*::)?(\w+)\s*,?\s*\)" +) +DROP_IMPL = re.compile(r"impl\s+Drop\s+for\s+(\w+)\s*\{") + + +def exit_hooks(root: Path) -> set[str]: + """Function names that provably run when a thread exits. + + * called in `impl Drop for Arena` (the arena's own TLS destructor); + * called in `arena::thread_exit::release_freed_ranges`, which that drop + calls with the freed ranges; + * passed to `register_thread_exit_range_hook` (run by the same dispatcher) + or to `agent::register_retire_hook` / called in `retire_agent` (run when + a worker agent retires at thread exit); + * called in an `impl Drop for T` whose `T` is the type of a + `thread_local!` in the same file (a TLS destructor of its own). + """ + hooks: set[str] = set() + for path in crate_source_files(root, CRATES): + rel = path.relative_to(root).as_posix() + code = strip_comments(path.read_text(encoding="utf-8", errors="replace")) + hooks.update(REGISTERED_HOOK.findall(code)) + if rel == ARENA_FILE: + match = ARENA_DROP.search(code) + if match: + hooks.update(CALL.findall(_brace_body(code, match.end() - 1))) + for fn_name in ("release_freed_ranges", "retire_agent"): + match = re.search(r"\bfn\s+%s\s*\([^{]*\{" % fn_name, code) + if match and rel in (THREAD_EXIT_FILE, AGENT_FILE): + hooks.update(CALL.findall(_brace_body(code, match.end() - 1))) + tls_types = { + decl_type + for name, line, decl_type in declarations(rel, code) + if (name, line) in declarations_in_tls(code) + } + for match in DROP_IMPL.finditer(code): + owner = match.group(1) + if owner != "Arena" and any(re.search(r"\b%s\b" % owner, t) for t in tls_types): + hooks.update(CALL.findall(_brace_body(code, match.end() - 1))) + return hooks + + +def load_inventory(path: Path) -> dict: + if not path.exists(): + return {"type_verdicts": [], "entries": []} + return json.loads(path.read_text(encoding="utf-8")) + + +def check(root: Path, inventory: dict) -> tuple[list[dict], list[str], dict]: + """(unclassified candidates, problems, stats).""" + candidates = scan(root) + drop_calls = exit_hooks(root) + problems: list[str] = [] + type_verdicts = {tv["type"]: tv for tv in inventory.get("type_verdicts", [])} + for tv in inventory.get("type_verdicts", []): + if tv.get("verdict") not in {"per_thread", "no_heap_address"}: + problems.append(f"type_verdicts[{tv.get('type')}]: verdict must be per_thread or no_heap_address") + if len((tv.get("why") or "").strip()) < 20: + problems.append(f"type_verdicts[{tv.get('type')}]: `why` missing or too short") + + index: dict[tuple[str, str], dict] = {} + for entry in inventory.get("entries", []): + label = f"{entry.get('file', '?')}:{','.join(entry.get('names', []))}" + verdict = entry.get("verdict") + if verdict not in VERDICTS: + problems.append(f"{label}: verdict {verdict!r} is not one of {sorted(VERDICTS)}") + if len((entry.get("why") or "").strip()) < 20: + problems.append(f"{label}: `why` missing or too short to be a reason") + if not entry.get("names"): + problems.append(f"{label}: entry names no static") + for name in entry.get("names", []): + key = (entry.get("file", ""), name) + if key in index: + problems.append(f"{entry.get('file')}:{name}: classified twice") + index[key] = entry + hook = (entry.get("hook") or "").strip() + if verdict == "open_bug": + problems.append( + f"{label}: open_bug ({entry.get('issue', 'no issue')}) — a static that can " + f"describe a dead thread's memory; invalidate it in Arena::drop or make it per-thread" + ) + if verdict == "thread_exit_invalidated" and not hook: + problems.append(f"{label}: {verdict} must name the `hook` that runs at thread exit") + if verdict == "thread_exit_invalidated" and hook: + if hook not in drop_calls: + problems.append( + f"{label}: hook `{hook}` does not run at thread exit (not called by " + f"`impl Drop for Arena` / `release_freed_ranges` / `retire_agent` / a TLS " + f"destructor, nor registered); the statics it covers now outlive their thread" + ) + else: + hook_file = entry.get("hook_file") or entry.get("file", "") + if not _defines_fn(root / hook_file, hook): + problems.append(f"{label}: hook `{hook}` is not defined in {hook_file}") + if verdict == "pending_fix": + if not (entry.get("pr") or "").strip(): + problems.append(f"{label}: pending_fix must cite the fix `pr`") + if hook and hook in drop_calls: + problems.append( + f"{label}: its fix landed (`{hook}` now runs at thread exit) — " + f"promote the entry to thread_exit_invalidated" + ) + + unclassified: list[dict] = [] + used_keys: set[tuple[str, str]] = set() + used_types: set[str] = set() + counter_only = 0 + for cand in candidates: + key = (cand["file"], cand["name"]) + if key in index: + used_keys.add(key) + if cand["counter_only"]: + problems.append( + f"{cand['file']}:{cand['name']}: counter-only (discharged mechanically); " + f"delete its inventory entry" + ) + continue + if cand["counter_only"]: + counter_only += 1 + continue + if cand["outer"] in type_verdicts: + used_types.add(cand["outer"]) + continue + unclassified.append(cand) + for key, entry in index.items(): + if key not in used_keys: + problems.append(f"{key[0]}:{key[1]}: stale entry — no such candidate static") + for name in type_verdicts: + if name not in used_types: + problems.append(f"type_verdicts[{name}]: stale — no candidate has this outer type") + stats = {"candidates": len(candidates), "counter_only": counter_only, "entries": len(index)} + return unclassified, problems, stats + + +def _defines_fn(path: Path, name: str) -> bool: + if not path.exists(): + return False + return name in function_bodies(path.read_text(encoding="utf-8", errors="replace")) + + +def report(root: Path, inventory_path: Path, quiet: bool = False) -> int: + inventory = load_inventory(inventory_path) + unclassified, problems, stats = check(root, inventory) + if stats["candidates"] < MIN_CANDIDATES or stats["counter_only"] < MIN_COUNTER_ONLY: + print( + f"thread_exit_address_globals: only {stats['candidates']} candidates / " + f"{stats['counter_only']} counter-only (floors {MIN_CANDIDATES}/{MIN_COUNTER_ONLY}) — " + f"the extraction regexes stopped matching; this run checked nothing", + file=sys.stderr, + ) + return 2 + for cand in unclassified: + problems.append( + f"{cand['file']}:{cand['line']}: `{cand['name']}: {cand['type']}` is a process-global " + f"static that can hold a heap address and has no thread-exit verdict in " + f"{inventory_path.name}. After its thread exits, Arena::drop frees the memory and " + f"another thread reuses it (#11471): invalidate it there, make it per-thread, or " + f"record why it cannot hold an arena address." + ) + for problem in problems: + print(f"thread_exit_address_globals: {problem}", file=sys.stderr) + pending = [e for e in inventory.get("entries", []) if e.get("verdict") == "pending_fix"] + if not quiet: + print( + f"thread_exit_address_globals: {stats['candidates']} candidates, " + f"{stats['counter_only']} counter-only, {stats['entries']} classified by entry, " + f"{len(pending)} pending_fix group(s), {len(problems)} problem(s)" + ) + return 1 if problems else 0 + + +def print_list(root: Path, inventory_path: Path) -> int: + inventory = load_inventory(inventory_path) + index = {(e["file"], n): e for e in inventory.get("entries", []) for n in e.get("names", [])} + types = {tv["type"]: tv for tv in inventory.get("type_verdicts", [])} + for cand in scan(root): + if cand["counter_only"]: + verdict = "counter_only" + elif (cand["file"], cand["name"]) in index: + verdict = index[(cand["file"], cand["name"])]["verdict"] + elif cand["outer"] in types: + verdict = types[cand["outer"]]["verdict"] + " (type)" + else: + verdict = "UNCLASSIFIED" + print(f"{verdict:26} {cand['file']}:{cand['line']} {cand['name']}: {cand['type']}") + return 0 + + +# ---------------------------------------------------------------- self-test + +SELF_TEST_TREE = { + ARENA_FILE: """ +pub(crate) struct Arena { blocks: Vec } +impl Drop for Arena { + fn drop(&mut self) { + crate::cache::forget_in_ranges(&[]); + } +} +""", + "crates/perry-runtime/src/cache.rs": """ +use std::sync::{Mutex, OnceLock}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +static ADDR_CACHE: Mutex> = Mutex::new(Vec::new()); +pub fn forget_in_ranges(r: &[(usize, usize)]) { let _ = r; } +static HITS: AtomicU64 = AtomicU64::new(0); +pub fn hit() { HITS.fetch_add(1, Ordering::Relaxed); HITS.store(0, Ordering::Relaxed); } +static NAMES: Mutex> = Mutex::new(Vec::new()); +thread_local! { + static LOCAL_TABLE: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); +} +""", +} + +SELF_TEST_INVENTORY = { + "type_verdicts": [], + "entries": [ + { + "file": "crates/perry-runtime/src/cache.rs", + "names": ["ADDR_CACHE"], + "verdict": "thread_exit_invalidated", + "hook": "forget_in_ranges", + "why": "self-test fixture: cleared by forget_in_ranges from Arena::drop", + } + ], +} + + +def _run_fixture(extra: dict[str, str] | None, inventory: dict, drop: tuple[str, str] | None = None): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tree = dict(SELF_TEST_TREE) + tree.update(extra or {}) + for rel, body in tree.items(): + if drop and rel == drop[0]: + body = body.replace(drop[1], "") + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return check(root, inventory), scan(root) + + +def self_test() -> int: + failures: list[str] = [] + + def expect(cond: bool, what: str) -> None: + if not cond: + failures.append(what) + + (unclassified, problems, _), cands = _run_fixture(None, SELF_TEST_INVENTORY) + names = {c["name"]: c for c in cands} + expect(not unclassified and not problems, f"baseline fixture must be clean: {unclassified} {problems}") + expect("LOCAL_TABLE" not in names, "a thread_local! static must not be enumerated") + expect("NAMES" not in names, "a Mutex> holds no word and must not be enumerated") + expect(names.get("HITS", {}).get("counter_only"), "a fetch_add/literal-store atomic is counter-only") + + # 1. A new address-keyed table with no verdict fails. + (unclassified, _, _), _ = _run_fixture( + {"crates/perry-stdlib/src/new.rs": "static BY_ADDR: OnceLock>> = OnceLock::new();\n"}, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "BY_ADDR" for c in unclassified), "an unclassified address-keyed map must fail") + + # 2. An address stored into an atomic is NOT counter-only, whatever its name says. + (unclassified, _, _), _ = _run_fixture( + { + "crates/perry-runtime/src/cnt.rs": ( + "static OBJECT_COUNT: AtomicU64 = AtomicU64::new(0);\n" + "pub fn f(p: *mut u8) { OBJECT_COUNT.store(p as u64, Ordering::Relaxed); }\n" + ) + }, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "OBJECT_COUNT" for c in unclassified), "a computed store must defeat counter-only") + + # 3. per_test_global! is process-global in production and IS enumerated. + (unclassified, _, _), _ = _run_fixture( + {"crates/perry-runtime/src/ptg.rs": "per_test_global! {\n static SIDE: OnceLock>> = OnceLock::new();\n}\n"}, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "SIDE" for c in unclassified), "per_test_global! statics must be enumerated") + + # 4. A crate-local struct holding a usize is resolved. + (unclassified, _, _), _ = _run_fixture( + {"crates/perry-runtime/src/st.rs": "struct Slot { ptr: usize }\nstatic SLOTS: Mutex> = Mutex::new(Vec::new());\n"}, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "SLOTS" for c in unclassified), "a struct field holding usize must be resolved") + + # 5. Deleting the Arena::drop call reddens a thread_exit_invalidated entry. + (_, problems, _), _ = _run_fixture(None, SELF_TEST_INVENTORY, drop=(ARENA_FILE, "crate::cache::forget_in_ranges(&[]);")) + expect(any("does not run at thread exit" in p for p in problems), "a removed hook call must fail") + + # 5b. A downstream table registered through the range-hook registry passes; + # deleting the registration fails; a hook defined elsewhere fails. + stdlib_table = { + "crates/perry-stdlib/src/lst.rs": ( + "static LISTENERS: Mutex> = Mutex::new(Vec::new());\n" + "fn release_listeners(f: &FreedRanges) { let _ = f; }\n" + "pub fn on(cb: i64) { REGISTER.call_once(|| " + "perry_runtime::arena::thread_exit::register_thread_exit_range_hook(\n release_listeners,\n )); }\n" + ) + } + reg = json.loads(json.dumps(SELF_TEST_INVENTORY)) + reg["entries"].append({"file": "crates/perry-stdlib/src/lst.rs", "names": ["LISTENERS"], "verdict": "thread_exit_invalidated", "hook": "release_listeners", "why": "self-test fixture: registered range hook"}) + (unclassified, problems, _), _ = _run_fixture(stdlib_table, reg) + expect(not unclassified and not problems, f"a registered range hook must be accepted: {problems}") + (_, problems, _), _ = _run_fixture(stdlib_table, reg, drop=("crates/perry-stdlib/src/lst.rs", "register_thread_exit_range_hook(\n release_listeners,\n )")) + expect(any("does not run at thread exit" in p for p in problems), "a deleted registration must fail") + moved = json.loads(json.dumps(reg)) + moved["entries"][-1]["hook_file"] = "crates/perry-runtime/src/cache.rs" + (_, problems, _), _ = _run_fixture(stdlib_table, moved) + expect(any("is not defined in" in p for p in problems), "a hook defined in another file must fail") + + # 5c. A TLS destructor path counts: `impl Drop for T` where T lives in thread_local!. + tls_drop = { + "crates/perry-stdlib/src/own.rs": ( + "static RECORDS: Mutex> = Mutex::new(HashMap::new());\n" + "thread_local! {\n static OWNED: RefCell = RefCell::new(Owned::default());\n}\n" + "impl Drop for Owned {\n fn drop(&mut self) {\n release_owned(&self.0);\n }\n}\n" + "fn release_owned(ids: &[u64]) { let _ = ids; }\n" + ) + } + own = json.loads(json.dumps(SELF_TEST_INVENTORY)) + own["entries"].append({"file": "crates/perry-stdlib/src/own.rs", "names": ["RECORDS"], "verdict": "thread_exit_invalidated", "hook": "release_owned", "why": "self-test fixture: TLS destructor path"}) + (unclassified, problems, _), _ = _run_fixture(tls_drop, own) + expect(not unclassified and not problems, f"a TLS-destructor hook must be accepted: {problems}") + + # 6. A stale entry fails. + stale = json.loads(json.dumps(SELF_TEST_INVENTORY)) + stale["entries"].append({"file": "crates/perry-runtime/src/cache.rs", "names": ["GONE"], "verdict": "no_heap_address", "why": "self-test fixture for a stale entry"}) + (_, problems, _), _ = _run_fixture(None, stale) + expect(any("stale entry" in p for p in problems), "a stale entry must fail") + + # 7. open_bug always fails; pending_fix fails once its hook lands. + bug = json.loads(json.dumps(SELF_TEST_INVENTORY)) + bug["entries"][0].update(verdict="open_bug", issue="#1") + (_, problems, _), _ = _run_fixture(None, bug) + expect(any("open_bug" in p for p in problems), "open_bug must fail") + pend = json.loads(json.dumps(SELF_TEST_INVENTORY)) + pend["entries"][0].update(verdict="pending_fix", pr="#2") + (_, problems, _), _ = _run_fixture(None, pend) + expect(any("promote the entry" in p for p in problems), "a landed pending_fix must fail") + + # 8. A stale type verdict fails. + tv = json.loads(json.dumps(SELF_TEST_INVENTORY)) + tv["type_verdicts"] = [{"type": "NoSuchWrapper", "verdict": "per_thread", "why": "self-test fixture for a stale type verdict"}] + (_, problems, _), _ = _run_fixture(None, tv) + expect(any("type_verdicts[NoSuchWrapper]: stale" in p for p in problems), "a stale type verdict must fail") + + # 8b. A type alias spanning several lines (rustfmt's shape for a long + # `Arc`) is still resolved. + (unclassified, _, _), _ = _run_fixture( + {"crates/perry-stdlib/src/alias.rs": "type Releaser = std::sync::Arc<\n dyn Fn(&mut u8) -> bool\n + Send,\n>;\nstatic RELEASERS: Mutex> = Mutex::new(Vec::new());\n"}, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "RELEASERS" for c in unclassified), "a multi-line type alias must be resolved") + + # 9. An exported atomic is never counter-only (codegen may write it). + (unclassified, _, _), _ = _run_fixture( + {"crates/perry-runtime/src/exp.rs": "#[no_mangle]\npub static EXPORTED_WORD: AtomicU64 = AtomicU64::new(0);\npub fn g() { EXPORTED_WORD.load(Ordering::Relaxed); }\n"}, + SELF_TEST_INVENTORY, + ) + expect(any(c["name"] == "EXPORTED_WORD" for c in unclassified), "an exported atomic must not be counter-only") + + if failures: + for failure in failures: + print(f"thread_exit_address_globals self-test FAILED: {failure}", file=sys.stderr) + return 1 + print("thread_exit_address_globals self-test: every planted shape caught") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--self-test", action="store_true", help="check the checker") + parser.add_argument("--list", action="store_true", help="print every candidate + verdict") + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + if args.self_test: + return self_test() + if args.list: + return print_list(REPO_ROOT, INVENTORY_PATH) + return report(REPO_ROOT, INVENTORY_PATH, quiet=args.quiet) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/thread_local_cold_allowlist.json b/scripts/thread_local_cold_allowlist.json index 971cf1697f9..4790ba7628d 100644 --- a/scripts/thread_local_cold_allowlist.json +++ b/scripts/thread_local_cold_allowlist.json @@ -1,6 +1,6 @@ { "_comment": "Files still declaring raw `thread_local!`. The count is the number of DECLARATIONS that survive into a shipping build \u2014 each one pays `_tlv_get_addr` per read on Darwin \u2014 and it is a ratchet, so adding a `static` to an already-listed file fails whether or not it opens a new block. New code should use `crate::perry_thread_local!` \u2014 see crates/perry-runtime/src/tls_hot.rs. Regenerate with scripts/check_thread_locals.py --update.", - "_hot_declarations": 520, + "_hot_declarations": 518, "files": { "crates/perry-runtime/src/agent.rs": 1, "crates/perry-runtime/src/arena/block.rs": 5, @@ -57,7 +57,7 @@ "crates/perry-runtime/src/node_stream_constructors.rs": 3, "crates/perry-runtime/src/node_stream_tests.rs": 24, "crates/perry-runtime/src/node_submodules/blob.rs": 4, - "crates/perry-runtime/src/node_submodules/diagnostics.rs": 9, + "crates/perry-runtime/src/node_submodules/diagnostics.rs": 10, "crates/perry-runtime/src/node_submodules/diagnostics_tail.rs": 2, "crates/perry-runtime/src/node_submodules/mod.rs": 4, "crates/perry-runtime/src/node_submodules/test.rs": 11,