GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: UiContext::unregister_widget, so rebuilt widget Vecs can drop their old ids
`WidgetId`s are globally monotonic (`NEXT_WIDGET_ID.fetch_add`) and never reused, so
an app that rebuilds a `Vec` of widgets gets NEW ids for the replacements —
re-registering does not overwrite the outgoing entries, and there was no way to
remove them. The registry kept raw pointers into the freed Vec.
That is a use-after-free, not a leak: several paths walk the whole registry and
dereference. `close_popovers_missed_by_press` runs on every left press from the
engine (`backend/window_runner.rs`), and `is_coordinate_covered` falls back to a
full scan — both do `ptr.as_ref()` (which only guards null) and then call
`visible()` / `popover_rect()` on the result.
`WidgetTree::remove` already existed with no production caller; this exposes it
through `UiContext` alongside the tick-receiver and coverage-cache bookkeeping that
`register_widget` does, so the two are symmetric. Apps that call `clear_hierarchy`
every rebuild do not need it — the wipe already drops the outgoing ids, which is why
only the four crates that never call it are affected.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/context.rs | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/src/context.rs b/src/context.rs
index ca9319d..bcde4e5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -557,6 +557,22 @@ impl UiContext {
}
}
+ /// Drop `id`'s registration. **Apps that rebuild a `Vec` of widgets must call this for the
+ /// outgoing ids**, because `WidgetId`s are globally monotonic (`NEXT_WIDGET_ID.fetch_add`)
+ /// and are never reused: the replacements register under *new* ids, so re-registering does
+ /// not overwrite the old entries. Those keep raw pointers into the freed Vec, and several
+ /// paths walk the whole registry and dereference — `close_popovers_missed_by_press` runs on
+ /// every left press (`backend/window_runner.rs`), and `is_coordinate_covered` falls back to a
+ /// full scan — so a stale entry is a use-after-free, not just a leak.
+ ///
+ /// Apps that call [`clear_hierarchy`](Self::clear_hierarchy) every rebuild do not need this;
+ /// the wipe already drops the outgoing ids.
+ pub fn unregister_widget(&mut self, id: WidgetId) {
+ self.tree.remove(id);
+ self.unregister_tick_receiver(id);
+ self.invalidate_coverage_cache();
+ }
+
pub fn link_ids(&mut self, parent: WidgetId, child: WidgetId) {
self.tree.link(parent, child);
}