Skip to content

gc: 18 subsystems register their root scanner behind a process-global latch, but the scanner registry is thread-local #8530

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions