You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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:
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*mutcrate::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.
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.
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:195CACHED_PERMISSION, process/report.rs:111CACHED_REPORT — same shape as (1).
tty.rs:58RESIZE_CALLBACK — process.stdout.on('resize', cb) stored in a native slot that bypasses the (rooted) EventEmitter listener array.
frame.rs:30FRAME_CALLBACKS — onFrame(cb); rooted only transiently by a RuntimeHandleScope during registration.
object/class_registry/construct.rs:4CURRENT_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:199ACCESSOR_RECEIVER_OVERRIDE — armed across a prototype walk that allocates.
object/collection_proto_thunks.rs:19BUILTIN_MAP_SET_VALUE_BITS / BUILTIN_SET_ADD_VALUE_BITS; object/native_module/callable_exports.rs:1710BUILTIN_CLOSURE_LENGTH / _NON_CONSTRUCTABLE — address-identity keys; stale after a move, and wrong after address reuse.
json/mod.rs:99SHAPE_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.rsIntervalTimer.args — setInterval(fn, d, freshObject); the adjacent CALLBACK_TIMERS and MOCK_TIMERS blocks both loop args, INTERVAL_TIMERS does not. promise/rejection.rsinternally_handled. fs/dir_glob_watch/watch.rsGLOB_ITERATORS. fs/stream.rsUtf8StreamState.mode_value.
Narrower/latent: node_repl.rs:14RECOVERABLE_ERRORS, module_require.rs:240MODULE_PATH_REGISTRY (may be independently rooted via a codegen module global — unresolved), child_process/v8_serde.rs:1048id_table across streaming calls, dgram.rs:260DGRAM_REGISTRY.bound (test-mode only), static_plugins.rs:16 (currently unreachable), event_target.rs:439DOM_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/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-78overflow_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.
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/RefCellholder of a GC pointer across all ofcrates/perry-runtime/src/, cross-referenced against the completegc_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:
gc_root_dominance_check.pygc_root_dominance_check.pywill 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:1075—CACHED_ENV, theprocess.envobject. Highest impact in the list.Populated in
js_process_env_implwithcrate::object::js_object_alloc(0, alloc_limit)— nursery, no_longlived. Dereferenced and written through on everyprocess.env.X = v(env_misc.rs:934-944):grep -rn "CACHED_ENV" crates/perry-runtime/src/gc/→ nothing.process.envis touched by nearly every real Node program. The siblingPROCESS_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:822—ERROR_CONSTRUCTOR_PTR.Dereferenced on the
.stackcompute path aterror.rs:1342. No scanner. The canonicalErrorclosure insideglobalThisis rewritten viaglobalThis's structural trace; this raw-usizeduplicate lives outside that graph.3.
tui/input.rs:38—INPUT_HANDLER.Holds the user's
useInput((input, key) => …)closure — the only reference to it — anddrain_inputcalls 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:195CACHED_PERMISSION,process/report.rs:111CACHED_REPORT— same shape as (1).tty.rs:58RESIZE_CALLBACK—process.stdout.on('resize', cb)stored in a native slot that bypasses the (rooted) EventEmitter listener array.frame.rs:30FRAME_CALLBACKS—onFrame(cb); rooted only transiently by aRuntimeHandleScopeduring registration.object/class_registry/construct.rs:4CURRENT_NEW_TARGET— an unrooted duplicate of the valueNEW_TARGET(object/this_binding.rs:28) already tracks correctly viascan_implicit_this_roots_mut.object/field_get_set/accessors.rs:199ACCESSOR_RECEIVER_OVERRIDE— armed across a prototype walk that allocates.object/collection_proto_thunks.rs:19BUILTIN_MAP_SET_VALUE_BITS/BUILTIN_SET_ADD_VALUE_BITS;object/native_module/callable_exports.rs:1710BUILTIN_CLOSURE_LENGTH/_NON_CONSTRUCTABLE— address-identity keys; stale after a move, and wrong after address reuse.json/mod.rs:99SHAPE_CACHE— its safety comment says "within one top-level stringify call no GC runs over the user object graph", whichtoJSON()falsifies.timer.rsIntervalTimer.args—setInterval(fn, d, freshObject); the adjacentCALLBACK_TIMERSandMOCK_TIMERSblocks both loopargs,INTERVAL_TIMERSdoes not.promise/rejection.rsinternally_handled.fs/dir_glob_watch/watch.rsGLOB_ITERATORS.fs/stream.rsUtf8StreamState.mode_value.node_repl.rs:14RECOVERABLE_ERRORS,module_require.rs:240MODULE_PATH_REGISTRY(may be independently rooted via a codegen module global — unresolved),child_process/v8_serde.rs:1048id_tableacross streaming calls,dgram.rs:260DGRAM_REGISTRY.bound(test-mode only),static_plugins.rs:16(currently unreachable),event_target.rs:439DOM_EXCEPTION_ERRORS,node_submodules/diagnostics.rs:282symbol-keyed channels.Negative results worth keeping
The sweep confirmed ~70 registered scanners by reading the scanner body, not by name. Notable:
object/alloc.rs:10CLASS_KEYS_BY_IDis safe because every write site uses a_longlivedallocator — 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 64perry_class_keysalloca reports false positives.object/prop_plan.rscaches are epoch-invalidated on every collection rather than scanned — a legitimate third strategy.bun_ffi/types.rs:FFI_TYPE_OBJECT_CACHEis this exact shape, already correctly fixed — the pattern to copy.gc/roots/scanner_shims.rs:69-78overflow_fields_mutable_root_scanneris 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+ onegc_register_mutable_root_scannerline, followingscan_process_finalization_roots_mutexactly. 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.