Skip to content

GC: enumerate the unrooted runtime-side caches — the population no static checker can see #7231

Description

@proggeramlug

Summary

#7226 fixed three instances of a bug class nothing static can find: a runtime-side table that holds a GC pointer and is not a registered root. This issue is the enumeration nobody had done — a sweep of every thread_local! / static / OnceLock / Cell / RefCell holder of a GC pointer across all of crates/perry-runtime/src/, cross-referenced against the complete gc_register_mutable_root_scanner* registry.

The class matters because it is strictly worse than the #7154 stale-register class, and is detected by the opposite signal:

unrooted register (#7154) unrooted runtime cache
goes bad only if a collection lands in a few-instruction window at collection #0, permanently
reproduces intermittently deterministically
found by gc_root_dominance_check.py nothing static — the checker reads emitted IR and a runtime table is not in it

gc_root_dominance_check.py will never report any of these. That is not a gap in the tool; it is outside its subject.

Independently verified (I read the declaration, the allocator, the dereference, and grepped gc/ for a scanner)

1. process/env_misc.rs:1075CACHED_ENV, the process.env object. Highest impact in the list.

thread_local! { static CACHED_ENV: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) }; }

Populated in js_process_env_impl with crate::object::js_object_alloc(0, alloc_limit)nursery, no _longlived. Dereferenced and written through on every process.env.X = v (env_misc.rs:934-944):

let obj = crate::value::js_nanbox_get_pointer(cached) as *mut crate::ObjectHeader;js_object_set_field_by_name(obj, key, val_f64);

grep -rn "CACHED_ENV" crates/perry-runtime/src/gc/nothing. process.env is touched by nearly every real Node program. The sibling PROCESS_FINALIZATION_OBJECT (process.rs:302) uses the same materialize-once-cache idiom and is rooted (scan_process_finalization_roots_mut, gc/mod.rs:614) — so this is an omission, not a design.

2. object/global_this/populate.rs:822ERROR_CONSTRUCTOR_PTR.

pub(crate) static ERROR_CONSTRUCTOR_PTR: std::cell::Cell<usize> = …;
ERROR_CONSTRUCTOR_PTR.with(|c| c.set(ctor as usize));   // ctor from js_closure_alloc — nursery

Dereferenced on the .stack compute path at error.rs:1342. No scanner. The canonical Error closure inside globalThis is rewritten via globalThis's structural trace; this raw-usize duplicate lives outside that graph.

3. tui/input.rs:38INPUT_HANDLER.

static INPUT_HANDLER: AtomicI64 = AtomicI64::new(0);

Holds the user's useInput((input, key) => …) closure — the only reference to it — and drain_input calls through it on every keystroke. No scanner. The idiomatic Ink-style inline-arrow pattern dies at the first GC.

Reported by the sweep, not yet individually re-verified by me

Listed so they are not lost; each needs the same four-step confirmation before being treated as fact.

  • process/permission.rs:195 CACHED_PERMISSION, process/report.rs:111 CACHED_REPORT — same shape as (1).
  • tty.rs:58 RESIZE_CALLBACKprocess.stdout.on('resize', cb) stored in a native slot that bypasses the (rooted) EventEmitter listener array.
  • frame.rs:30 FRAME_CALLBACKSonFrame(cb); rooted only transiently by a RuntimeHandleScope during registration.
  • object/class_registry/construct.rs:4 CURRENT_NEW_TARGET — an unrooted duplicate of the value NEW_TARGET (object/this_binding.rs:28) already tracks correctly via scan_implicit_this_roots_mut.
  • object/field_get_set/accessors.rs:199 ACCESSOR_RECEIVER_OVERRIDE — armed across a prototype walk that allocates.
  • object/collection_proto_thunks.rs:19 BUILTIN_MAP_SET_VALUE_BITS / BUILTIN_SET_ADD_VALUE_BITS; object/native_module/callable_exports.rs:1710 BUILTIN_CLOSURE_LENGTH / _NON_CONSTRUCTABLE — address-identity keys; stale after a move, and wrong after address reuse.
  • json/mod.rs:99 SHAPE_CACHE — its safety comment says "within one top-level stringify call no GC runs over the user object graph", which toJSON() falsifies.
  • Scanner gaps (the table is rooted, one field is not): timer.rs IntervalTimer.argssetInterval(fn, d, freshObject); the adjacent CALLBACK_TIMERS and MOCK_TIMERS blocks both loop args, INTERVAL_TIMERS does not. promise/rejection.rs internally_handled. fs/dir_glob_watch/watch.rs GLOB_ITERATORS. fs/stream.rs Utf8StreamState.mode_value.
  • Narrower/latent: node_repl.rs:14 RECOVERABLE_ERRORS, module_require.rs:240 MODULE_PATH_REGISTRY (may be independently rooted via a codegen module global — unresolved), child_process/v8_serde.rs:1048 id_table across streaming calls, dgram.rs:260 DGRAM_REGISTRY.bound (test-mode only), static_plugins.rs:16 (currently unreachable), event_target.rs:439 DOM_EXCEPTION_ERRORS, node_submodules/diagnostics.rs:282 symbol-keyed channels.

Negative results worth keeping

The sweep confirmed ~70 registered scanners by reading the scanner body, not by name. Notable:

  • object/alloc.rs:10 CLASS_KEYS_BY_ID is safe because every write site uses a _longlived allocator — the same fact that makes GC: the remaining unrooted-alloca hazards after #7207 — class-keys pointer caches, interleaved staging arrays, inlined-callee param slots #7210's 64 perry_class_keys alloca reports false positives.
  • object/prop_plan.rs caches are epoch-invalidated on every collection rather than scanned — a legitimate third strategy.
  • bun_ffi/types.rs: FFI_TYPE_OBJECT_CACHE is this exact shape, already correctly fixed — the pattern to copy.
  • gc/roots/scanner_shims.rs:69-78 overflow_fields_mutable_root_scanner is defined, re-exported, and never called (coverage is real via the structural per-object trace). Dead code with a doc comment asserting it is needed — the shape CLAUDE.md's kill-policy warns about.

Suggested shape of the fix

Not one PR. The three verified ones are independent and each is a scan_*_roots_mut + one gc_register_mutable_root_scanner line, following scan_process_finalization_roots_mut exactly. The scanner gaps are cheaper still (one loop each) and should go first, because a partially-correct scanner is more dangerous than an absent one: it reads as covered.

Each fix needs a witness of the kind #7226 established — PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1, red at base, faulting at the stale dereference rather than cycles later.

Refs #7226, #7210, #7154, #7196, #6910.

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