Skip to main content

frust_native_widgets/
registry.rs

1//! The retained-handle registry: tracks the one native view
2//! handle a `platform_view` slot currently owns, keyed by
3//! [`SlotId`], so a dispose reaching this crate can be resolved against
4//! the exact handle it targets.
5//!
6//! ## Idle-deferred dispose
7//!
8//! The differ's `Dispose` command for a slot can arrive many frames after
9//! that slot's view was created — or even after a *replacement* `Create`
10//! already landed for the same (re-used) slot id, since Android's real
11//! dispose export (`nativeDisposeControl(view)`) hands back the raw view
12//! object, not a slot id. A
13//! disposal must therefore be resolved **by identity** — the specific
14//! handle a Dispose command actually names via [`Registry::remove_matching`]
15//! — never by assuming "whichever handle currently occupies the slot" is
16//! the one being torn down; the latter would delete a *live, already-
17//! replaced-in* control out from under the app. [`Registry::remove`] (by
18//! `slot_id` alone) is the simpler counterpart for a caller that already
19//! knows the exact slot — a future widget-teardown path, not the raw
20//! dispose export.
21//!
22//! ## Design: generic core, cfg-gated handle types
23//!
24//! [`Registry`] itself carries no `#[cfg]` — its insert/remove/live-count
25//! contract is exercised on any host by this module's own unit tests
26//! (below), using a plain counting fixture, independent of JNI/ObjC. The
27//! real per-platform handle types live in the [`android`]/[`apple`]/
28//! [`appkit`] submodules, each compiled only under its own target:
29//!
30//! - [`android::AndroidHandle`] is the RAII owner of every
31//!   `Global<JObject<'static>>` a control's create step allocated — the
32//!   view itself, plus any secondary refs (a listener object, a child
33//!   hierarchy) it retains. Removing (or replacing, or dropping the whole
34//!   registry) a [`Registry`] entry pair-deletes them all together — the
35//!   paired-delete discipline this design requires: ART aborts the
36//!   process at 51,200 live global refs process-wide, so a leaked ref here
37//!   is a crash, not a slow leak.
38//! - [`apple::AppleHandle`] is the ARC-managed `Retained<UIView>`
39//!   counterpart — memory-safe by construction (no paired-delete
40//!   discipline needed; `Retained`'s own `Drop` releases the object) — kept
41//!   behind the same slot-keyed shape so a surface-recreate replay's
42//!   replace-and-release-old contract behaves identically on every platform.
43//! - [`appkit::AppKitHandle`] is the macOS twin of [`apple::AppleHandle`]:
44//!   the same ARC-managed shape over a `Retained<NSView>` (AppKit instead of
45//!   UIKit), behind the same main-thread typestate guard.
46
47use std::collections::HashMap;
48
49/// A `platform_view` slot id (`frust_shell_common::platform_view`'s
50/// differ-assigned identity, stamped from `next_slot_id()`'s process-wide
51/// counter — `docs/ARCHITECTURE.md`'s `frust-core` row) — the registry's
52/// key.
53pub type SlotId = u64;
54
55/// A retained-handle registry keyed by [`SlotId`], generic over the
56/// concrete platform handle type `H` so its core contract is host-testable
57/// with no FFI dependency (see the module doc, and this module's `tests`).
58pub struct Registry<H> {
59    live: HashMap<SlotId, H>,
60}
61
62impl<H> Registry<H> {
63    /// An empty registry.
64    pub fn new() -> Self {
65        Self {
66            live: HashMap::new(),
67        }
68    }
69
70    /// Record a freshly created `handle` for `slot_id`, **replacing**
71    /// (never panicking on) any handle already live under the same id —
72    /// the surface-recreate-replay / rapid-recreate race this registry
73    /// must handle. Returns the replaced handle, if any,
74    /// so a caller can log the unexpected-replace path; dropping it
75    /// releases its resources exactly like an explicit [`Self::remove`]
76    /// would (the registry "treats attach-for-live-slot-id as replace-
77    /// and-release-old").
78    pub fn insert(&mut self, slot_id: SlotId, handle: H) -> Option<H> {
79        self.live.insert(slot_id, handle)
80    }
81
82    /// Release the handle for `slot_id` directly, by the slot id itself —
83    /// for a caller that already knows exactly which slot it means (a
84    /// future widget-teardown path), as opposed
85    /// to [`Self::remove_matching`]'s identity-based lookup for the real
86    /// platform dispose export, which never receives a slot id at all. A
87    /// dispose for a slot id that was already replaced or already removed
88    /// is a silent no-op (late/duplicate dispose, both expected once
89    /// dispose can arrive idle-deferred — see the module doc),
90    /// never an error.
91    pub fn remove(&mut self, slot_id: SlotId) -> Option<H> {
92        self.live.remove(&slot_id)
93    }
94
95    /// Find and remove the entry whose handle satisfies `is_match`, **by
96    /// identity** — the real disposal contract every platform backend
97    /// uses (Android: `Env::is_same_object` against the `JObject` the
98    /// `nativeDisposeControl(view)` export receives, which carries no slot
99    /// id at all). Never assumes "whichever
100    /// slot was created most recently" is the one being disposed: a match
101    /// against a handle that was already replaced (the module doc's
102    /// idle-deferred-dispose case) or already removed finds nothing and is
103    /// a silent no-op, leaving whatever handle currently occupies that
104    /// slot (if any) untouched.
105    pub fn remove_matching(&mut self, mut is_match: impl FnMut(&H) -> bool) -> Option<(SlotId, H)> {
106        let slot_id = self
107            .live
108            .iter()
109            .find(|(_, handle)| is_match(handle))
110            .map(|(slot_id, _)| *slot_id)?;
111        self.live.remove(&slot_id).map(|handle| (slot_id, handle))
112    }
113
114    /// Whether `slot_id` currently has a live handle.
115    pub fn contains(&self, slot_id: SlotId) -> bool {
116        self.live.contains_key(&slot_id)
117    }
118
119    /// The handle currently live under `slot_id`, if any — what a caller
120    /// holding a slot id (an `UpdateParams` command, a listener callback
121    /// carrying its own slot id) resolves against, as opposed to
122    /// [`Self::remove_matching`]'s identity lookup for a dispose that carries
123    /// none.
124    pub fn get(&self, slot_id: SlotId) -> Option<&H> {
125        self.live.get(&slot_id)
126    }
127
128    /// [`Self::get`]'s mutable counterpart, for a caller that mutates the
129    /// handle in place (the runtime's per-instance props/state).
130    pub fn get_mut(&mut self, slot_id: SlotId) -> Option<&mut H> {
131        self.live.get_mut(&slot_id)
132    }
133
134    /// The number of live handles — the leak bar every create/dispose
135    /// cycle must return to `0`.
136    pub fn live_count(&self) -> usize {
137        self.live.len()
138    }
139}
140
141impl<H> Default for Registry<H> {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147#[cfg(target_os = "android")]
148pub mod android {
149    //! Android's [`Registry`](super::Registry) handle: a RAII owner of
150    //! every `Global<JObject<'static>>` one control retains.
151
152    use jni::objects::JObject;
153    use jni::refs::Global;
154
155    /// Every global ref a single control's `create` step allocated: the
156    /// view itself, plus any secondary refs it retains (a listener object,
157    /// a stress-style child hierarchy — an earlier prototype `Registry` had
158    /// the same shape, one `HashMap` per kind of ref). Dropping (or removing
159    /// from a [`Registry`](super::Registry)) this pairs-deletes all of
160    /// them together — the paired-delete discipline this design
161    /// requires; ART aborts the process at 51,200 live global refs
162    /// process-wide, so a leaked ref here is a crash, not a slow leak.
163    pub struct AndroidHandle {
164        /// The control's own retained view.
165        pub view: Global<JObject<'static>>,
166        /// Any other refs the control's `create` step retained (a
167        /// listener, child views) — dropped alongside `view`.
168        pub extra: Vec<Global<JObject<'static>>>,
169    }
170
171    impl AndroidHandle {
172        /// A handle retaining only the view itself.
173        pub fn new(view: Global<JObject<'static>>) -> Self {
174            Self {
175                view,
176                extra: Vec::new(),
177            }
178        }
179
180        /// A handle retaining the view plus every ref in `extra`.
181        pub fn with_extra(
182            view: Global<JObject<'static>>,
183            extra: Vec<Global<JObject<'static>>>,
184        ) -> Self {
185            Self { view, extra }
186        }
187    }
188}
189
190// iOS only — not `target_vendor = "apple"`: this plugin's iOS surface is
191// UIKit, which doesn't exist on macOS (the macOS arm is AppKit — `appkit`
192// below) — see `Cargo.toml`'s comment on the same gate for the linker
193// failure `target_vendor = "apple"` caused.
194#[cfg(target_os = "ios")]
195pub mod apple {
196    //! iOS's [`Registry`](super::Registry) handle: an ARC-managed
197    //! `Retained<UIView>`.
198
199    use objc2::MainThreadMarker;
200    use objc2::rc::Retained;
201    use objc2_ui_kit::UIView;
202
203    /// A retained control view. Memory-safe by construction — no
204    /// paired-delete discipline needed, `Retained`'s own `Drop` releases
205    /// the object — but UIKit types are main-thread-only, so
206    /// [`AppleHandle::view`] only hands the `Retained<UIView>` back
207    /// alongside proof of a live [`MainThreadMarker`], never off it (every
208    /// platform-view create/update/dispose command is drained on the main
209    /// thread already, so this is a documentation-and-typestate guard, not
210    /// a new runtime cost).
211    pub struct AppleHandle {
212        view: Retained<UIView>,
213    }
214
215    impl AppleHandle {
216        /// Retain `view`, requiring proof the caller is on the main
217        /// thread.
218        pub fn new(view: Retained<UIView>, _mtm: MainThreadMarker) -> Self {
219            Self { view }
220        }
221
222        /// The retained view, requiring the same main-thread proof.
223        pub fn view(&self, _mtm: MainThreadMarker) -> &Retained<UIView> {
224            &self.view
225        }
226    }
227}
228
229// macOS only — the AppKit arm's handle, mirroring `apple` above exactly with
230// `NSView` in place of `UIView` (the two arms share no UI framework, only the
231// ARC ownership model).
232#[cfg(target_os = "macos")]
233pub mod appkit {
234    //! macOS's [`Registry`](super::Registry) handle: an ARC-managed
235    //! `Retained<NSView>`.
236
237    use objc2::MainThreadMarker;
238    use objc2::rc::Retained;
239    use objc2_app_kit::NSView;
240
241    /// A retained control view. Memory-safe by construction — no
242    /// paired-delete discipline needed, `Retained`'s own `Drop` releases
243    /// the object — but AppKit views are main-thread-only, so
244    /// [`AppKitHandle::view`] only hands the `Retained<NSView>` back
245    /// alongside proof of a live [`MainThreadMarker`], never off it (every
246    /// desktop platform-view create/update/dispose call is made on the main
247    /// thread already — `frust_plugin::desktop`'s contract — so this is a
248    /// documentation-and-typestate guard, not a new runtime cost).
249    pub struct AppKitHandle {
250        view: Retained<NSView>,
251    }
252
253    impl AppKitHandle {
254        /// Retain `view`, requiring proof the caller is on the main
255        /// thread.
256        pub fn new(view: Retained<NSView>, _mtm: MainThreadMarker) -> Self {
257            Self { view }
258        }
259
260        /// The retained view, requiring the same main-thread proof.
261        pub fn view(&self, _mtm: MainThreadMarker) -> &Retained<NSView> {
262            &self.view
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::cell::RefCell;
271    use std::rc::Rc;
272
273    /// A host-testable stand-in for a platform handle: increments a shared
274    /// counter on construction, decrements on drop — the leak bar's
275    /// substitute for ART's global-ref count / ARC's retain count. Carries
276    /// an `identity` so tests can simulate the real Android dispose
277    /// export's identity-based lookup (`remove_matching`) rather than a
278    /// slot-id-keyed one.
279    struct CountingHandle {
280        identity: u32,
281        count: Rc<RefCell<i32>>,
282    }
283
284    impl CountingHandle {
285        fn new(identity: u32, count: &Rc<RefCell<i32>>) -> Self {
286            *count.borrow_mut() += 1;
287            Self {
288                identity,
289                count: Rc::clone(count),
290            }
291        }
292    }
293
294    impl Drop for CountingHandle {
295        fn drop(&mut self) {
296            *self.count.borrow_mut() -= 1;
297        }
298    }
299
300    #[test]
301    fn create_dispose_cycles_return_to_zero_live_refs() {
302        let count = Rc::new(RefCell::new(0));
303        let mut registry: Registry<CountingHandle> = Registry::new();
304
305        for slot in 0..100u64 {
306            registry.insert(slot, CountingHandle::new(slot as u32, &count));
307            assert_eq!(registry.live_count(), 1);
308            let removed = registry.remove(slot);
309            assert!(removed.is_some(), "dispose finds the handle it created");
310            drop(removed);
311            assert_eq!(registry.live_count(), 0);
312        }
313
314        assert_eq!(
315            *count.borrow(),
316            0,
317            "every one of the 100 creates was paired with a dispose"
318        );
319    }
320
321    #[test]
322    fn insert_replaces_and_releases_the_old_handle_for_a_reused_slot() {
323        // The surface-recreate-replay / rapid-recreate race this registry
324        // must handle: a fresh Create for an
325        // already-live slot id must replace in place, not panic or leak.
326        let count = Rc::new(RefCell::new(0));
327        let mut registry: Registry<CountingHandle> = Registry::new();
328        let slot = 7u64;
329
330        registry.insert(slot, CountingHandle::new(100, &count));
331        assert_eq!(*count.borrow(), 1);
332
333        let replaced = registry.insert(slot, CountingHandle::new(200, &count));
334        assert!(replaced.is_some());
335        drop(replaced);
336        assert_eq!(
337            *count.borrow(),
338            1,
339            "replacing drops the old handle immediately, leaving only the new one"
340        );
341        assert_eq!(registry.live_count(), 1);
342    }
343
344    /// The idle-deferred-dispose case (see the module doc):
345    /// a slot's original handle is replaced by a fresh Create
346    /// before that handle's own late Dispose is ever drained. The late
347    /// Dispose carries the OLD handle's identity — `remove_matching` must
348    /// find nothing (the old handle is already gone) and must NOT touch
349    /// the NEW handle now occupying the slot.
350    #[test]
351    fn a_late_dispose_for_a_replaced_handle_is_a_no_op_via_identity_match() {
352        let count = Rc::new(RefCell::new(0));
353        let mut registry: Registry<CountingHandle> = Registry::new();
354        let slot = 42u64;
355
356        const OLD_IDENTITY: u32 = 1;
357        const NEW_IDENTITY: u32 = 2;
358
359        registry.insert(slot, CountingHandle::new(OLD_IDENTITY, &count));
360
361        // A fresh Create replaces the slot's handle (surface-recreate
362        // replay, or a rapid recreate race) before the old handle's
363        // Dispose is ever drained — dropping the old handle immediately.
364        let replaced = registry.insert(slot, CountingHandle::new(NEW_IDENTITY, &count));
365        drop(replaced);
366        assert_eq!(*count.borrow(), 1);
367
368        // The late Dispose finally arrives, naming the OLD (already-gone)
369        // handle's identity.
370        let found = registry.remove_matching(|h| h.identity == OLD_IDENTITY);
371        assert!(
372            found.is_none(),
373            "a late dispose for an already-replaced handle is a no-op"
374        );
375        assert_eq!(*count.borrow(), 1, "the current (new) handle is untouched");
376        assert!(registry.contains(slot));
377
378        // The eventual, correctly-targeted dispose for the CURRENT handle
379        // still works.
380        let found = registry.remove_matching(|h| h.identity == NEW_IDENTITY);
381        assert!(found.is_some());
382        drop(found); // the removed handle is only released once dropped
383        assert_eq!(*count.borrow(), 0);
384        assert!(!registry.contains(slot));
385    }
386
387    #[test]
388    fn duplicate_dispose_is_a_silent_no_op() {
389        let count = Rc::new(RefCell::new(0));
390        let mut registry: Registry<CountingHandle> = Registry::new();
391        let slot = 3u64;
392
393        registry.insert(slot, CountingHandle::new(9, &count));
394        assert!(registry.remove(slot).is_some());
395        assert_eq!(*count.borrow(), 0);
396
397        // A second dispose for the same (now-gone) slot id must not panic
398        // or double-decrement — just report nothing to release.
399        assert!(registry.remove(slot).is_none());
400        assert_eq!(*count.borrow(), 0);
401    }
402
403    #[test]
404    fn dispose_for_an_unknown_slot_is_a_silent_no_op() {
405        let mut registry: Registry<CountingHandle> = Registry::new();
406        assert!(registry.remove(999).is_none());
407        assert_eq!(registry.live_count(), 0);
408    }
409
410    #[test]
411    fn remove_matching_against_an_empty_registry_finds_nothing() {
412        let mut registry: Registry<CountingHandle> = Registry::new();
413        assert!(registry.remove_matching(|_| true).is_none());
414    }
415}