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
Every perry-ui-* crate caches JS callbacks as raw NaN-boxed f64 in thread-local side tables, and no UI crate registers a gc_register_mutable_root_scanner. The ledger currently carries 466 perry-ui-* frontier entries of this shape.
A stored callback is a heap pointer held across arbitrary time, including across collections. Two independent failure modes:
Not marked — the collector can reclaim a listener that is still reachable only from the UI table, so a later invoke_listener calls freed memory.
Not rewritten — after an evacuating minor the cached pointer still points into from-space. Per docs/src/internals/gc-rooting-invariant.md this surfaces cycles later as TypeError: value is not a function, far from the cause.
This is the class CLAUDE.md describes as invisible to the static checker: scripts/gc_root_dominance_check.py reads emitted LLVM IR, so a side table holding a heap pointer is structurally out of its reach. The runtime instruments (PERRY_GC_PROTECT_FROMSPACE, PERRY_GC_SCHEDULE_SEED) are the only detectors.
Note the reproducibility tell from the same doc: an unrooted table goes bad at collection #0 and stays bad, unlike an unrooted register which is intermittent.
Suggested shape
Register one scanner per UI crate covering its callback tables, in the manner of gc/roots.rs's existing gc_register_mutable_root_scanner users, then move the corresponding frontier entries to holders with a real verdict. The frontier entries are the work list.
Every
perry-ui-*crate caches JS callbacks as raw NaN-boxedf64in thread-local side tables, and no UI crate registers agc_register_mutable_root_scanner. The ledger currently carries 466perry-ui-*frontier entries of this shape.Representative instances (all the same pattern):
crates/perry-ui-ios/src/adaptive_layout.rs:LISTENERS: RefCell<HashMap<i64, f64>>(added in feat(ios): add iOS 27 platform APIs #8699)crates/perry-ui-ios/src/network.rs:LISTENERS: RefCell<HashMap<i64, f64>>crates/perry-ui-macos/src/network.rs:LISTENERS: RefCell<HashMap<i64, f64>>crates/perry-ui-macos/src/state.rs:ON_CHANGE_CALLBACKS: RefCell<HashMap<i64, Vec<f64>>>crates/perry-ui-ios/src/state.rs:ON_CHANGE_CALLBACKS: RefCell<HashMap<i64, Vec<f64>>>crates/perry-ui-macos/src/widgets/mod.rs:HOVER_CALLBACKS: RefCell<HashMap<i64, f64>>Why this matters
A stored callback is a heap pointer held across arbitrary time, including across collections. Two independent failure modes:
invoke_listenercalls freed memory.docs/src/internals/gc-rooting-invariant.mdthis surfaces cycles later asTypeError: value is not a function, far from the cause.This is the class CLAUDE.md describes as invisible to the static checker:
scripts/gc_root_dominance_check.pyreads emitted LLVM IR, so a side table holding a heap pointer is structurally out of its reach. The runtime instruments (PERRY_GC_PROTECT_FROMSPACE,PERRY_GC_SCHEDULE_SEED) are the only detectors.Note the reproducibility tell from the same doc: an unrooted table goes bad at collection #0 and stays bad, unlike an unrooted register which is intermittent.
Suggested shape
Register one scanner per UI crate covering its callback tables, in the manner of
gc/roots.rs's existinggc_register_mutable_root_scannerusers, then move the corresponding frontier entries toholderswith a real verdict. The frontier entries are the work list.Found while auditing #8699; not caused by it.