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}