git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitbfc26659618e2204635b4a7440f4b8d594c95bbe
parent039fe4c013
authorLucas Galante <[email protected]>
date2026-09-19 00:53
A bundled glyph belongs to a renderer, not to the process

`upload_icon` cached `(name, px) -> image id` in a process-lifetime
static. But an image id names an entry in one renderer's image table,
and a renderer does not outlive its session: window_runner repairs a
lost Wayland transport by opening a new session around the same
`Application`, which rebuilds the VkRenderer and with it the image
table. A draw for an id that table does not hold is skipped rather than
reported — the contract the doc comment above `run` already states.

So after a reconnect every bundled cce-icons glyph in the process drew
nothing at all, silently. cce-status-interface hit this first and fixed
it app-side (73ab926, its own tinted-glyph cache), but the toolkit's
cache had the same bug for everyone else. Reproduced in a shadow session
with CCE_UI_FAULT_RECONNECT: cce-gallery's copy-icon button is a glyph
before the injected drop and a bare plate after it, with every other
widget in the window intact.

Two halves, because there are two places an id gets held.

The cache: `vk::renderer_epoch()` is a new counter of image tables
built, and `upload_icon` keys its cache on it. Epoch 0 spans the first
renderer AND everything queued before it existed, so the uploads an
`Application::new` makes are not thrown away and re-done before the
first frame. The id cache itself has to stay — this is called from
widget rebuilds, and uploading per call would exhaust the 256-image
budget in seconds — which is why the fix is invalidation rather than
`icon.rs::upload_themed`'s cache-the-decode-only shape.

The widgets: a `Button` built with `new_icon` captured the id once, so
clearing the cache underneath it changed nothing — it outlives the
renderer too. It now holds the glyph NAME (`icon_name`,
`with_icon_name`) and re-resolves through `upload_icon` on every read,
which after the above is a hash lookup that yields a live id on the
first frame following a rebuild. `Ramp`'s delete button moves to the
same path. `with_icon(id, w, h)` is unchanged and still means "the app
owns this upload", which is the right contract for app-rendered
content — and is now documented as carrying the app's duty to replace
it from `renderer_init`.

 src/lib.rs                 | 33 +++++++++++++++++++--
 src/vk/image.rs            | 28 ++++++++++++++++++
 src/vk/mod.rs              |  3 +-
 src/widget/input/button.rs | 71 +++++++++++++++++++++++++++++++++++++++++-----
 src/widget/input/ramp.rs   | 12 ++++----
 5 files changed, 130 insertions(+), 17 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 058de85..1f316ea 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -72,14 +72,43 @@ pub fn icons_dir() -> String {
 /// `(image id, pixel w, pixel h)` for `PaintCtx::image` / `ImageView`; cached
 /// per `(name, px)` so widget rebuilds reuse the one upload. `None` when the
 /// icon is missing or unparsable (callers keep a text fallback).
+///
+/// **The cache is per renderer, not per process.** An image id names an entry
+/// in one renderer's image table, and a renderer does not outlive its
+/// session: `window_runner` repairs a lost Wayland transport by opening a new
+/// session around the same `Application`, which rebuilds the renderer and its
+/// image table. A draw for an id that table does not hold is skipped rather
+/// than reported, so a cache that survived the rebuild left every bundled
+/// glyph in the process silently undrawn — a status bar that reconnected kept
+/// its numbers and lost its icons, and the same went for every
+/// [`Button::new_icon`] face, treelist chevron and ramp delete button in the
+/// DE. Keying the cache on [`vk::renderer_epoch`] makes the first lookup after
+/// a rebuild a miss, which re-rasterizes and re-uploads into the live
+/// renderer.
+///
+/// The id cache itself has to stay: this is called from widget rebuilds, so
+/// uploading per call would burn through the renderer's 256-image budget in
+/// seconds. Caching only the decode — what [`icon::upload_themed`] does — is
+/// right for a caller that uploads rarely and owns what it gets back, and
+/// wrong here.
+///
+/// [`Button::new_icon`]: widget::Button::new_icon
 pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
     use std::collections::HashMap;
     use std::sync::Mutex;
-    static CACHE: Mutex<Option<HashMap<(String, u32), Option<(u32, u32, u32)>>>> =
+    /// The cached ids, and the renderer epoch they were uploaded to.
+    static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
         Mutex::new(None);
+    let epoch = crate::vk::renderer_epoch();
     let key = (name.to_string(), px);
     let mut guard = CACHE.lock().unwrap();
-    let cache = guard.get_or_insert_with(HashMap::new);
+    let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
+    if *cached_epoch != epoch {
+        // The renderer these ids named is gone, and its image table went with
+        // it — so this is a forget, not a teardown; there is nothing to free.
+        cache.clear();
+        *cached_epoch = epoch;
+    }
     if let Some(hit) = cache.get(&key) {
         return *hit;
     }
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 97a2463..0fdf565 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -80,6 +80,9 @@ enum Pending {
 
 static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
 static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+/// How many image tables have been built in this process. See
+/// [`renderer_epoch`].
+static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
 /// Pixel buffers the renderer has finished with, waiting to be refilled.
 /// Bounded: a streaming caller needs one or two in flight, and holding more
 /// frame-sized buffers than that is just memory.
@@ -146,6 +149,27 @@ fn retire_buffer(mut buf: Vec<u8>) {
     }
 }
 
+/// Which renderer's image table the ids handed out right now belong to.
+///
+/// `0` until the first renderer exists, and again for the whole life of that
+/// first renderer: uploads queued before it was built (from `Application::new`
+/// and from anything the app did on the way to its first frame) are drained
+/// into it, so they are that epoch's images, not a previous one's.
+/// Every later renderer — `window_runner` builds one per session, and a lost
+/// Wayland transport starts a new session around the same `Application` —
+/// counts as the next epoch.
+///
+/// This is what lets a long-lived cache of image ids notice that its ids have
+/// stopped naming anything. It is the cheap half of the contract; the other
+/// half is the app's, because only the app knows how to produce the pixels
+/// again (see [`Application::renderer_init`], and `upload_icon` for the
+/// toolkit's own use of this).
+///
+/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
+pub fn renderer_epoch() -> u32 {
+    STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
+}
+
 /// Queue an image's GPU resources for destruction.
 pub fn free_image(id: u32) {
     PENDING.lock().unwrap().push(Pending::Free { id });
@@ -232,6 +256,10 @@ impl ImageStage {
         render_pass: vk::RenderPass,
         frames_in_flight: usize,
     ) -> Self {
+        // One image table per renderer, so this is the renderer count — see
+        // `renderer_epoch`, which is what tells a cache of ids that its
+        // renderer is gone.
+        STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
         unsafe {
             let bindings = [
                 vk::DescriptorSetLayoutBinding::default()
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index ddefa51..c5dc437 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -40,7 +40,8 @@ mod text;
 
 pub use core::VkCore;
 pub use image::{
-    free_image, recycle_buffer, update_pixels, upload_pixels, upload_rgba, ImageQuad, PixelFormat,
+    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba, ImageQuad,
+    PixelFormat,
 };
 pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
 pub use rt::{RtCamera, RtMaterial, RtOffscreen, RtTriangle};
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index ce6238e..d71a5ec 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -40,7 +40,33 @@ pub struct Button {
     label: Option<String>,
     /// Icon face: an uploaded texture `(image id, pixel w, pixel h)` drawn
     /// centered in place of the label (see [`crate::upload_icon`]).
+    ///
+    /// Set directly by [`Adapted::with_icon`] for an app that owns its own
+    /// upload — and then it is the APP's job to replace it when the renderer
+    /// is rebuilt, because an image id names an entry in one renderer's image
+    /// table and nothing here can produce those pixels again.
+    /// [`icon_name`] is the way out of that for a bundled glyph.
+    ///
+    /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
+    /// [`icon_name`]: Button::icon_name
     icon: Option<(u32, f32, f32)>,
+    /// A bundled cce-icons glyph name, when the face came from one
+    /// ([`Adapted::with_icon_name`], [`Button::new_icon`]). Takes precedence
+    /// over [`icon`]: the id is then re-resolved through
+    /// [`crate::upload_icon`] on every read rather than captured once.
+    ///
+    /// That indirection is the whole point. An id captured at construction
+    /// dies with its renderer — `window_runner` builds a new one around the
+    /// same `Application` when it repairs a lost Wayland transport, and a
+    /// draw for an id the new image table does not hold is skipped rather
+    /// than reported, so every icon button in the process went blank and
+    /// stayed blank. `upload_icon`'s cache is keyed on the renderer epoch, so
+    /// re-reading through it costs a hash lookup per frame and yields a live
+    /// id on the first frame after a rebuild.
+    ///
+    /// [`Adapted::with_icon_name`]: Adapted::<Button>::with_icon_name
+    /// [`icon`]: Button::icon
+    icon_name: Option<String>,
     /// Opacity of the icon face — the ONLY state lever an icon has, since
     /// `PaintCtx::image` carries no color and images ignore vertex color. A
     /// disabled icon button dims instead of graying its glyph.
@@ -90,6 +116,7 @@ impl Button {
             justify: Justification::Center,
             label: None,
             icon: None,
+            icon_name: None,
             icon_alpha: 1.0,
             hovered: false,
             focused: false,
@@ -131,16 +158,29 @@ impl Button {
     /// rather than on a plate. `fallback` is the label drawn instead when the
     /// icon set is missing on this machine.
     pub fn new_icon(name: &str, fallback: &str, x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
-        let b = Button::adapted(ButtonKind::CopyIcon, x, y, w, h);
-        match crate::upload_icon(name, 32) {
-            Some((id, iw, ih)) => b.with_icon(id, iw as f32, ih as f32),
-            None => b.with_label(fallback),
-        }
+        Button::adapted(ButtonKind::CopyIcon, x, y, w, h).with_icon_name(name, fallback)
     }
 
     /// Whether an icon face is set (hosts size icon buttons square).
     pub fn has_icon(&self) -> bool {
-        self.icon.is_some()
+        self.icon_face().is_some()
+    }
+
+    /// The face to draw: the live id for a named bundled glyph, else whatever
+    /// the app handed to [`Adapted::with_icon`].
+    ///
+    /// Named glyphs re-resolve here instead of being captured, so the face
+    /// survives a renderer rebuild — see the [`icon_name`] field.
+    ///
+    /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
+    /// [`icon_name`]: Button::icon_name
+    fn icon_face(&self) -> Option<(u32, f32, f32)> {
+        match &self.icon_name {
+            Some(name) => {
+                crate::upload_icon(name, 32).map(|(id, w, h)| (id, w as f32, h as f32))
+            }
+            None => self.icon,
+        }
     }
 
     /// Where the icon face draws inside `rect`: centered, inset one 4px margin
@@ -152,7 +192,7 @@ impl Button {
     /// is neither a quad nor a label. Keeping the geometry here means the icon
     /// lands in the same place on both paths.
     pub fn icon_rect(&self, rect: Rect) -> Option<(u32, Rect, f32)> {
-        let (image, iw, ih) = self.icon?;
+        let (image, iw, ih) = self.icon_face()?;
         let s = (rect.width.min(rect.height) - 8.0).max(4.0);
         let (dw, dh) = if iw >= ih {
             (s, s * ih / iw.max(1.0))
@@ -238,6 +278,23 @@ impl Adapted<Button> {
         self
     }
 
+    /// Icon face from a bundled cce-icons glyph, by NAME — the form to prefer
+    /// over [`with_icon`] whenever the artwork is one of cce-icons', because
+    /// the id is re-resolved per read and so survives a renderer rebuild (see
+    /// the [`icon_name`] field). `fallback` is the label drawn instead when
+    /// the icon set is missing on this machine.
+    ///
+    /// [`with_icon`]: Adapted::<Button>::with_icon
+    /// [`icon_name`]: Button::icon_name
+    pub fn with_icon_name(mut self, name: &str, fallback: &str) -> Self {
+        if crate::upload_icon(name, 32).is_some() {
+            self.icon_name = Some(name.to_string());
+            self
+        } else {
+            self.with_label(fallback)
+        }
+    }
+
     /// Dim the icon face — see the `icon_alpha` field. 1.0 is fully opaque.
     pub fn with_icon_alpha(mut self, alpha: f32) -> Self {
         self.icon_alpha = alpha;
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index a02935d..5bb0cfa 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -150,13 +150,11 @@ impl Ramp {
         // (x) and value (y), labeled like the dropdowns.
         let key_pad = Slider2D::new().with_label("Key");
         // A square x-icon button (cce-icons); label fallback if the icon set
-        // is missing on this machine.
-        let del_button = match crate::upload_icon("x", 32) {
-            Some((id, w, h)) => {
-                Button::new(0.0, 0.0, 22.0, 22.0).with_icon(id, w as f32, h as f32)
-            }
-            None => Button::new(0.0, 0.0, 64.0, 22.0).with_label("Delete"),
-        };
+        // is missing on this machine. By NAME, not by a captured id: an id
+        // does not survive the renderer rebuild a reconnect performs, and the
+        // widget outlives the renderer (see `Button::icon_name`).
+        let del_button =
+            Button::new(0.0, 0.0, 22.0, 22.0).with_icon_name("x", "Delete");
         // Short names on purpose: the strip's columns are narrow, and these
         // render inside param rows too ("Bevel (Raised)" used to clip).
         // Labeled: the dropdowns draw their own detached labels, sitting on