git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit7014b9fc2d525e6bc21c0d0d488d8b62d5f112d5
parent8c54fc9e64
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 19:56
Keep one renderer for every daemon popup

Each popup built a fresh VkRenderer, and its pipelines were 70-100 ms of a
~100 ms popup on an idle machine and 250-500 ms under load: nearly all of
the time between the desktop menu's right-click and the menu. The daemon
now keeps the renderer like it keeps the font system, building it at
startup on a never-mapped scratch surface and moving it from popup to
popup with cce-ui's attach_surface/detach_surface (detached before the
State drops its wl_surface). Row icons, still uploaded per popup, are
freed at close, since the renderer now outlives them.

Measured in a shadow under load average ~20 against a control built from
HEAD at the same cce-ui pin: menu popups 220-590 ms -> 14-43 ms to first
frame, the launcher 300-1000 ms -> 75-580 ms. Screenshots of the menu and
the launcher on a third reopen are pixel-identical to the control's, and
daemon RSS is flat over 36 menu and 60 launcher opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   | 26 ++++++++++++++----
 src/main.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++------------
 2 files changed, 95 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 340fbb8..eb35e92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,9 +128,11 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
   an absolute path, missing, or a generic name several apps share — see
   `cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
   nothing here: the user's data dir is the theme search's first base dir.
-  Icons are uploaded per popup on purpose (`cce_ui::icon::upload_themed` caches
-  the decode, not the image id) because `Drop` destroys this app's `VkRenderer`
-  between popups and an id cached across them would name freed GPU resources.
+  Icons are uploaded per popup (`cce_ui::icon::upload_themed` caches the
+  decode, not the image id), and the daemon frees them with
+  `cce_ui::vk::free_image` when the popup closes: the renderer outlives the
+  popup (see "One renderer for every popup" below), so an upload nobody freed
+  would stay on the GPU for the daemon's life.
   Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
   *System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
   `ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
@@ -210,13 +212,27 @@ list across to `Frame2D::images`: images ride a separate pipeline from the verte
 batches, and that return value was dropped (with `images: &[]` hardcoded) until
 2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
 it worked in every engine-runner client. `State::renderer` is an `Option`
-solely so `Drop` can tear the swapchain down before destroying the `wl_surface` (daemon
-mode churns one `State` per popup). It still reuses cce-ui pieces à la carte: the
+so the daemon can take it back when a popup closes, and so `Drop` can tear the
+swapchain down before destroying the `wl_surface` in standalone mode. It still reuses cce-ui pieces à la carte: the
 narrow widget traits (`Layout`/`Paint`/`Input` via `Adapted<T>`), the scene paint walk
 (`append_widget_text`) for text extraction, `color`/`layout`/`scale` getters, and the
 `zcce_window_manager_v1` protocol. Follow existing cce-ui conventions when touching
 widget code, but don't try to "port" this app onto the engine runner.
 
+### One renderer for every popup
+
+The daemon keeps one `VkRenderer` for its whole life, like the font system:
+`State::new` takes it and moves it onto the popup's new `wl_surface` with
+`VkRenderer::attach_surface`, and when the popup closes the daemon takes it back
+and calls `detach_surface` BEFORE the `State` drops (the drop destroys the
+`wl_surface`, and a swapchain must not outlive it). `run_daemon` builds it at
+startup on a scratch surface that is never mapped, so the first popup attaches
+too. Until 2026-10-05 every popup built a new renderer, and its pipelines were
+70-100 ms of a ~100 ms popup on an idle machine and several hundred under
+load; a reopen is now one swapchain, and a menu popup is ready in ~2 ms and on
+screen in 15-40 ms. Anything a popup uploads to it (icons) must be freed at
+close for the same reason. Standalone mode still builds its own.
+
 Surface choice: an XDG toplevel flagged as popup via the cce window-management
 protocol when the compositor global is present and no `-x/-y` was given; otherwise a
 layer-shell **Overlay** surface with exclusive keyboard. The window continuously
diff --git a/src/main.rs b/src/main.rs
index fb30ea2..5abf2b5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1747,6 +1747,7 @@ impl State {
         json_layout_config: Option<JsonLayoutConfig>,
         parent_app_id: Option<String>,
         fonts: Option<(FontSystem, SwashCache)>,
+        renderer: Option<VkRenderer>,
     ) -> Result<
         (Self, Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>),
         cce_ui::vk::SurfaceLost,
@@ -1890,18 +1891,27 @@ impl State {
         // Raw-Vulkan renderer on the same display/surface pointers the wgpu
         // stack used. Corner radius 0: the window background tessellates its own
         // rounded corners (rounded_rect_vertices_corners).
+        //
+        // The daemon hands in the renderer it kept from the last popup, and
+        // moving it onto this surface costs one swapchain. Building a new one
+        // costs a device and every pipeline: 70-100 ms of a ~100 ms popup on
+        // an idle machine, several hundred under load. Both fail only when
+        // the connection is already dead under the surface.
         let t = std::time::Instant::now();
-        // Fails only when the connection is already dead under the surface.
-        let renderer = unsafe {
-            VkRenderer::try_new(
-                conn.backend().display_id().as_ptr() as *mut std::ffi::c_void,
-                wl_surface.id().as_ptr() as *mut std::ffi::c_void,
-                pw,
-                ph,
-                0.0,
-            )
-        }?;
-        log::debug!("[timing] VkRenderer::try_new: {:?}", t.elapsed());
+        let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
+        let surface_ptr = wl_surface.id().as_ptr() as *mut std::ffi::c_void;
+        let renderer = match renderer {
+            Some(mut kept) => {
+                unsafe { kept.attach_surface(display_ptr, surface_ptr, pw, ph) }?;
+                log::debug!("[timing] VkRenderer::attach_surface: {:?}", t.elapsed());
+                kept
+            }
+            None => {
+                let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
+                log::debug!("[timing] VkRenderer::try_new: {:?}", t.elapsed());
+                made
+            }
+        };
 
         // Reuse the daemon's font system across popups (a rebuild re-scans the
         // fonts dir and loses the shaping caches).
@@ -1955,10 +1965,10 @@ impl State {
             let app_names: Vec<String> = apps.iter().map(|app| app.name.clone()).collect();
 
             // Resolve every entry's Icon= against the icon theme. Uploads are
-            // per-popup by design (see cce_ui::icon::upload_themed): the daemon
-            // tears its VkRenderer down between popups, so an id cached across
-            // them would name freed GPU resources. Only the decode is cached, so
-            // the second open of the launcher skips the disk and the rasterizer.
+            // per-popup, and the daemon frees them when the popup closes (the
+            // renderer itself is kept). Only the decode is cached (see
+            // cce_ui::icon::upload_themed), so the second open of the launcher
+            // skips the disk and the rasterizer.
             let t_icons = std::time::Instant::now();
             let icons: std::collections::HashMap<String, (u32, u32, u32)> = apps
                 .iter()
@@ -2141,8 +2151,8 @@ impl State {
 
     /// Give the switcher's window rows their app's icon. Rows stream in over
     /// stdin, so this runs per ingest and only resolves the rows that don't
-    /// have an icon yet. Uploads are per-popup for the same reason as Apps
-    /// mode's (see `State::new`). `index` is `State::switcher_icon_index`.
+    /// have an icon yet. Uploads are per-popup, like Apps mode's (see
+    /// `State::new`). `index` is `State::switcher_icon_index`.
     fn resolve_switcher_icons(
         fuzzel: &mut FuzzelWidget,
         index: &mut Option<std::collections::HashMap<String, String>>,
@@ -3618,6 +3628,7 @@ fn run_standalone() {
         json_layout_config,
         parent_app_id,
         None,
+        None,
     )
     .unwrap_or_else(|lost| {
         log::error!("cannot open the window: {lost}");
@@ -3904,6 +3915,39 @@ fn run_daemon(socket_path: &str) {
 
     event_queue.roundtrip(&mut app).unwrap();
 
+    // The renderer every popup draws with, built now on a surface that is
+    // never mapped and then detached from it, so the first popup only
+    // attaches it as every later one does. Each popup hands it back when it
+    // closes (see the end of the loop). If this fails the first popup builds
+    // its own, as all of them used to.
+    let t_renderer = std::time::Instant::now();
+    let mut renderer_slot: Option<VkRenderer> = {
+        let scratch = app.compositor_state.create_surface(&qh);
+        let made = unsafe {
+            VkRenderer::try_new(
+                conn_clone.backend().display_id().as_ptr() as *mut std::ffi::c_void,
+                scratch.id().as_ptr() as *mut std::ffi::c_void,
+                1,
+                1,
+                0.0,
+            )
+        };
+        let kept = match made {
+            Ok(mut r) => {
+                r.detach_surface();
+                Some(r)
+            }
+            Err(e) => {
+                log::warn!("could not prewarm the popup renderer: {e}");
+                None
+            }
+        };
+        scratch.destroy();
+        let _ = conn_clone.flush();
+        kept
+    };
+    log::info!("[timing] daemon renderer prewarm: {:?}", t_renderer.elapsed());
+
     let scale = cce_ui::wayland::detect_scale_factor(&app.output_state);
     let xdg_shell_state = smithay_client_toolkit::shell::xdg::XdgShell::bind(&globals, &qh).ok();
 
@@ -4194,6 +4238,7 @@ fn run_daemon(socket_path: &str) {
             json_layout_config,
             parent_app_id,
             fonts_slot.take(),
+            renderer_slot.take(),
         ) {
             Ok(made) => made,
             Err(lost) => {
@@ -4314,6 +4359,18 @@ fn run_daemon(socket_path: &str) {
             );
             let sc = std::mem::replace(&mut st.swash_cache, SwashCache::new());
             fonts_slot = Some((fs, sc));
+            // Keep the renderer for the next popup, detached now: the State's
+            // drop below destroys this wl_surface, and the swapchain must not
+            // outlive it. Its images outlive the popup too, so free the row
+            // icons this popup uploaded (Apps, the switcher) or every open of
+            // the launcher would leave another set on the GPU.
+            for (_, (id, _, _)) in st.fuzzel.icons.drain() {
+                cce_ui::vk::free_image(id);
+            }
+            if let Some(mut r) = st.renderer.take() {
+                r.detach_surface();
+                renderer_slot = Some(r);
+            }
         }
         app.window = None;
         app.surface = None;