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