Summary
MUTABLE_ROOT_SCANNERS is thread-local (crates/perry-runtime/src/gc/roots.rs:105). Registration must therefore happen once per thread. 18 files guard it with a process-global Once/OnceLock instead, so the first thread to touch the subsystem registers its scanner and consumes the latch — and every later thread's collector never scans that subsystem's roots at all.
This is the same defect #8503 just fixed for the yoga backend, where GC_SCANNER_REGISTERED was a global Mutex<bool> in front of the same thread-local registry. It is not a one-off.
The correct pattern, already in tree
gc/mod.rs:746 gets it right, and says why:
/// scanner list (`MUTABLE_ROOT_SCANNERS`) is thread-local, so soundness
/// requires every thread that can trigger a collection to register
/// independently — not just the main thread that runs `js_gc_init()`.
static GC_INIT_DONE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
Thread-local latch, thread-local registry. #8528 follows the same rule for the per-heap path-module table.
The 18 offenders
Each of these registers via gc_register_mutable_root_scanner* inside a call_once / get_or_init on a process-global latch:
crates/perry-runtime/src/cluster.rs
crates/perry-runtime/src/object/native_this_alias.rs
crates/perry-stdlib/src/commander.rs
crates/perry-stdlib/src/common/async_bridge.rs
crates/perry-stdlib/src/cron.rs
crates/perry-stdlib/src/crypto/hash_handles.rs
crates/perry-stdlib/src/domain.rs
crates/perry-stdlib/src/events.rs
crates/perry-stdlib/src/exponential_backoff.rs
crates/perry-stdlib/src/fetch/gc.rs
crates/perry-stdlib/src/net/mod.rs
crates/perry-stdlib/src/readline/mod.rs
crates/perry-stdlib/src/sqlite.rs
crates/perry-stdlib/src/streams/gc.rs
crates/perry-stdlib/src/tls.rs
crates/perry-stdlib/src/worker_threads.rs
crates/perry-stdlib/src/ws.rs
crates/perry-stdlib/src/zlib.rs
Worked example — worker_threads.rs:175,228:
static WORKER_GC_REGISTERED: Once = Once::new();
...
WORKER_GC_REGISTERED.call_once(|| {
perry_runtime::gc::gc_register_mutable_root_scanner_named(
"stdlib:worker_threads:workers", scan_worker_roots_mut);
});
scan_worker_roots_mut walks a process-global WORKERS: Mutex<HashMap<..>> holding NaN-boxed listener callbacks — and, after #8527, the Worker handle itself. A worker_threads program is multi-threaded by construction, so this one is not hypothetical.
Triage note
The pattern is uniformly wrong; the reachability is not uniform. A subsystem only ever touched from the main JS thread is latently wrong but currently harmless. Each of the 18 needs a one-line answer to "can a second heap reach this?" before deciding between converting the latch and documenting why it cannot happen. worker_threads, cluster, common/async_bridge, net, ws and streams look like the ones to check first.
Why it will not show up as a test failure
An unregistered scanner produces no error. The roots are simply invisible to that thread's collector, so the failure is a use-after-free surfacing later and elsewhere — the #7154 signature, TypeError: value is not a function in an unrelated function some cycles on. The failure direction is green.
Gate
scripts/gc_runtime_root_holders.py cannot catch this: it verifies a holder is reached by a registered scanner, treating registration as a static fact, not a per-thread one. A lint that flags gc_register_mutable_root_scanner* inside a non-thread-local call_once/get_or_init would be a cheap and exact check.
Found while auditing #8527, which adds a new root (worker.object_bits) to one of the mis-registered scanners.
Summary
MUTABLE_ROOT_SCANNERSis thread-local (crates/perry-runtime/src/gc/roots.rs:105). Registration must therefore happen once per thread. 18 files guard it with a process-globalOnce/OnceLockinstead, so the first thread to touch the subsystem registers its scanner and consumes the latch — and every later thread's collector never scans that subsystem's roots at all.This is the same defect #8503 just fixed for the yoga backend, where
GC_SCANNER_REGISTEREDwas a globalMutex<bool>in front of the same thread-local registry. It is not a one-off.The correct pattern, already in tree
gc/mod.rs:746gets it right, and says why:Thread-local latch, thread-local registry. #8528 follows the same rule for the per-heap path-module table.
The 18 offenders
Each of these registers via
gc_register_mutable_root_scanner*inside acall_once/get_or_initon a process-global latch:crates/perry-runtime/src/cluster.rscrates/perry-runtime/src/object/native_this_alias.rscrates/perry-stdlib/src/commander.rscrates/perry-stdlib/src/common/async_bridge.rscrates/perry-stdlib/src/cron.rscrates/perry-stdlib/src/crypto/hash_handles.rscrates/perry-stdlib/src/domain.rscrates/perry-stdlib/src/events.rscrates/perry-stdlib/src/exponential_backoff.rscrates/perry-stdlib/src/fetch/gc.rscrates/perry-stdlib/src/net/mod.rscrates/perry-stdlib/src/readline/mod.rscrates/perry-stdlib/src/sqlite.rscrates/perry-stdlib/src/streams/gc.rscrates/perry-stdlib/src/tls.rscrates/perry-stdlib/src/worker_threads.rscrates/perry-stdlib/src/ws.rscrates/perry-stdlib/src/zlib.rsWorked example —
worker_threads.rs:175,228:scan_worker_roots_mutwalks a process-globalWORKERS: Mutex<HashMap<..>>holding NaN-boxed listener callbacks — and, after #8527, the Worker handle itself. Aworker_threadsprogram is multi-threaded by construction, so this one is not hypothetical.Triage note
The pattern is uniformly wrong; the reachability is not uniform. A subsystem only ever touched from the main JS thread is latently wrong but currently harmless. Each of the 18 needs a one-line answer to "can a second heap reach this?" before deciding between converting the latch and documenting why it cannot happen.
worker_threads,cluster,common/async_bridge,net,wsandstreamslook like the ones to check first.Why it will not show up as a test failure
An unregistered scanner produces no error. The roots are simply invisible to that thread's collector, so the failure is a use-after-free surfacing later and elsewhere — the #7154 signature,
TypeError: value is not a functionin an unrelated function some cycles on. The failure direction is green.Gate
scripts/gc_runtime_root_holders.pycannot catch this: it verifies a holder is reached by a registered scanner, treating registration as a static fact, not a per-thread one. A lint that flagsgc_register_mutable_root_scanner*inside a non-thread-localcall_once/get_or_initwould be a cheap and exact check.Found while auditing #8527, which adds a new root (
worker.object_bits) to one of the mis-registered scanners.