git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit6d0e3135dc9d73193e9088e92098fb107d202adc
parent493f8aeae0
authorLucas Galante <[email protected]>
date2026-09-19 00:40
fix(page): drop the page image when the renderer is replaced

The page pane's GPU image id is uploaded outside renderer_init, and the
runner does not replay such images into a replacement renderer. So after
a reconnect the ImageView held an id that named nothing and its draws
were skipped in SILENCE — the pane simply went blank, with no error
anywhere.

There is no separate reconnect callback: renderer_init is called once per
renderer, so the first call is this process's own and every later one is
a replacement. Remembering that it has been called is the only way to
tell them apart. renderer_handed_over does that, drops the id and asks
the next tick to recompose and re-upload; the raster rebuilds from the
node graph cheaply, and nothing in the toolkit can preserve an id across
renderers. Freeing the stale id is safe — destroy_image returns early on
an id the new table does not hold, ids come from a counter that never
resets, and free_image only queues — so the failure mode really is
"draws nothing", never "draws the wrong picture".

Split out of the callback so it can be tested: renderer_init needs a live
VkRenderer, renderer_handed_over needs nothing.

Found by cce-1f, auditing clients that cache cce_ui::vk image ids across
a reconnect; cce-status-interface lost its glyphs the same way. The
toolkit-level cause for bundled glyphs (upload_icon caching ids rather
than decoded pixels) is theirs and is being fixed in cce-ui.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md          | 14 +++++++++++++-
 src/app.rs         | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/application.rs |  3 +++
 src/main.rs        | 34 ++++++++++++++++++++++++++++++++++
 4 files changed, 99 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6923cf6..8034a94 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -439,7 +439,19 @@ them looks identical to the others:
   fails loudly — the one of the three that tells you itself.
 
 The GPU image is owned by `State::page_image` and freed when replaced;
-`ImageView` only borrows the id. `gem_graph`, the source family's
+`ImageView` only borrows the id. **A replacement renderer invalidates that id.** There is no reconnect
+callback: the runner calls `renderer_init` once per renderer, so the first call
+is this process's own and every later one is a replacement — remembering is the
+only way to tell them apart (`State::seen_renderer`, via
+`renderer_handed_over`, which is split out of the callback so it can be tested
+without a live `VkRenderer`). Images uploaded outside that callback are not
+replayed, so a cached id names nothing and its draws are skipped in SILENCE:
+the page pane just goes blank. The id is dropped and `page_dirty` asks the next
+tick to recompose and re-upload — the raster is cheap to rebuild from the node
+graph, and no id can be carried across renderers. Found by cce-1f's audit of
+clients caching vk image ids.
+
+`gem_graph`, the source family's
 everything-at-once node, is deliberately not ported: it is these four chained,
 and that collapse is the whole premise of "fifty operators, ten nodes".
 
diff --git a/src/app.rs b/src/app.rs
index c00ebe3..e85ee8b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1120,6 +1120,16 @@ pub struct State {
     /// The GPU image behind the page pane. Owned here — `ImageView` only
     /// borrows an id — so replacing a page frees the one it replaces.
     pub page_image: Option<u32>,
+    /// Whether `renderer_init` has run before. There is no separate reconnect
+    /// callback: the runner calls `renderer_init` once per renderer, so the
+    /// first call is this process's own and every later one is a REPLACEMENT
+    /// after a reconnect. Remembering is the only way to tell them apart.
+    pub seen_renderer: bool,
+    /// Set when the page raster must be re-uploaded — after a replacement
+    /// renderer drops the old id. Consumed on the next tick rather than acted
+    /// on in `renderer_init`, which runs before the frame has settled and
+    /// where relaying the panes would be premature.
+    pub page_dirty: bool,
     /// Frame the scene was last built at, so the timeline moving can invalidate it.
     pub last_sim_frame: i32,
     pub plate_menu_slot: Option<usize>,
@@ -4125,6 +4135,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             viewport_menu_actions: Vec::new(),
             sim_cache: crate::geometry::SimCache::default(),
             page_image: None,
+            seen_renderer: false,
+            page_dirty: false,
             last_sim_frame: i32::MIN,
             plate_menu_slot: None,
             plate_menu_actions: Vec::new(),
@@ -7199,6 +7211,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     pub fn tick_frame(&mut self, dt: f32) -> bool {
         let now = Instant::now();
 
+        // A replacement renderer left the page pane with no image; recompose
+        // and re-upload it now that the frame has settled.
+        if std::mem::take(&mut self.page_dirty) {
+            self.rebuild_page();
+        }
+
         // Drop-target glow animation: exponential smoothing toward the live
         // target — the position GLIDES between cells, alpha fades in while a
         // drag is in flight and out after it ends (lingering at the last
@@ -7562,6 +7580,37 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// One-time renderer setup (engine `renderer_init` hook): the persistent
     /// 3D meshes. The spheres mesh starts empty and fills from the node graph
     /// via the pending-mesh flush.
+    /// Note that a renderer has been handed over, and invalidate anything
+    /// that cannot survive a REPLACEMENT one. Returns whether this was a
+    /// replacement.
+    ///
+    /// Split out of `renderer_init` so it can be tested: the callback needs a
+    /// live `VkRenderer`, this needs nothing. There is no separate reconnect
+    /// callback — the runner calls `renderer_init` once per renderer, so the
+    /// first call is this process's own and every later one is a replacement,
+    /// and remembering is the only way to tell them apart.
+    ///
+    /// Images uploaded outside that callback are not replayed into the new
+    /// renderer, so a cached id names nothing and its draws are skipped in
+    /// SILENCE — the page pane simply went blank. Freeing the stale id is
+    /// safe (destroy_image returns early on an id the new table lacks, ids
+    /// come from a counter that never resets, and free_image only queues), and
+    /// the raster recomposes from the node graph cheaply, so dropping and
+    /// re-uploading beats trying to preserve anything.
+    pub fn renderer_handed_over(&mut self) -> bool {
+        if !std::mem::replace(&mut self.seen_renderer, true) {
+            return false;
+        }
+        if let Some(old) = self.page_image.take() {
+            cce_ui::vk::free_image(old);
+        }
+        self.slots.page_view.set_image(None);
+        // Re-uploaded on the next tick, not here: this runs before the frame
+        // has settled, and rebuild_page relays the panes.
+        self.page_dirty = true;
+        true
+    }
+
     pub fn init_renderer(&mut self, renderer: &mut cce_ui::vk::VkRenderer) {
         let cube_verts = cube_vertices();
         let linear_grid_color = cce_ui::colors::to_linear_rgb(self.grid_color);
diff --git a/src/application.rs b/src/application.rs
index 216074b..75bdd99 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -366,6 +366,9 @@ impl Application for State {
 
     fn renderer_init(&mut self, renderer: &mut VkRenderer) {
         self.init_renderer(renderer);
+        // Everything that cannot survive a REPLACEMENT renderer, which this
+        // may be — see `renderer_handed_over`.
+        self.renderer_handed_over();
     }
 
     fn stage_renderer(&mut self, renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
diff --git a/src/main.rs b/src/main.rs
index 756fe85..e07237a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4612,6 +4612,40 @@ mod tests {
         );
     }
 
+    /// A replacement renderer invalidates the page pane's image id, and the
+    /// state has to notice.
+    ///
+    /// There is no reconnect callback: the runner calls `renderer_init` once
+    /// per renderer, so the FIRST call is this process's own and every later
+    /// one is a replacement. Remembering that it has been called is the only
+    /// way to tell them apart — and getting it wrong is silent, because a
+    /// stale id names nothing and its draws are skipped rather than failing.
+    #[test]
+    fn test_a_replacement_renderer_drops_the_page_image() {
+        let mut state = State::new(false);
+        assert!(!state.seen_renderer, "a fresh State has not been given a renderer");
+        assert!(!state.page_dirty);
+
+        // Stand in for a composed page: an id owned by State and borrowed by
+        // the view.
+        state.page_image = Some(7);
+        state.slots.page_view.set_image(Some((7, 100, 100)));
+
+        // The FIRST renderer is this process's own — nothing to invalidate,
+        // and dropping the image here would throw away a page that is fine.
+        assert!(!state.renderer_handed_over(), "the first renderer is not a replacement");
+        assert_eq!(state.page_image, Some(7), "the first renderer must not drop the image");
+        assert!(!state.page_dirty);
+
+        // A LATER one is a replacement: the id names nothing in it, so it is
+        // dropped and the next tick re-uploads rather than leaving the pane
+        // blank forever.
+        assert!(state.renderer_handed_over(), "the second renderer must read as a replacement");
+        assert_eq!(state.page_image, None, "the dead id was kept");
+        assert!(state.slots.page_view.image.is_none(), "the view still borrows a dead id");
+        assert!(state.page_dirty, "nothing would re-upload the page");
+    }
+
     /// A page's raster is its physical size times its resolution — the
     /// property that makes DPI a page parameter rather than an export one.
     #[test]