Repository navigation
GC: enumerate the unrooted runtime-side caches — the population no static checker can see #7231
Description
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:1075 — CACHED_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:822 — ERROR_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 — nurseryDereferenced 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:38 — INPUT_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: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.- Scanner gaps (the table is rooted, one field is not):
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. - 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_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 + 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.
Activity
fix(gc): scan setInterval's trailing arguments
fix(gc): root the callback and every staged argument of the timer fam…
fix(gc): root the ten unrooted runtime-side caches, and the scanner t…
proggeramlug commentedon Aug 2, 2026
The sweep, completed — every candidate taken to a verdict
Method for every row: read the declaration, the allocator, the dereference, and
grepthe identifier acrosscrates/perry-runtime/src/gc/and the holding module for a registration. Registry side: everygc_register_*root_scanner*call site enumerated, and every budgeted(FULL, STEP)pair diffed by reading both bodies rather than trusting the names.Fixes in #7239. Checker half in #7235. New issue from the sweep: #7236.
REAL — fixed in #7239
process/env_misc.rs:1076CACHED_ENVjs_object_alloc; the cell is the entire reference graph (process.envis ajs_process_env()call, not a field ofprocess)PERRY_GC_MOVING_LOOP_POLLS=1process/permission.rsCACHED_PERMISSIONruntime_write_barrier_root_nanboxis an incremental mark barrier, not a root registration — it does nothing for the sweep or the evacuation rewriteprocess/report.rsCACHED_REPORTobject/global_this/populate.rs:822ERROR_CONSTRUCTOR_PTRusizeof ajs_closure_allocclosure. The canonicalErrorclosure IS aglobalThisfield, so the structural trace keeps it alive and rewrites that reference; this duplicate is outside the graphtui/input.rs:38INPUT_HANDLERuseInputarrow, inline in idiomatic ink style ⇒ no other reference once the FFI returnstty.rs:58RESIZE_CALLBACKframe.rs:30FRAME_CALLBACKSRuntimeHandleScopeduring registration. Itsunsafe impl SendSAFETY comment asserted the closures "point to global compiled code / GC-rooted data" — the premise was falseobject/class_registry/construct.rs:5CURRENT_NEW_TARGETscan_implicit_this_roots_muttracks, held across a whole constructor bodyobject/field_get_set/accessors.rs:209ACCESSOR_RECEIVER_OVERRIDEgettrapobject/global_fetch.rs:15PENDING_FETCH_SIGNALAbortSignal, sole reference across the fetch call's own argument loweringperry-stdlib/worker_threads.rsMESSAGE_CALLBACK,CLOSE_CALLBACKscan_parent_port_event_roots_mutwalkedMESSAGE_EVENT_CALLBACKSand neither sibling. Three slots, samethread_local!block, same rawClosureHeader*REAL — verified, deliberately NOT fixed here (each with the reason)
promise/rejection.rs:68internally_handledHashSet<usize>of raw promise addresses consulted by identity;scan_unhandled_rejection_roots_mutvisitsunhandled/reported/pending_handledand not it. A stream'sclosedpromise loses its marking after a move (spuriousunhandledRejection); an address reused later inherits it (a real one swallowed)visit_metadata_usize_slot+ the remove/insert loopscan_map_iterator_array_roots_mutalready uses. Wants its own witness. Not a step-twin gap: it is registered non-budgetedCURRENT_NEW_TARGET/ACCESSOR_RECEIVER_OVERRIDEsave/restoreprev_thisdefect, in RustRuntimeHandleScopeplumbing at every save sitemodule_require.rs:240MODULE_PATH_REGISTRYmodule.exportsvalues; the codegen module global that also holds them is a registered root, so this is a stale-address (move) hazard, not a sweep oneRwLockwhile arenas are per-thread — a naive scanner would be unsound acrossperry/threadagentsjson/mod.rs:99SHAPE_CACHEtoJSON()REFUTED — the negative results
(FULL, STEP)pairs diffed field-by-field by reading both bodies and every helper they call:runtime_handles,temp_roots, promise (13 tables), timer (5 tables), class side-table (10 tables), symbol (5), tui hooks, tui state. No drift. fix(gc): root the callback and every staged argument of the timer family #7230'sIntervalTimer.argsfix landed in both twins (timer.rs:1667andtimer/gc_scan.rs:183-189, same commit). Premise re-confirmed first:gc/cycle.rs:375-390runs only the step fn whenbudgeted_scannerisSome, so a step-only omission really would be invisible togc()-driven tests.Two symmetric oddities, therefore not drift, noted and left:
CALLBACK_TIMERS.argsis visited twice on both paths (timer.rs:1638+:1642, mirrored atgc_scan.rs:109+:134) — a merge artifact fromf252989a53landing a second loop beside the pre-existing one; harmless but it doubles that table's incremental root budget. AndMOCK_TIMERSskips thecrate::agent::ownsfilter its three siblings apply, contradicting the [thread] Cross-heap unsoundness in perry/thread: global queue drains, timer queues, module globals — design decision needed #6185 rule stated attimer.rs:1613.buffer/header.rs's nine address registries are not root gaps.GC_TYPE_BUFFERandGC_TYPE_TYPED_ARRAYboth carrymovable: falseingc/types.rs, so the addresses cannot go stale, and the registries are identity sets rather than liveness references. What they do share with the pre-fixSYMBOL_POINTERSis address-reuse aliasing after death — that wants pruning (prune_dead_symbol_pointers's shape), not rooting. Same verdict forregex.rs'sREGEX_POINTERS/REGEX_SOURCE_TABLE,node_repl.rs'sRECOVERABLE_ERRORS,diagnostics.rs's fiveERROR_MESSAGE_*maps, andBUILTIN_MAP_SET_VALUE_BITS/BUILTIN_CLOSURE_LENGTH.static_plugins.rs:16STATIC_PLUGINSis unreachable.perry_register_static_pluginhas no caller anywhere in the workspace. Latent, not live — and by CLAUDE.md's kill-policy it should get a caller or be deleted rather than acquire a scanner nobody exercises.event_target.rs'sDOM_EXCEPTION_ERRORSalready has per-dead-error cleanup;child_process/v8_serde.rs'sid_tableruns under an explicit relocation-suppression window (documented at:1041).gc/roots/scanner_shims.rs:72-78overflow_fields_*and:91 shadow_stack_root_scannerare defined, exported and never registered — dead code carrying doc comments that assert they are needed. Unchanged here; flagged as the shape the kill-policy warns about.One thing the sweep found that is NOT this class
--unrooted-allocas --moving-onlywent 98 → 2 once #7235 split the predicate, and the 2 residuals are real:Type::Symbolis listed incollectors/pointer_locals.rs'sis_definitely_non_pointer_type, whilealloc_symbolis agc_mallocof a movable GC object whose own comment concedes fresh symbols are kept alive "not at all". ASymbol-typed local therefore gets no shadow slot. Filed as #7236; it is the only thing betweengc-root-dominanceand promotion to a required context (#7198).fix(gc): root the ten unrooted runtime-side caches, and the scanner t…
proggeramlug commentedon Aug 2, 2026
Partially fixed on
main=d4ac65f21— the three independently-verified caches are closed, the unverified tail is not. Re-verified in the sweep #7262. Leaving open for the tail.Closed by #7239
All three of the "I read the declaration, the allocator, the dereference, and grepped
gc/for a scanner" items are now rooted, plus seven more:CACHED_ENV—scan_process_env_cache_roots_mutregistered atgc/mod.rs:639, with your reasoning quoted at:636. Runtime-verified in this sweep:test_gap_gc_process_env_cache_rooting10/10 byte-exact on the allocation-point arm at 5 copying minors / 16 750 objects copied.ERROR_CONSTRUCTOR_PTR—scan_error_constructor_root_mut,global_this/populate.rs:873.INPUT_HANDLER— rooted.Also
CACHED_PERMISSION,CACHED_REPORT,RESIZE_CALLBACK,FRAME_CALLBACKS,CURRENT_NEW_TARGET,ACCESSOR_RECEIVER_OVERRIDE,PENDING_FETCH_SIGNAL, and theworker_threads.rsscanner gap (MESSAGE_CALLBACK/CLOSE_CALLBACK— three slots in onethread_local!block, one visited).Still open — no
gc/reference for any of these#7239 states why for three of them rather than leaving them silent, which is worth preserving:
internally_handledneeds a rekey rather than a root;MODULE_PATH_REGISTRYis process-global while arenas are per-thread, so a naive scanner would be unsound; and the save/restore idiom inCURRENT_NEW_TARGET/ACCESSOR_RECEIVER_OVERRIDEstill parks the displaced value in a bare Rust local even though the cells themselves are now rooted.SHAPE_CACHElooks like the sharpest remaining one on this list, for the reason given in the original sweep: its safety comment asserts "within one top-level stringify call no GC runs over the user object graph", andtoJSON()falsifies that.Refuted, and worth not re-deriving
#7239 diffed all 8 budgeted (FULL, STEP) scanner pairs field-by-field and found no drift.
buffer/header.rs's nine address registries are not root gaps —GC_TYPE_BUFFER/GC_TYPE_TYPED_ARRAYaremovable: falseand these are identity sets, not liveness references.static_plugins.rsis unreachable (perry_register_static_pluginhas no caller in the workspace).proggeramlug commentedon Aug 3, 2026
Consolidated plan reference —
docs/src/internals/rfc-rooting-by-construction.md("Where this sits in the one GC correctness plan", landed in #7292).This issue is layer 3: hand-written Rust in
perry-runtime— a*mut ObjectHeaderin a Rust local or a cache, held across an allocation.Unlike the codegen-side rooting issues, this class is NOT subsumed by the planned statepoint work. LLVM never sees this code, so stack maps cannot help. It is also the class the static checker is structurally blind to —
scripts/gc_root_dominance_check.pyreads emitted LLVM IR and read0 violationson both sides of #7249, a real bug. See the blind-spot list added in #7289.⇒ Work here is safe to start and will not be thrown away. The long-term direction is making
RuntimeHandleScopenon-optional rather than opt-in — today every bug in this class is a site where someone simply didn't reach for it.Verification note: a 10/10-deterministic failure is diagnostic of this class rather than a stale register; the instruments that see it are
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800on a dependency-scale workload. #7280 records 25 curated corpus files passing while 20 lines of stock zod fail.proggeramlug commentedon Aug 9, 2026
The tail is now enumerated mechanically rather than by hand — PR #7695.
scripts/gc_runtime_root_holders.py(runs fromlint, already a required context) enumerates everystatic/thread_local!inperry-runtime+perry-stdlibwhose type can hold a GC pointer, computes which of them a registered scanner actually reaches, and requires a written verdict for the rest inscripts/gc_runtime_root_holders.json. An unclassified holder fails; an entry that no longer matches also fails, so a fix must delete its own exemption.Coverage is computed, not name-matched: registered scanners are read from every
gc_register_*root_scanner*(...)call site, a call graph is walked from them, and a holder counts as covered when its identifier appears in a reachable function defined in the same file. The graph walk is what catches the accessor-indirection cases this issue's sweep flagged (cp_live_lock(),get_closure_props(),buffer_props()); the same-file rule is what stopsREGISTRY/SLOTS/ROOTS/STATES/CACHED— each of which names several different holders here — from certifying the wrong one.Current state: 81 holders, 47 reached by a registered scanner, 30 classified.
Against this issue's "still open" list:
SHAPE_CACHE— fixed, PR fix(gc): root the JSON.stringify shape template's three raw pointers (#7268) #7694 (rooted, plus the element and template-keys_arrrooting intry_emit_shape_element; the reentrancy save became a pushed frame so the saved cache stays inside the scanner's reach). Listed asopen_gapin the inventory only because gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231) #7695 branches frommain; whichever lands second deletes the entry, and the gate will say so.BUILTIN_MAP_SET_VALUE_BITS— reads as covered now.MODULE_PATH_REGISTRY,RECOVERABLE_ERRORS,GLOB_ITERATORS,internally_handled— read as covered by the computed walk. That is the gate's verdict, not a re-audit of the scanner bodies: it says a registered scanner mentions them, not that it visits every slot correctly. The docstring is explicit that "covered" is a population bound, not a semantic audit — the fix(gc): root the ten unrooted runtime-side caches, and the scanner that walked 1 of 3 sibling slots (#7231) #7239 three-slots-of-four shape is out of its reach.Two holders the sweep had not named, now tracked as
open_gap:json/mod.rsPARSE_KEY_RING— a hot-key mirror of the rootedPARSE_KEY_CACHEholding the same*const StringHeadervalues. A move rewrites one copy and not the other, so a ring hit hands out a pre-move address. Narrow: the keys arejs_string_from_bytes_longlived, i.e. old-gen, so only old-gen defrag can move them — not the copying minor.perf_hooks.rsPERF_ENTRY_KEYS_ARRAY— a nurserykeys_arrayaddress compared by identity atperf_hooks.rs:119and never rewritten. Stale ⇒ a silent slow path, or a match against a newly-allocated array recycled into the address. The neighbouringPERF_ENTRIESis covered byscan_perf_entries_roots_mut; this one slot is not.One
unverifiedentry is left deliberately, named and dated rather than silent:diagnostics.rsDIAG_CHANNEL_BY_KEY, whoseDiagChannelKey::Symbolvariant carries a symbol address — whether that can be a gc_malloc'd fresh symbol rather than aBox::leak'd registered one has not been established.Three bugs found while building it, all in the checker, all of the "green because it matched nothing" shape — recording them because this is the failure mode the repo keeps paying for:
scan_raw_json_key_root_mutand reportedRAW_JSON_KEY— which that scanner visits three lines below its own declaration — as uncovered;gc_register_mutable_root_scanner_named("stdlib:worker_threads:workers", scan_worker_roots_mut)registered nothing and sixworker_threadsholders read as uncovered;--self-testplants a covered holder, one reachable only through an accessor, one uncovered per rule, and a same-named decoy in another file; then asserts the verdict machinery itself can go red. Live sabotage: planting an unrootedCell<*mut ObjectHeader>intoregex.rsfails the real scan.Also from this cluster, in case they help scope what is left: #7274 is fixed (PR #7693), #7246 is fixed (PR #7697), and #7247 is closed — re-verified green 10/10 on
mainwith copying minors confirmed live.gc: two ways the holder gate could not fail (#7231, CodeRabbit review)
gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231)
gc: two ways the holder gate could not fail (#7231, CodeRabbit review)
gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231) (
proggeramlug commentedon Aug 9, 2026
Closed by #7695 (v0.5.1402).
scripts/gc_runtime_root_holders.py+scripts/gc_runtime_root_holders.jsonnow enumerate the population this issue was about, and — the part that makes it a gate rather than a list — coverage is computed, not declared: registered scanners → call-graph walk → holder named in a reachable function in the same file.Current state on main: 80 holder declarations scanned, 47 reached by a registered scanner, 29 classified in the inventory, 122 registered scanners. Wired into
lint.It is shrink-only and proved it on its first real run: the
SHAPE_CACHEentry carried its own expiry note ("Fixed in PR #7694 — DELETE THIS ENTRY when that lands, the gate will tell you"), #7694 landed, and both the gate and its--self-testwent red on exactly that entry until it was deleted (30 → 29).Sabotage-verified independently at merge: a planted
static AUDIT_PLANT_HOLDER: Mutex<Option<*mut ArrayHeader>>injson/mod.rsexits 1 naming the holder, its line, and the rule that caught it.The two previously-untracked
open_gaps it surfaced —PARSE_KEY_RING(an unrewritten mirror of the rootedPARSE_KEY_CACHE) andPERF_ENTRY_KEYS_ARRAY— remain tracked in the inventory as the follow-on work.Also worth recording: five separate ways the checker could report coverage it did not have were found and fixed before it shipped (unstripped string literals breaking brace-counting; a registration regex that read only the first argument so
..._named("…", scanner)registered nothing; rule B keyed on file rather than function, reporting 544 holders; bare-name module collisions able to certify the wrong module's holder; and inventory entries accepted with no verdict). A coverage checker that over-reports coverage is worse than none.