From bc95330c8b5c074dcbc05f80e8adf07a1ea5f7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 06:49:49 +0200 Subject: [PATCH 01/10] runtime: thread-exit range-hook registry for address-keyed globals (#11471) --- crates/perry-runtime/src/arena/block.rs | 3 + crates/perry-runtime/src/arena/mod.rs | 2 + crates/perry-runtime/src/arena/thread_exit.rs | 146 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 crates/perry-runtime/src/arena/thread_exit.rs 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..1e8b2144273 --- /dev/null +++ b/crates/perry-runtime/src/arena/thread_exit.rs @@ -0,0 +1,146 @@ +//! 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`. +pub(crate) fn release_freed_ranges(ranges: &[(usize, usize)]) { + let freed = FreedRanges::new(ranges); + if freed.is_empty() { + return; + } + // 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"); + } +} From 152effc80c6154d6ffabf6a16e74cae68a30e907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 06:58:05 +0200 Subject: [PATCH 02/10] lint: thread-exit custody gate for address-keyed process globals (#11471) --- .github/workflows/test.yml | 13 + scripts/thread_exit_address_globals.py | 653 +++++++++++++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 scripts/thread_exit_address_globals.py 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/scripts/thread_exit_address_globals.py b/scripts/thread_exit_address_globals.py new file mode 100644 index 00000000000..10d875bced1 --- /dev/null +++ b/scripts/thread_exit_address_globals.py @@ -0,0 +1,653 @@ +#!/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 +) +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()) + + 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) + ) + 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) -> bool: + uses = 0 + for match in re.finditer(r"\b%s\b" % re.escape(name), all_code): + start = all_code.rfind("\n", 0, match.start()) + 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*\)" +) +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(release_listeners)); }\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(release_listeners)")) + 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") + + # 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()) From 5e5257aa8b81d78b7e49258e4a3bf276fee582f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 08:53:22 +0200 Subject: [PATCH 03/10] runtime/stdlib: release address-keyed process globals at thread exit (#11471) Every process-global table that can hold an arena or gc_malloc address now forgets the exiting thread's entries: Arena::drop and MallocState::drop report the freed ranges to arena::thread_exit, which runs a hook per table. Regression tests in perry-stdlib runtime_thread_exit_tests/. --- crates/perry-runtime/src/arena/thread_exit.rs | 34 +- crates/perry-runtime/src/async_hooks.rs | 9 + .../src/async_hooks/thread_exit.rs | 131 + crates/perry-runtime/src/buffer/header.rs | 56 + crates/perry-runtime/src/buffer/own_props.rs | 30 + crates/perry-runtime/src/builtins/console.rs | 22 + .../src/child_process/reactor.rs | 149 +- crates/perry-runtime/src/dgram.rs | 60 + crates/perry-runtime/src/dgram_reactor.rs | 67 + crates/perry-runtime/src/event_target.rs | 25 + crates/perry-runtime/src/frame.rs | 48 + crates/perry-runtime/src/gc/malloc.rs | 25 + .../perry-runtime/src/geisterhand_registry.rs | 48 + crates/perry-runtime/src/media_playback.rs | 28 + crates/perry-runtime/src/messaging.rs | 45 + .../src/node_submodules/diagnostics.rs | 75 +- crates/perry-runtime/src/node_vm.rs | 33 + .../src/object/global_this_webassembly.rs | 36 + crates/perry-runtime/src/object/mod.rs | 20 + .../perry-runtime/src/object/native_module.rs | 40 +- .../src/object/prototype_chain.rs | 39 + crates/perry-runtime/src/os/signal.rs | 94 + .../perry-runtime/src/os_process_streams.rs | 30 + crates/perry-runtime/src/plugin.rs | 31 + .../perry-runtime/src/promise/native_async.rs | 111 + crates/perry-runtime/src/pty/reactor.rs | 105 +- crates/perry-runtime/src/symbol.rs | 117 + crates/perry-runtime/src/symbol/accessors.rs | 42 + crates/perry-runtime/src/timer/mock.rs | 62 + crates/perry-runtime/src/tls.rs | 15 + crates/perry-runtime/src/tui/hooks.rs | 77 + crates/perry-runtime/src/tui/input.rs | 21 + crates/perry-runtime/src/tui/state.rs | 32 + crates/perry-runtime/src/typed_feedback.rs | 57 + crates/perry-runtime/src/ui_text_registry.rs | 42 + crates/perry-runtime/src/v8.rs | 49 + crates/perry-stdlib/src/common/handle.rs | 80 + .../perry-stdlib/src/crypto/hash_handles.rs | 25 + crates/perry-stdlib/src/domain.rs | 20 + crates/perry-stdlib/src/events.rs | 27 + crates/perry-stdlib/src/readline/mod.rs | 46 + .../src/runtime_thread_exit_tests.rs | 8 + .../listeners_tests.rs | 243 + .../reactors_tests.rs | 175 + .../stdlib_misc_tests.rs | 204 + .../streams_tests.rs | 232 + .../symbols_tests.rs | 347 ++ .../src/runtime_thread_exit_tests/ui_tests.rs | 219 + crates/perry-stdlib/src/sqlite.rs | 198 + crates/perry-stdlib/src/streams.rs | 205 + crates/perry-stdlib/src/streams/byob.rs | 36 +- crates/perry-stdlib/src/streams/expando.rs | 29 + crates/perry-stdlib/src/streams/transform.rs | 76 + crates/perry-stdlib/src/tls.rs | 122 + crates/perry-stdlib/src/webcrypto/util.rs | 47 + crates/perry-stdlib/src/worker_threads.rs | 91 + crates/perry-stdlib/src/zlib.rs | 90 + scripts/thread_exit_address_globals.json | 4141 +++++++++++++++++ scripts/thread_exit_address_globals.py | 16 +- 59 files changed, 8522 insertions(+), 60 deletions(-) create mode 100644 crates/perry-runtime/src/async_hooks/thread_exit.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/reactors_tests.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/stdlib_misc_tests.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/streams_tests.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs create mode 100644 crates/perry-stdlib/src/runtime_thread_exit_tests/ui_tests.rs create mode 100644 scripts/thread_exit_address_globals.json diff --git a/crates/perry-runtime/src/arena/thread_exit.rs b/crates/perry-runtime/src/arena/thread_exit.rs index 1e8b2144273..96c121919cc 100644 --- a/crates/perry-runtime/src/arena/thread_exit.rs +++ b/crates/perry-runtime/src/arena/thread_exit.rs @@ -107,12 +107,44 @@ pub fn register_thread_exit_range_hook(hook: fn(&FreedRanges)) { } } -/// Run every registered hook over `ranges`. Called by `Arena::drop`. +/// 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() 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..7e0fe193c02 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -167,6 +167,31 @@ 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..05b0bc351e0 --- /dev/null +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -0,0 +1,347 @@ +//! #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 CLASS_ID: 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(CLASS_ID, 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(CLASS_ID, 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(CLASS_ID, 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/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json new file mode 100644 index 00000000000..20a99b0ceac --- /dev/null +++ b/scripts/thread_exit_address_globals.json @@ -0,0 +1,4141 @@ +{ + "_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": "pending_fix", + "why": "Direct-mapped process-global cache of (typed-array address << 8 | kind); a thread exit left positive entries for freed blocks (#11463). PR #11470 clears them from Arena::drop.", + "hook": "invalidate_kind_caches_in_ranges", + "pr": "#11470" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "names": [ + "INLINE_OWNING_U32_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global cache of owning-Uint32Array addresses admitted by the codegen inline path; same thread-exit staleness as PERRY_TA_KIND_CACHE, fixed by the same PR #11470 hook.", + "hook": "invalidate_kind_caches_in_ranges", + "pr": "#11470" + }, + { + "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": [ + "ROOT_CERTS_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", + "pr": "#11462" + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "DEFAULT_CA_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", + "pr": "#11462" + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "SYSTEM_CA_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", + "pr": "#11462" + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "EXTRA_CA_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", + "pr": "#11462" + }, + { + "file": "crates/perry-runtime/src/tls.rs", + "names": [ + "SHARED_SIGALGS_CACHE" + ], + "verdict": "pending_fix", + "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", + "pr": "#11462" + }, + { + "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 index 10d875bced1..08cd421c267 100644 --- a/scripts/thread_exit_address_globals.py +++ b/scripts/thread_exit_address_globals.py @@ -158,7 +158,7 @@ ) 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 + r"^\s*(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)(?:<[^=]*>)?\s*=\s*(.+?);", re.M | re.S ) TYPE_IDENT = re.compile(r"\b[A-Z]\w*\b") OUTER_NAME = re.compile(r"^(?:[\w$]+::)*(\w+)") @@ -272,7 +272,7 @@ def _brace_body(code: str, open_at: int) -> str: 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*\)" + 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*\{") @@ -571,14 +571,14 @@ def expect(cond: bool, what: str) -> None: "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(release_listeners)); }\n" + "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(release_listeners)")) + (_, 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" @@ -621,6 +621,14 @@ def expect(cond: bool, what: str) -> None: (_, 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"}, From bb9d902c1643f87fc0611d08b87ef3aed5dd9a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 08:56:22 +0200 Subject: [PATCH 04/10] lint: #11462/#11467 landed; record the typed-array caches' Arena::drop hook --- scripts/thread_exit_address_globals.json | 59 +++--------------------- 1 file changed, 6 insertions(+), 53 deletions(-) diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 20a99b0ceac..70f2b8572db 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -1576,20 +1576,18 @@ "names": [ "PERRY_TA_KIND_CACHE" ], - "verdict": "pending_fix", - "why": "Direct-mapped process-global cache of (typed-array address << 8 | kind); a thread exit left positive entries for freed blocks (#11463). PR #11470 clears them from Arena::drop.", - "hook": "invalidate_kind_caches_in_ranges", - "pr": "#11470" + "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": "pending_fix", - "why": "Process-global cache of owning-Uint32Array addresses admitted by the codegen inline path; same thread-exit staleness as PERRY_TA_KIND_CACHE, fixed by the same PR #11470 hook.", - "hook": "invalidate_kind_caches_in_ranges", - "pr": "#11470" + "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", @@ -3203,51 +3201,6 @@ "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": [ - "ROOT_CERTS_CACHE" - ], - "verdict": "pending_fix", - "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", - "pr": "#11462" - }, - { - "file": "crates/perry-runtime/src/tls.rs", - "names": [ - "DEFAULT_CA_CACHE" - ], - "verdict": "pending_fix", - "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", - "pr": "#11462" - }, - { - "file": "crates/perry-runtime/src/tls.rs", - "names": [ - "SYSTEM_CA_CACHE" - ], - "verdict": "pending_fix", - "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", - "pr": "#11462" - }, - { - "file": "crates/perry-runtime/src/tls.rs", - "names": [ - "EXTRA_CA_CACHE" - ], - "verdict": "pending_fix", - "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", - "pr": "#11462" - }, - { - "file": "crates/perry-runtime/src/tls.rs", - "names": [ - "SHARED_SIGALGS_CACHE" - ], - "verdict": "pending_fix", - "why": "Process-global AtomicU64 GC root caching a JS array built on whichever thread asked first; pins/dangles after that thread exits. PR #11462 makes it per-thread, which deletes this static (the entry then goes stale and must be removed).", - "pr": "#11462" - }, { "file": "crates/perry-runtime/src/tls.rs", "names": [ From 15f5ec6e985cc3de5a5d4430d901dca6c456ae26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:05:09 +0200 Subject: [PATCH 05/10] fmt: thread_exit dispatcher and malloc range report --- crates/perry-runtime/src/arena/thread_exit.rs | 5 ++++- crates/perry-runtime/src/gc/malloc.rs | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/arena/thread_exit.rs b/crates/perry-runtime/src/arena/thread_exit.rs index 96c121919cc..71cebd8ae05 100644 --- a/crates/perry-runtime/src/arena/thread_exit.rs +++ b/crates/perry-runtime/src/arena/thread_exit.rs @@ -173,6 +173,9 @@ mod tests { 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"); + assert!( + !freed.holds_bits(0x7FFE_0000_0000_2010), + "an int32 is not an address" + ); } } diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index 7e0fe193c02..890a37cea1a 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -184,8 +184,7 @@ impl Drop for MallocState { .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) }; + 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)) }) From ecbf50010ef53722168141b3bd1136321f7d73a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:14:13 +0200 Subject: [PATCH 06/10] lint: record CONSOLE_CHANNEL_IDS holder verdict; rename test class constant --- .../src/runtime_thread_exit_tests/symbols_tests.rs | 8 ++++---- scripts/gc_runtime_root_holders.json | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) 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 index 05b0bc351e0..3ecd4bfd2ab 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -57,7 +57,7 @@ fn u8_cache_holds(addr: usize) -> bool { #[test] fn thread_exit_releases_the_threads_symbol_side_table_entries() { - const CLASS_ID: u32 = 0x0B11_4711; + const STATIC_SYMBOL_CLASS: u32 = 0x0B11_4711; let ((owner, sym), alive) = std::thread::spawn(|| { use perry_runtime::symbol as s; let scope = RuntimeHandleScope::new(); @@ -108,7 +108,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { ); // static [sym] = [] on a (process-global) class id (CLASS_STATIC_SYMBOLS). unsafe { - s::js_class_register_static_symbol(CLASS_ID, sym.get_nanbox_f64(), value_value()) + s::js_class_register_static_symbol(STATIC_SYMBOL_CLASS, sym.get_nanbox_f64(), value_value()) }; let owner = obj.get_raw_mut_ptr::() as usize; @@ -121,7 +121,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { 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(CLASS_ID, sym), + s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym), ]; ((owner, [sym, sym2, sym3]), alive) }) @@ -146,7 +146,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { "a dead thread's symbol accessor outlived its heap" ); assert!( - !s::class_static_symbol_held_for_test(CLASS_ID, sym[0]), + !s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym[0]), "a dead thread's class-static symbol member outlived its heap" ); } 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", From 2397ff2ad1713e890663a146ad0fcc7f38aa10b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:20:23 +0200 Subject: [PATCH 07/10] fmt: symbols thread-exit test --- .../src/runtime_thread_exit_tests/symbols_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 3ecd4bfd2ab..10739032d62 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -108,7 +108,11 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { ); // 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()) + s::js_class_register_static_symbol( + STATIC_SYMBOL_CLASS, + sym.get_nanbox_f64(), + value_value(), + ) }; let owner = obj.get_raw_mut_ptr::() as usize; From d47a39fa579671212b424ad7affee807bb046598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:24:38 +0200 Subject: [PATCH 08/10] lint: index identifier mentions once in the thread-exit gate --- scripts/thread_exit_address_globals.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/thread_exit_address_globals.py b/scripts/thread_exit_address_globals.py index 08cd421c267..3de03220490 100644 --- a/scripts/thread_exit_address_globals.py +++ b/scripts/thread_exit_address_globals.py @@ -160,6 +160,7 @@ 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 @@ -202,6 +203,11 @@ def scan(root: Path) -> list[dict]: 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(): @@ -225,7 +231,7 @@ def scan(root: Path) -> list[dict]: counter_only = ( bool(SCALAR_ATOMIC.match(type_text)) and not EXPORTED.search(attrs) - and _uses_are_counter_only(name, all_code) + and _uses_are_counter_only(name, all_code, mentions.get(name, [])) ) outer = OUTER_NAME.match(type_text) candidates.append( @@ -241,18 +247,18 @@ def scan(root: Path) -> list[dict]: return candidates -def _uses_are_counter_only(name: str, all_code: str) -> bool: +def _uses_are_counter_only(name: str, all_code: str, ends: list[int]) -> bool: uses = 0 - for match in re.finditer(r"\b%s\b" % re.escape(name), all_code): - start = all_code.rfind("\n", 0, match.start()) + 1 - end = all_code.find("\n", match.end()) + 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]): + if not COUNTER_USE.match(all_code[match_end : match_end + 120]): return False return uses > 0 From e8a93c85e8acb7d49930034f8bc01c478f514805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:33:00 +0200 Subject: [PATCH 09/10] docs: changeset for #11485 --- changelog.d/11485-thread-exit-address-globals.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11485-thread-exit-address-globals.md 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. From e07a649d686fd41c4a0bc4eb457a2591be9702d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:37:27 +0200 Subject: [PATCH 10/10] tls: record the per-thread console diagnostics-channel ids as cold --- scripts/thread_local_cold_allowlist.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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,