Skip to content

perry-ui-* callback tables hold NaN-boxed JS closures with no GC scanner #8701

Description

@proggeramlug

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.

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:

  1. 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.
  2. 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.

Found while auditing #8699; not caused by it.

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