git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit27240023152e302944b822cb289a0cc550c1e7b3
parenta23b28b542
authorLucas Galante <[email protected]>
date2026-09-09 08:51
fix(window_runner): exit when the compositor is gone instead of rejoining its successor

A session restore in a headless shadow came up with two cce-color-editor
windows: one at the saved position, one shoved aside by the spawn-overlap
nudge. state.json held a single entry and the deferred spawn ran once. The
second window was the ORIGINAL process: `ctl exit force` terminates the
display without waiting for windows to close, and the client — kept alive
by the reconnect loop from 714aee1 — retried its connect with backoff for
~25s, long enough to attach to the next compositor on the same display
name, beside the copy that compositor had just respawned from state.json.
The same happens after a compositor crash followed by a prompt relogin.

The reconnect exists to repair a broken transport (fd exhaustion on
dmabuf feedback) while the compositor is still alive; it was never meant
to outlive the compositor, which saves every window for restore precisely
so its successor can respawn them. So a failed connect is now its own
outcome, `SessionEnd::NoCompositor` — the socket is unlinked by a
deliberate exit and refuses after a crash — and `run` exits on it, as a
Wayland client whose display went away always has. A lost connection to a
compositor that still answers reconnects exactly as before.

The decision is factored into `after_session` (pure; unit-tested for the
exit, retry, budget, reset and cap cases). Verified in a shadow with a
rebuilt cce-color-editor: the client exits within 250ms of `exit force`,
`start --restore` brings up exactly one window, and a
CCE_UI_FAULT_RECONNECT=3 drop still reconnects in-process (same pid binds
the window-manager global twice, 3s apart).

Clients carry the old loop until rebuilt against this toolkit.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/backend/window_runner.rs | 204 ++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 184 insertions(+), 20 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 765cf8f..f2e7bc8 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -5228,12 +5228,27 @@ impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturePinchV1, ()> for
 }
 
 /// Why a session's event loop stopped.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum SessionEnd {
     /// The app asked to exit.
     AppExit,
-    /// The compositor connection died. The `Application` is intact and can be
+    /// The compositor connection died while the compositor itself may well be
+    /// alive — a broken transport. The `Application` is intact and can be
     /// re-attached to a fresh connection.
     ConnectionLost,
+    /// Nothing answered at the display socket: the compositor this app
+    /// belonged to is gone. A deliberate exit unlinks the socket and a crash
+    /// leaves it refusing; either way there is no session left to rejoin.
+    NoCompositor,
+}
+
+/// What [`run`] does once a session has ended.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AfterSession {
+    /// Leave the process-lifetime loop: run `on_exit` and quit.
+    Exit,
+    /// Sleep this long, then open a fresh session on the same `Application`.
+    Reconnect(std::time::Duration),
 }
 
 /// How many consecutive failed reconnects before giving up. Reset once a
@@ -5242,6 +5257,51 @@ enum SessionEnd {
 const RECONNECT_ATTEMPTS: u32 = 8;
 const RECONNECT_RESET: std::time::Duration = std::time::Duration::from_secs(10);
 
+/// Decide whether a finished session is followed by another.
+///
+/// `lived` is how long the session that just ended lasted, `has_app` whether
+/// an `Application` exists to carry over, and `attempt` the running count of
+/// consecutive reconnects (reset here once a session outlives
+/// [`RECONNECT_RESET`]).
+///
+/// Only a lost connection is retried, and only while the compositor is still
+/// there to reconnect to. A reconnect is a repair of THIS session's transport
+/// — the fd-exhaustion break `raise_fd_limit` documents — not a way to outlive
+/// the compositor. When the connect itself fails the compositor has exited,
+/// and it has already saved this window for restore: the next compositor
+/// respawns the app from `state.json` on its own. A client that kept
+/// retrying instead (the backoff below spans ~25s) reattached to that
+/// successor beside the respawned copy, and every restore after a forced
+/// exit or a crash came up with two of each cce-ui window. So the process
+/// exits, as a Wayland client whose display went away always has.
+fn after_session(
+    end: SessionEnd,
+    has_app: bool,
+    lived: std::time::Duration,
+    attempt: &mut u32,
+) -> AfterSession {
+    match end {
+        SessionEnd::AppExit | SessionEnd::NoCompositor => AfterSession::Exit,
+        SessionEnd::ConnectionLost => {
+            // Nothing to preserve if we never got as far as building the
+            // app — that is a failure to start, not a lost window.
+            if !has_app {
+                return AfterSession::Exit;
+            }
+            if lived > RECONNECT_RESET {
+                *attempt = 0;
+            }
+            *attempt += 1;
+            if *attempt > RECONNECT_ATTEMPTS {
+                return AfterSession::Exit;
+            }
+            AfterSession::Reconnect(std::time::Duration::from_millis(
+                100 * (1 << (*attempt).min(6)),
+            ))
+        }
+    }
+}
+
 /// Raise this process's file-descriptor soft limit toward its hard limit.
 ///
 /// A cce-ui client's fd usage is not bounded by anything the app controls.
@@ -5298,6 +5358,12 @@ fn raise_fd_limit() {
 /// its `Sender` (cce-terminal's pty reader is the canonical case), and a fresh
 /// channel would orphan them into a live-but-deaf process.
 ///
+/// What is repaired is the transport, never the compositor: a reconnect only
+/// goes through while the compositor that owned the lost session is still
+/// listening. If the connect itself fails the compositor has exited, and the
+/// process exits with it — see [`after_session`] for why staying alive there
+/// duplicated every window on the next session restore.
+///
 /// Caveat: GPU resources belong to the renderer, so a rebuild re-runs
 /// [`Application::renderer_init`]. Images uploaded outside it (e.g. in
 /// [`Application::new`]) are not replayed into the new renderer — upload from
@@ -5364,27 +5430,27 @@ pub fn run<A: Application>() {
         app = returned_app;
         sources_registered = true;
 
-        match end {
-            SessionEnd::AppExit => break,
-            SessionEnd::ConnectionLost => {
-                // Nothing to preserve if we never got as far as building the
-                // app — that is a failure to start, not a lost window.
-                if app.is_none() {
-                    log::error!("[window_runner] no compositor connection; giving up");
-                    break;
-                }
-                if started.elapsed() > RECONNECT_RESET {
-                    attempt = 0;
-                }
-                attempt += 1;
-                if attempt > RECONNECT_ATTEMPTS {
-                    log::error!(
+        match after_session(end, app.is_some(), started.elapsed(), &mut attempt) {
+            AfterSession::Exit => {
+                match end {
+                    SessionEnd::AppExit => {}
+                    SessionEnd::NoCompositor if app.is_some() => log::warn!(
+                        "[window_runner] compositor is gone; exiting (its successor restores the session itself)"
+                    ),
+                    SessionEnd::NoCompositor => {
+                        log::error!("[window_runner] no compositor connection; giving up")
+                    }
+                    SessionEnd::ConnectionLost if app.is_some() => log::error!(
                         "[window_runner] connection lost; giving up after {} attempts",
                         attempt - 1
-                    );
-                    break;
+                    ),
+                    SessionEnd::ConnectionLost => {
+                        log::error!("[window_runner] no compositor connection; giving up")
+                    }
                 }
-                let backoff = std::time::Duration::from_millis(100 * (1 << attempt.min(6)));
+                break;
+            }
+            AfterSession::Reconnect(backoff) => {
                 log::warn!(
                     "[window_runner] compositor connection lost; reconnecting in {backoff:?} (attempt {attempt})"
                 );
@@ -5413,7 +5479,7 @@ fn run_session<'l, A: Application>(
         Ok(c) => c,
         Err(e) => {
             log::error!("[window_runner] cannot connect to compositor: {e}");
-            return (existing_app, SessionEnd::ConnectionLost);
+            return (existing_app, SessionEnd::NoCompositor);
         }
     };
     let (globals, mut event_queue) = match registry_queue_init(&conn) {
@@ -5972,3 +6038,101 @@ mod near_roll_fallback_tests {
         );
     }
 }
+
+#[cfg(test)]
+mod reconnect_tests {
+    use super::{after_session, AfterSession, SessionEnd, RECONNECT_ATTEMPTS, RECONNECT_RESET};
+    use std::time::Duration;
+
+    const LONG: Duration = Duration::from_secs(60);
+    const SHORT: Duration = Duration::from_millis(50);
+
+    #[test]
+    fn app_exit_ends_the_process() {
+        let mut attempt = 0;
+        assert_eq!(after_session(SessionEnd::AppExit, true, LONG, &mut attempt), AfterSession::Exit);
+        assert_eq!(attempt, 0);
+    }
+
+    #[test]
+    fn lost_transport_reconnects_with_backoff() {
+        let mut attempt = 0;
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, LONG, &mut attempt),
+            AfterSession::Reconnect(Duration::from_millis(200))
+        );
+        assert_eq!(attempt, 1);
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            AfterSession::Reconnect(Duration::from_millis(400))
+        );
+        assert_eq!(attempt, 2);
+    }
+
+    /// The compositor exited (its socket is unlinked, or refusing after a
+    /// crash). It saved this window for restore, so the successor respawns
+    /// the app itself; a client that waited for it reattached beside the
+    /// respawned copy, and the restore came up with two of every window.
+    #[test]
+    fn compositor_gone_exits_instead_of_waiting_for_a_successor() {
+        let mut attempt = 0;
+        assert_eq!(
+            after_session(SessionEnd::NoCompositor, true, LONG, &mut attempt),
+            AfterSession::Exit
+        );
+        // Even mid-budget: a reconnect that finds nobody listening is the
+        // compositor leaving, not another transport break.
+        let mut attempt = 3;
+        assert_eq!(
+            after_session(SessionEnd::NoCompositor, true, SHORT, &mut attempt),
+            AfterSession::Exit
+        );
+    }
+
+    #[test]
+    fn nothing_to_carry_over_gives_up() {
+        let mut attempt = 0;
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, false, SHORT, &mut attempt),
+            AfterSession::Exit
+        );
+        assert_eq!(
+            after_session(SessionEnd::NoCompositor, false, SHORT, &mut attempt),
+            AfterSession::Exit
+        );
+    }
+
+    #[test]
+    fn budget_is_bounded_and_resets_after_a_long_session() {
+        let mut attempt = 0;
+        for _ in 0..RECONNECT_ATTEMPTS {
+            assert!(matches!(
+                after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+                AfterSession::Reconnect(_)
+            ));
+        }
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            AfterSession::Exit
+        );
+        // A session that outlived the reset window earns a fresh budget.
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, RECONNECT_RESET + SHORT, &mut attempt),
+            AfterSession::Reconnect(Duration::from_millis(200))
+        );
+        assert_eq!(attempt, 1);
+    }
+
+    #[test]
+    fn backoff_caps_at_six_point_four_seconds() {
+        let mut attempt = 6;
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            AfterSession::Reconnect(Duration::from_millis(6400))
+        );
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            AfterSession::Reconnect(Duration::from_millis(6400))
+        );
+    }
+}