GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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))
);
}