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

commitb299c272a613923e72a6de910a7743c2f4d599d3
parent4e0236557a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 20:28
Draw no popup frame before the surface's first configure

With the kept renderer (7014b9f) a popup is ready in microseconds, and
the Super-Tab switcher's window list, streamed in right after the
request, asked for a frame before the layer surface's first configure
arrived. Mesa attached a buffer to the unconfigured surface, the
compositor disconnected the client for it, and the daemon exited -- with
status 0, which systemd does not restart. A newly built renderer used to
take long enough that the configure always won. State::render now draws
nothing until a configure handler has run; both already ask for a redraw.

Also logs "open -> first frame with rows" for list popups, since a
streamed list's first frame can be empty.

In a shadow: 20 switcher opens served without a disconnect (both builds
since 7014b9f lost the daemon on the first or second), on screen with
rows at a 31 ms median against 317 ms for the build before 7014b9f;
96/96 menu and launcher popups drawn; switcher, menu and launcher
screenshots pixel-identical to that build's.

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

 CLAUDE.md   | 13 ++++++++++++-
 src/main.rs | 38 +++++++++++++++++++++++++++++++++++---
 2 files changed, 47 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 178be60..1d1cdba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -232,7 +232,18 @@ 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, the launcher in 30-60. An image uploaded to it lives as
+screen in 15-40 ms, the launcher in 30-60. **A popup draws nothing before its
+surface's first configure** (`State::configured`, checked in `State::render`):
+a buffer attached ahead of a layer surface's configure is a protocol error,
+and the compositor disconnects the daemon, and with it every popup after.
+The renderer used to take long enough to build that the configure always
+won; once it was kept, the Super-Tab switcher's streamed rows asked for a
+frame first and killed the daemon on its first open (fixed the same day,
+before it reached a release that stayed installed). Note a shadow's idle
+timeout turns its output off after 10 minutes, after which xdg toplevels
+(the launcher) are never configured at all; `ccectl idle timeouts 0 0`
+first. `open -> first frame with rows` is logged for list popups: a
+streamed list's first frame can be empty. An image uploaded to it lives as
 long as it does: upload through a cache like `icon_image`, never per popup,
 or free it at close. Standalone mode still builds its own.
 
diff --git a/src/main.rs b/src/main.rs
index b07489b..019acd9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1719,6 +1719,20 @@ struct State {
     /// app_id → icon name for the switcher's rows ([`desktop_icon_index`]),
     /// built on the first row that needs it and kept for the popup's life.
     switcher_icon_index: Option<std::collections::HashMap<String, String>>,
+    /// When `State::new` began, and whether the first frame that shows any
+    /// rows has been logged since. A streamed list (Dmenu, the switcher)
+    /// arrives after the popup opens, so the first frame alone can be an
+    /// empty list; this is the one that shows the user something to pick.
+    opened_at: std::time::Instant,
+    rows_frame_logged: bool,
+    /// Whether the compositor has configured the surface yet. Nothing may be
+    /// drawn before: a buffer attached ahead of a layer surface's first
+    /// configure is a protocol error, and the compositor disconnects the
+    /// whole client, which for the daemon is every popup after it. Until
+    /// 2026-10-05 the renderer took long enough to build that the configure
+    /// always won; with the daemon's kept renderer a popup is ready in
+    /// microseconds, and the switcher's streamed rows asked for a frame first.
+    configured: bool,
     last_tick: std::time::Instant,
     ui_context: cce_ui::context::UiContext,
     /// Dissolved root plate container (Phase 6as): the plate was a pure value-holder for the
@@ -2102,6 +2116,9 @@ impl State {
             select_item,
             switcher_mode,
             switcher_icon_index: None,
+            opened_at: t_start,
+            rows_frame_logged: false,
+            configured: false,
             last_tick: std::time::Instant::now(),
             ui_context: cce_ui::context::UiContext::new(),
             window_rect,
@@ -2195,7 +2212,9 @@ impl State {
         if new.is_empty() {
             return;
         }
+        let t = std::time::Instant::now();
         let index = index.get_or_insert_with(|| desktop_icon_index(&application_dirs()));
+        log::debug!("[timing] switcher icon index: {:?}", t.elapsed());
         let icons: Vec<(String, (u32, u32, u32))> = new
             .into_iter()
             .filter_map(|item| {
@@ -2465,7 +2484,13 @@ impl State {
         }
     }
 
+    /// Draw a frame, or nothing before the surface's first configure (see
+    /// `State::configured`): true if a frame was drawn. The configure
+    /// handlers ask for a redraw, so a skipped frame is drawn right after it.
     fn render(&mut self) -> bool {
+        if !self.configured {
+            return false;
+        }
         let now = std::time::Instant::now();
         self.last_tick = now;
 
@@ -2481,7 +2506,11 @@ impl State {
             // Always a full frame: the popup is small and repaints whole.
             damage: None,
         });
-        false
+        if !self.rows_frame_logged && !self.fuzzel.filtered_items.is_empty() {
+            self.rows_frame_logged = true;
+            log::info!("[timing] open -> first frame with rows: {:?}", self.opened_at.elapsed());
+        }
+        true
     }
 }
 
@@ -3158,6 +3187,7 @@ impl LayerShellHandler for AppState {
             let pw = (width as f64 * state.scale) as u32;
             let ph = (height as f64 * state.scale) as u32;
             state.resize(pw, ph);
+            state.configured = true;
         }
         self.redraw = true;
     }
@@ -3172,6 +3202,9 @@ impl WindowHandler for AppState {
         configure: WindowConfigure,
         _serial: u32,
     ) {
+        if let Some(state) = &mut self.state {
+            state.configured = true;
+        }
         let (w, h) = configure.new_size;
         if let (Some(w), Some(h)) = (w, h) {
             let width = w.get();
@@ -4375,8 +4408,7 @@ fn run_daemon(socket_path: &str) {
             if app.redraw {
                 app.redraw = false;
                 if let Some(st) = &mut app.state {
-                    let _ = st.render();
-                    if !first_frame_logged {
+                    if st.render() && !first_frame_logged {
                         first_frame_logged = true;
                         log::info!("[timing] request -> first frame: {:?}", t_request.elapsed());
                     }