graphic design tool
git clone https://git.lucas.co/cce-designer.git
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]