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

commit7c620ae1be2bd6b5d17c837d6e1d2f0be7343540
parent60f62b257c
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 16:28
feat(window_runner): a daemon can outlive its compositor

2724002 made a client exit when the compositor is gone (NoCompositor)
instead of rejoining the next one, because the compositor restores its
windows and a rejoining client came up beside its own copy. That is
right for windows and wrong for a process the compositor does not
restore: a systemd user service like the status bar or the notifier,
which must outlive it and whose D-Bus names other programs depend on.

Application::outlives_compositor (default false) opts such an app into
AfterSession::AwaitCompositor: the runner polls for the successor's
socket every 250 ms and rejoins it with the same Application.

Found when the status bar was rebuilt against 2724002: at every logout
its modules exited, the launcher's restart backoff grew while nobody
was logged in, and the tray's StatusNotifierWatcher came back 2.5 s
after the next login -- Dropbox, starting into that gap, found no tray.
Verified in a shadow: with the compositor killed, a status-bar module
stays alive, waits, and rejoins the next compositor as the same process.

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

 CLAUDE.md                    |  14 ++++++
 src/backend/window_runner.rs | 110 ++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 111 insertions(+), 13 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bcba40a..f6fa371 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,6 +117,20 @@ compositor and asked for a window over its connection. Reproduced by opening a
 `wl_surface`, killing the shadow compositor, then constructing: `try_new` returns the
 error where the old `new` panicked.
 
+### A daemon can outlive its compositor (`Application::outlives_compositor`, 2026-09-25)
+
+When the compositor is gone (`SessionEnd::NoCompositor` — nothing at the socket) the
+runner EXITS by default: the compositor saves windows for restore and its successor
+respawns them, so a client that rejoined came up beside its own copy (2724002). That is
+wrong for a process the compositor does not restore — a systemd user service like the
+status bar or the notifier, which must outlive it and whose D-Bus names other programs
+depend on. Such an app returns true from `outlives_compositor`; the runner then waits for
+the successor's socket (`await_compositor_socket`, a 250 ms poll) and rejoins it with the
+same `Application`. Found when the status bar was rebuilt against 2724002: at every logout
+its modules exited, the launcher's backoff grew while nobody was logged in, and the tray's
+StatusNotifierWatcher came back seconds after the next login — Dropbox, starting into the
+gap, reported no tray.
+
 ### `renderer_init` — GPU handles do not survive a reconnect
 
 A connection is one **session**. A Wayland transport cannot be repaired once it breaks,
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index d6f6043..02c8ac2 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3673,6 +3673,20 @@ pub trait Application: Sized + 'static {
         false
     }
 
+    /// Wait for the NEXT compositor when this one goes away, instead of
+    /// exiting. Default false, which is right for any window the compositor
+    /// saves and restores: its successor respawns the app itself, and a
+    /// client that rejoined too came up beside its own copy (see
+    /// [`after_session`]). Return true from a process the compositor does NOT
+    /// restore and that must outlive it — a systemd user service like the
+    /// status bar or the notifier, whose D-Bus names (the tray's
+    /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
+    /// depend on. Exiting took those names down at every logout and
+    /// compositor restart, and Dropbox, starting into the gap, found no tray.
+    fn outlives_compositor(&self) -> bool {
+        false
+    }
+
     /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
     /// caches its geometry until its own rebuild flag (relief carves collected
     /// in a view pass, widget lists built on layout) raises that flag here, so
@@ -5595,6 +5609,39 @@ enum AfterSession {
     Exit,
     /// Sleep this long, then open a fresh session on the same `Application`.
     Reconnect(std::time::Duration),
+    /// The compositor is gone and the app outlives it
+    /// ([`Application::outlives_compositor`]): wait for a successor's socket,
+    /// then open a fresh session on the same `Application`.
+    AwaitCompositor,
+}
+
+/// The display socket this process connects to: `$WAYLAND_DISPLAY` (absolute,
+/// or a name under `$XDG_RUNTIME_DIR`), `wayland-0` when unset — the lookup
+/// `Connection::connect_to_env` makes.
+fn wayland_socket_path() -> Option<std::path::PathBuf> {
+    let name = std::env::var_os("WAYLAND_DISPLAY").unwrap_or_else(|| "wayland-0".into());
+    let name = std::path::PathBuf::from(name);
+    if name.is_absolute() {
+        return Some(name);
+    }
+    Some(std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?).join(name))
+}
+
+/// Sleep until the display socket exists again — the successor compositor
+/// has bound it. Polled at 250 ms: a quarter-second after the next login is
+/// soon enough, and a daemon waiting through a logged-out hour costs four
+/// `stat`s a second. A stale socket a crash left behind satisfies the poll
+/// and fails the connect, which comes back here after the same pause.
+fn await_compositor_socket() {
+    loop {
+        std::thread::sleep(std::time::Duration::from_millis(250));
+        match wayland_socket_path() {
+            Some(path) if path.exists() => return,
+            Some(_) => {}
+            // No runtime dir to look in: keep trying the connect itself.
+            None => return,
+        }
+    }
 }
 
 /// How many consecutive failed reconnects before giving up. Reset once a
@@ -5620,13 +5667,23 @@ const RECONNECT_RESET: std::time::Duration = std::time::Duration::from_secs(10);
 /// 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.
+///
+/// Unless the app OUTLIVES the compositor (`outlives`,
+/// [`Application::outlives_compositor`]) — a daemon the compositor does not
+/// restore. Then there is no copy to collide with and every reason to stay:
+/// it waits for the successor and rejoins it.
 fn after_session(
     end: SessionEnd,
     has_app: bool,
     lived: std::time::Duration,
     attempt: &mut u32,
+    outlives: bool,
 ) -> AfterSession {
     match end {
+        SessionEnd::NoCompositor if has_app && outlives => {
+            *attempt = 0;
+            AfterSession::AwaitCompositor
+        }
         SessionEnd::AppExit | SessionEnd::NoCompositor => AfterSession::Exit,
         SessionEnd::ConnectionLost => {
             // Nothing to preserve if we never got as far as building the
@@ -5776,7 +5833,8 @@ pub fn run<A: Application>() {
         app = returned_app;
         sources_registered = true;
 
-        match after_session(end, app.is_some(), started.elapsed(), &mut attempt) {
+        let outlives = app.as_ref().is_some_and(|a| a.outlives_compositor());
+        match after_session(end, app.is_some(), started.elapsed(), &mut attempt, outlives) {
             AfterSession::Exit => {
                 match end {
                     SessionEnd::AppExit => {}
@@ -5802,6 +5860,11 @@ pub fn run<A: Application>() {
                 );
                 std::thread::sleep(backoff);
             }
+            AfterSession::AwaitCompositor => {
+                log::warn!("[window_runner] compositor is gone; waiting for the next one");
+                await_compositor_socket();
+                log::info!("[window_runner] a compositor is back; rejoining");
+            }
         }
     }
 
@@ -6480,7 +6543,7 @@ mod reconnect_tests {
     #[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!(after_session(SessionEnd::AppExit, true, LONG, &mut attempt, false), AfterSession::Exit);
         assert_eq!(attempt, 0);
     }
 
@@ -6488,12 +6551,12 @@ mod reconnect_tests {
     fn lost_transport_reconnects_with_backoff() {
         let mut attempt = 0;
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, true, LONG, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, true, LONG, &mut attempt, false),
             AfterSession::Reconnect(Duration::from_millis(200))
         );
         assert_eq!(attempt, 1);
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt, false),
             AfterSession::Reconnect(Duration::from_millis(400))
         );
         assert_eq!(attempt, 2);
@@ -6507,27 +6570,48 @@ mod reconnect_tests {
     fn compositor_gone_exits_instead_of_waiting_for_a_successor() {
         let mut attempt = 0;
         assert_eq!(
-            after_session(SessionEnd::NoCompositor, true, LONG, &mut attempt),
+            after_session(SessionEnd::NoCompositor, true, LONG, &mut attempt, false),
             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),
+            after_session(SessionEnd::NoCompositor, true, SHORT, &mut attempt, false),
             AfterSession::Exit
         );
     }
 
+    /// A daemon the compositor does not restore (the status bar, the
+    /// notifier) waits for the successor instead — with no copy to collide
+    /// with, exiting only took its D-Bus names down with it. It starts a fresh
+    /// budget, and a transport break still reconnects as before.
+    #[test]
+    fn an_app_that_outlives_the_compositor_waits_for_the_next() {
+        let mut attempt = 3;
+        assert_eq!(
+            after_session(SessionEnd::NoCompositor, true, SHORT, &mut attempt, true),
+            AfterSession::AwaitCompositor
+        );
+        assert_eq!(attempt, 0);
+        assert_eq!(
+            after_session(SessionEnd::ConnectionLost, true, LONG, &mut attempt, true),
+            AfterSession::Reconnect(Duration::from_millis(200))
+        );
+        // Asked to exit, or never started: it still goes.
+        assert_eq!(after_session(SessionEnd::AppExit, true, LONG, &mut attempt, true), AfterSession::Exit);
+        assert_eq!(after_session(SessionEnd::NoCompositor, false, SHORT, &mut attempt, true), AfterSession::Exit);
+    }
+
     #[test]
     fn nothing_to_carry_over_gives_up() {
         let mut attempt = 0;
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, false, SHORT, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, false, SHORT, &mut attempt, false),
             AfterSession::Exit
         );
         assert_eq!(
-            after_session(SessionEnd::NoCompositor, false, SHORT, &mut attempt),
+            after_session(SessionEnd::NoCompositor, false, SHORT, &mut attempt, false),
             AfterSession::Exit
         );
     }
@@ -6537,17 +6621,17 @@ mod reconnect_tests {
         let mut attempt = 0;
         for _ in 0..RECONNECT_ATTEMPTS {
             assert!(matches!(
-                after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+                after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt, false),
                 AfterSession::Reconnect(_)
             ));
         }
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt, false),
             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),
+            after_session(SessionEnd::ConnectionLost, true, RECONNECT_RESET + SHORT, &mut attempt, false),
             AfterSession::Reconnect(Duration::from_millis(200))
         );
         assert_eq!(attempt, 1);
@@ -6557,11 +6641,11 @@ mod reconnect_tests {
     fn backoff_caps_at_six_point_four_seconds() {
         let mut attempt = 6;
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt, false),
             AfterSession::Reconnect(Duration::from_millis(6400))
         );
         assert_eq!(
-            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
+            after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt, false),
             AfterSession::Reconnect(Duration::from_millis(6400))
         );
     }