Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix: don't give focus to a client that only reappeared after crashing
cce-calendar would grab focus at random. The cause was not in the focus
code: its Wayland connection broke mid-session, cce-ui rebuilt the
surface on a fresh connection 200ms later (window_runner::run is built
to survive exactly that), and the compositor focused the new window on
map. From the gate's point of view a window had appeared, which is
indistinguishable from an app the user just launched.
Every branch of the focus-on-map gate keyed on *startup* restore —
has_restored_focused_window, restored_focused_window_mapped,
startup_input_seen. Once the session settles they all fall through and
anything that maps takes focus, so a crash-and-reconnect nineteen hours
in walked straight past it.
So track windows that go away without the compositor ever asking them to
close, and give one re-map by that app_id within 5s a pass on focus. The
discriminator is deliberately the unrequested vanish, NOT a match against
saved state: a mid-session spawn borrows geometry from
last_window_states too, and must keep focusing and spawn-panning
normally — the trap the surrounding comments already warn about. A
`close_requested` flag now records the compositor asking; the existing
`closed` field could not serve, as nothing ever set it.
The record is consumed on use, so a client that crashes twice gets two
separate passes rather than a standing exemption, and the grace is kept
short so a deliberate close-then-relaunch is not mistaken for a crash.
Verified in a shadow session, killing a client to produce the same
unrequested unmap: focus stayed on the window in use and the block was
logged. Both controls pass — a brand-new app still takes focus, and so
does the same app respawned once the grace has expired.
The underlying connection loss is fd exhaustion from dmabuf feedback,
fixed on the client side in cce-ui@2416904.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/server/window.rs | 30 ++++++++++++++++++++++++++++++
src/server/window_manager.rs | 37 +++++++++++++++++++++++++++++++++++++
2 files changed, 67 insertions(+)
diff --git a/src/server/window.rs b/src/server/window.rs
index 07f1cb7..d1d4328 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -404,6 +404,9 @@ pub struct Window {
pub session_restored: bool,
pub restored_focused: bool,
pub closed: bool,
+ /// Set when the compositor asks this window to close, so `unmap` can tell
+ /// a departure someone requested from a client that simply vanished.
+ pub close_requested: bool,
pub has_parent: bool,
pub minimized: bool,
/// While Some, the window is mid fullscreen-toggle: its on-screen rect is
@@ -664,6 +667,7 @@ impl Window {
session_restored: false,
restored_focused: false,
closed: false,
+ close_requested: false,
has_parent: false,
minimized: false,
fs_anim: None,
@@ -1412,6 +1416,23 @@ impl Window {
}
}
+ // A client whose connection broke rebuilds its surface from
+ // scratch (cce-ui window_runner::run) and maps again seconds
+ // later. The user never asked for that window, so it must not
+ // take focus from whatever they moved on to.
+ //
+ // Keyed on the previous window vanishing WITHOUT a requested
+ // close — not on matching saved state, which a mid-session spawn
+ // does too and which must still focus and spawn-pan normally.
+ if should_focus {
+ if let Some(app_id) = self.get_app_id_string() {
+ if (*self.server).wm.take_recent_vanish(&app_id) {
+ log::info!("[FocusRestore] Blocking focus steal by reconnecting client {:?} ({})", self.get_title(), app_id);
+ should_focus = false;
+ }
+ }
+ }
+
// A WORLD window spawning during overview pulls the session
// out of it, landing at zoom 1 on the new window — the user
// asked for it (launcher pick, spawn keybind). Chrome
@@ -1475,6 +1496,14 @@ impl Window {
if self.state != WindowState::Mapped {
return;
}
+ // Nobody asked this window to go: either its program exited on its
+ // own or — the case this feeds — its Wayland connection broke and
+ // cce-ui is about to rebuild the surface on a fresh one.
+ if !self.close_requested {
+ if let Some(app_id) = self.get_app_id_string() {
+ (*self.server).wm.note_vanished(app_id);
+ }
+ }
wl_listener_remove_safe(&mut self.commit);
self.surfaces.save();
assert!(!matches!(self.impl_type, WindowImpl::Destroying));
@@ -1494,6 +1523,7 @@ impl Window {
}
pub unsafe fn close(&mut self) {
+ self.close_requested = true;
match self.impl_type {
WindowImpl::Toplevel(toplevel) => {
if !toplevel.is_null() {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 67e62bd..e9e103b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -169,6 +169,11 @@ pub struct WindowManager {
/// windows that map unbidden (autostarts like keepassxc) must not steal
/// focus from the restored session's focused window.
pub startup_input_seen: bool,
+ /// app_ids whose window went away without the compositor ever asking it
+ /// to close, and when. A client that loses its Wayland connection lands
+ /// here and reappears a moment later having rebuilt its surface; see
+ /// `take_recent_vanish`.
+ pub vanished_windows: Vec<(String, std::time::Instant)>,
pub last_viewport_zoom: f64,
pub last_viewport_pan_x: f64,
pub last_viewport_pan_y: f64,
@@ -221,6 +226,13 @@ pub(crate) fn manage_debug() -> bool {
*FLAG.get_or_init(|| std::env::var_os("CCE_MANAGE_DEBUG").is_some())
}
+/// How long after a window vanishes unbidden a re-map by the same app_id
+/// still counts as that client reconnecting rather than a fresh launch.
+/// cce-ui retries 200ms after losing its connection and backs off from there,
+/// so a few seconds covers the early attempts; keeping it short is what stops
+/// a deliberate close-then-relaunch from being mistaken for one.
+const RECONNECT_FOCUS_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
+
/// Does a `rounded_apps` / `bevel_apps` config pattern match this app_id?
///
/// Case-insensitive, and a pattern containing `*` is a glob (`*` stands for any
@@ -347,6 +359,7 @@ impl WindowManager {
self.restore_placeholders = Vec::new();
self.restore_placeholder_timer = std::ptr::null_mut();
self.startup_input_seen = false;
+ self.vanished_windows = Vec::new();
self.mode_rules = Vec::new();
self.keybinds = Vec::new();
self.pointer_binds = Vec::new();
@@ -2890,6 +2903,30 @@ impl WindowManager {
self.focus_history.retain(|&w| w != window);
}
+ /// Record that this app_id's window disappeared unbidden.
+ pub fn note_vanished(&mut self, app_id: String) {
+ let now = std::time::Instant::now();
+ self.vanished_windows
+ .retain(|(_, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
+ self.vanished_windows.push((app_id, now));
+ }
+
+ /// Whether this app_id vanished unbidden within the grace, consuming the
+ /// record so one disappearance excuses exactly one re-map — a client that
+ /// crashes twice does not get a standing exemption.
+ pub fn take_recent_vanish(&mut self, app_id: &str) -> bool {
+ let now = std::time::Instant::now();
+ self.vanished_windows
+ .retain(|(_, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
+ match self.vanished_windows.iter().position(|(id, _)| id == app_id) {
+ Some(i) => {
+ self.vanished_windows.remove(i);
+ true
+ }
+ None => false,
+ }
+ }
+
/// Refocus after the focused window goes away, by the policy crate's
/// next-visible rule (most recent eligible history entry, else the last
/// eligible window in window order, else clear focus). This side owns