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

commit261aad049050f569849783aa25566ad6e1340414
parent843ccc8069
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 15:48
fix: the daemon exits with its compositor instead of outliving it

At the 2026-09-25 logout the daemon panicked building a popup
(`No surface formats: ERROR_SURFACE_LOST_KHR`). It had survived the
previous logout eight minutes earlier and was still holding that
compositor's dead Wayland connection when the next session asked it for
a window. Two things let it survive:

- The unit hung off graphical-session.target, and systemd skipped that
  target's stop (a queued dropbox start made the transaction
  "destructive"). It is PartOf/WantedBy cce-session.target now, which
  startcce starts and stops with each compositor, like cce-grid.
  Needs `systemctl --user reenable cce-cloud.service`.
- Between popups it sat in a blocking accept() and never read the
  Wayland socket, so a dead connection went unnoticed. The idle wait now
  dispatches the Wayland source alongside the listener (a calloop
  Generic on a dup of it), and a dispatch error exits cleanly with
  status 0 (compositor_gone). The same holds mid-popup and for a lost
  surface at construction (VkRenderer::try_new, cce-ui f9ccb5f); the
  startup connect logs and exits 1 rather than unwrapping.

Verified in a shadow: the installed daemon outlives a killed compositor
and panics at renderer.rs:471 on the next request; this build exits 0
within a second of the compositor going.

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

 CLAUDE.md         |  13 +++++++
 cce-cloud.service |  14 ++++++--
 src/main.rs       | 103 ++++++++++++++++++++++++++++++++++++++++++++----------
 3 files changed, 108 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 987a8cf..3c7dbb3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,19 @@ cce-cloud --daemon                       # run the daemon (normally started by t
 - If the socket connect fails, it falls back to `run_standalone()`: the same UI
   in-process, selection printed directly to stdout.
 
+**The daemon's life is one compositor's.** It holds one Wayland connection for good,
+so it has to notice that connection dying: the wait for the next request dispatches the
+Wayland source alongside the socket listener (a calloop `Generic` on a dup of it), and a
+dispatch error — the compositor gone — is `compositor_gone`, a clean exit with status 0.
+The unit is `PartOf=` / `WantedBy=cce-session.target`, which startcce starts and stops
+with each compositor, so the next session brings up a fresh daemon. Until 2026-09-25 the
+wait was a blocking `accept()` that never read the Wayland socket, and the unit hung off
+`graphical-session.target`, whose stop systemd skipped at one logout (a queued dropbox
+start made the transaction "destructive"): the daemon survived into the next session and
+panicked building its first popup on the dead connection (`No surface formats:
+ERROR_SURFACE_LOST_KHR`). A unit's `[Install]` change needs `systemctl --user reenable
+cce-cloud.service` — `ccebuild install` copies units but does not re-enable them.
+
 The daemon re-parses the forwarded args with the same flag loop as standalone — **flag
 changes must be made in both `run_standalone()` and `run_daemon()`** (and, for the
 needs-stdin decision, in `run_client()`).
diff --git a/cce-cloud.service b/cce-cloud.service
index 90a88b9..abc8c39 100644
--- a/cce-cloud.service
+++ b/cce-cloud.service
@@ -1,7 +1,15 @@
 [Unit]
 Description=CCE Cloud Menu Daemon
-After=graphical-session.target
-PartOf=graphical-session.target
+# Bound to ONE compositor's life, like cce-grid: startcce starts
+# cce-session.target after the compositor is up and stops it when it exits.
+# It was graphical-session.target until 2026-09-25, which does not reliably
+# stop at logout (a queued start job elsewhere, dropbox's, made the stop
+# transaction "destructive" and systemd skipped it), so the daemon outlived
+# the compositor, still holding its dead Wayland connection, and panicked
+# on the next session's first popup. After changing [Install], re-enable:
+# `systemctl --user reenable cce-cloud.service`.
+After=cce-session.target
+PartOf=cce-session.target
 
 [Service]
 Type=simple
@@ -25,4 +33,4 @@ Environment=RUST_LOG=info
 Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin
 
 [Install]
-WantedBy=graphical-session.target
+WantedBy=cce-session.target
diff --git a/src/main.rs b/src/main.rs
index 5814d51..205f952 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1462,7 +1462,10 @@ impl State {
         json_layout_config: Option<JsonLayoutConfig>,
         parent_app_id: Option<String>,
         fonts: Option<(FontSystem, SwashCache)>,
-    ) -> (Self, Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>) {
+    ) -> Result<
+        (Self, Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>),
+        cce_ui::vk::SurfaceLost,
+    > {
         let t_start = std::time::Instant::now();
         cce_ui::scale::set_scale_factor(scale as f32);
         let (width, height) = if mode == LauncherMode::Json {
@@ -1603,15 +1606,16 @@ impl State {
         // stack used. Corner radius 0: the window background tessellates its own
         // rounded corners (rounded_rect_vertices_corners).
         let t = std::time::Instant::now();
+        // Fails only when the connection is already dead under the surface.
         let renderer = unsafe {
-            VkRenderer::new(
+            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::new: {:?}", t.elapsed());
 
         // Reuse the daemon's font system across popups (a rebuild re-scans the
@@ -1787,7 +1791,7 @@ impl State {
         state.upload_vertices();
         log::debug!("[timing] initial layout/upload: {:?}", t.elapsed());
         log::debug!("[timing] State::new total: {:?}", t_start.elapsed());
-        (state, cce_toplevel)
+        Ok((state, cce_toplevel))
     }
 
     fn check_stdin_updates(&mut self) -> bool {
@@ -3263,7 +3267,11 @@ fn run_standalone() {
         json_layout_config,
         parent_app_id,
         None,
-    );
+    )
+    .unwrap_or_else(|lost| {
+        log::error!("cannot open the window: {lost}");
+        std::process::exit(1);
+    });
 
     app.window = state.window.clone();
     app.surface = Some(state.wl_surface.clone());
@@ -3312,7 +3320,10 @@ fn run_standalone() {
         } else {
             std::time::Duration::from_millis(16)
         };
-        event_loop.dispatch(timeout, &mut app).unwrap();
+        if let Err(e) = event_loop.dispatch(timeout, &mut app) {
+            log::error!("compositor connection lost: {e}");
+            std::process::exit(1);
+        }
 
         if app.exit {
             break;
@@ -3448,6 +3459,28 @@ fn run_client(socket_path: &str, args: &[String]) -> Result<(), Box<dyn std::err
     Ok(())
 }
 
+/// The compositor this daemon serves has gone: exit, as a Wayland client whose
+/// display went away does.
+///
+/// The daemon holds ONE Wayland connection for its whole life, so it has to
+/// notice when that connection dies. Until 2026-09-25 it did not: between
+/// popups it sat in a blocking `accept()`, never reading the Wayland socket,
+/// and a daemon that outlived a logout kept a connection to a compositor that
+/// no longer existed. The next session's first popup was then built on that
+/// dead connection and the renderer panicked (`No surface formats:
+/// ERROR_SURFACE_LOST_KHR`). The idle wait now dispatches the Wayland source
+/// alongside the listener, so a dead connection surfaces as a dispatch error
+/// the moment the compositor goes; a surface that is lost anyway (the death
+/// raced a request) lands here too.
+///
+/// Status 0, so `Restart=on-failure` does not relaunch it into a session with
+/// no compositor: the unit is bound to `cce-session.target`, which startcce
+/// starts with each compositor, and that start brings up a fresh daemon.
+fn compositor_gone(why: impl std::fmt::Display) -> ! {
+    log::warn!("compositor is gone ({why}); exiting");
+    std::process::exit(0);
+}
+
 fn run_daemon(socket_path: &str) {
     use std::io::{Write, BufRead};
     let _ = std::fs::remove_file(socket_path);
@@ -3471,7 +3504,15 @@ fn run_daemon(socket_path: &str) {
     let _ = cce_ui::widget::get_font_db(); // measure_text's resvg fontdb (system-font scan)
     log::info!("[timing] daemon prewarm: {:?}", t_prewarm.elapsed());
 
-    let conn = Connection::connect_to_env().unwrap();
+    let conn = match Connection::connect_to_env() {
+        Ok(conn) => conn,
+        Err(e) => {
+            // A failure status, so systemd's Restart=on-failure tries again
+            // if the session is still starting up.
+            log::error!("cannot connect to the compositor: {e}");
+            std::process::exit(1);
+        }
+    };
     let conn_clone = conn.clone();
     let (globals, mut event_queue) = registry_queue_init(&conn).unwrap();
     let qh = event_queue.handle();
@@ -3514,18 +3555,36 @@ fn run_daemon(socket_path: &str) {
     let mut event_loop = calloop::EventLoop::try_new().unwrap();
     let loop_handle = event_loop.handle();
     WaylandSource::new(conn, event_queue).insert(loop_handle.clone()).unwrap();
+    // The listener wakes the loop too, so waiting for the next request also
+    // reads the Wayland connection — see `compositor_gone`. The callback does
+    // nothing: the accept after each dispatch takes the connection.
+    let listener_wake = listener.try_clone().expect("dup the daemon socket");
+    loop_handle
+        .insert_source(
+            calloop::generic::Generic::new(
+                listener_wake,
+                calloop::Interest::READ,
+                calloop::Mode::Level,
+            ),
+            |_, _, _| Ok(calloop::PostAction::Continue),
+        )
+        .unwrap();
+    let _ = listener.set_nonblocking(true);
 
     let mut pending: Option<std::os::unix::net::UnixStream> = None;
     loop {
         let mut stream = match pending.take() {
             Some(s) => s,
-            None => {
-                let _ = listener.set_nonblocking(false);
-                match listener.accept() {
-                    Ok((s, _)) => s,
-                    Err(_) => continue,
+            None => match listener.accept() {
+                Ok((s, _)) => s,
+                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+                    if let Err(e) = event_loop.dispatch(None, &mut app) {
+                        compositor_gone(e);
+                    }
+                    continue;
                 }
-            }
+                Err(_) => continue,
+            },
         };
 
         let (stdin_sender, stdin_channel) = calloop::channel::channel::<()>();
@@ -3759,7 +3818,7 @@ fn run_daemon(socket_path: &str) {
         app.switcher_mode = switcher_mode;
         app.selected_item = None;
 
-        let (state, cce_toplevel) = State::new(
+        let (state, cce_toplevel) = match State::new(
             &conn_clone,
             &qh,
             &app.compositor_state,
@@ -3780,7 +3839,13 @@ fn run_daemon(socket_path: &str) {
             json_layout_config,
             parent_app_id,
             fonts_slot.take(),
-        );
+        ) {
+            Ok(made) => made,
+            Err(lost) => {
+                let _ = stream.write_all(b"\n");
+                compositor_gone(lost);
+            }
+        };
 
         app.window = state.window.clone();
         app.surface = Some(state.wl_surface.clone());
@@ -3795,9 +3860,6 @@ fn run_daemon(socket_path: &str) {
         // so fire one signal to make the channel handler ingest them.
         let _ = stdin_sender.send(());
 
-        // Watch for preempting connections while the popup is open.
-        let _ = listener.set_nonblocking(true);
-
         log::debug!("[timing] request -> popup ready: {:?}", t_request.elapsed());
         let mut first_frame_logged = false;
         let mut last_tick = std::time::Instant::now();
@@ -3816,7 +3878,10 @@ fn run_daemon(socket_path: &str) {
             } else {
                 std::time::Duration::from_millis(16)
             };
-            event_loop.dispatch(timeout, &mut app).unwrap();
+            if let Err(e) = event_loop.dispatch(timeout, &mut app) {
+                let _ = stream.write_all(b"\n");
+                compositor_gone(e);
+            }
 
             if app.exit {
                 break;