git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commite713d2c7de44790dc78ba0af37fc81f02a0df536
parenteaf19d5b44
authorLucas Galante <[email protected]>
date2026-09-08 21:50
fix(xwayland): send the configure a restored window's mirror already claims

A saved-state restore pre-writes the wlr_xwayland_surface width/height to
the saved size so the first frame renders at it, but X itself is still at
the window's natural size. XwaylandWindow::configure deduplicated against
those same fields, so a window restored to exactly the geometry it was
then configured to — a fullscreen window at (0,0) is the common case —
looked already configured, the ConfigureWindow never went out, and the X
window stayed at e.g. 103x36 while the compositor reported Fullscreen.

Track what this compositor has actually sent, separately from what
wlroots reports: `sent_geom` on XwaylandWindow, written by the one
`send_configure` path every former wlr_xwayland_surface_configure call
now goes through, and a pure `needs_configure(wanted, reported, sent)`
that sends when the mirror disagrees OR the geometry has never been
sent. The mirror still catches X moving or resizing itself; the sent
record catches the mirror the restore faked.

Verified headless at scale 2 with an X11 GTK4 client that fullscreens
after restore: 26x36 before, 1280x720 after (640x360 for an
xwayland_hidpi_except window). Unit test added for the decision.

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

 src/server/window.rs          |   7 +++
 src/server/xwayland_window.rs | 114 ++++++++++++++++++++++++++++++++----------
 2 files changed, 94 insertions(+), 27 deletions(-)

diff --git a/src/server/window.rs b/src/server/window.rs
index 8431756..24eb570 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1121,6 +1121,13 @@ impl Window {
                     }
                 }
                 WindowImpl::Xwayland(xwindow) => {
+                    // This pre-writes the wlroots mirror so `render_finish`
+                    // reports the saved size from the first frame; X itself
+                    // is still at the window's natural size until the
+                    // arrange pass configures it. That configure must not
+                    // be deduplicated against this mirror — see
+                    // `xwayland_window::needs_configure`, which also checks
+                    // the geometry the compositor has actually sent.
                     if !xwindow.is_null() && !(*xwindow).xsurface.is_null() {
                         let s = crate::xwayland_window::x11_scale_for(self.server, (*xwindow).xsurface);
                         (*(*xwindow).xsurface).width = crate::xwayland_window::to_x11(saved.width as i32, s) as u16;
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 518161d..9fe29d9 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -28,6 +28,40 @@ pub struct XwaylandWindow {
 
     pub map: ffi::wl_listener,
     pub unmap: ffi::wl_listener,
+
+    /// The last geometry this compositor handed to X through
+    /// `send_configure`, physical pixels; `None` until the first one. See
+    /// `needs_configure` for why this is kept apart from the wlroots mirror.
+    pub sent_geom: Option<X11Geom>,
+}
+
+/// A window geometry in X11 root coordinates — physical pixels under
+/// `xwayland_hidpi` (see `x11_scale`), the same units as the
+/// `wlr_xwayland_surface` fields it is compared against.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub struct X11Geom {
+    pub x: i16,
+    pub y: i16,
+    pub width: u16,
+    pub height: u16,
+}
+
+/// Whether `wanted` has to be sent to X, given what wlroots reports the
+/// window's geometry to be (`reported`) and the last geometry this
+/// compositor sent (`sent`).
+///
+/// Comparing against the wlroots mirror alone was the bug: the saved-state
+/// restore (`Window::try_restore`) pre-writes the mirror's width/height to
+/// the saved size so the first frame renders at it, which makes the mirror
+/// a statement of what the compositor wants X to have, not what X has. A
+/// window saved fullscreen restored at exactly the fullscreen size then
+/// looked already-configured, the configure was skipped, and the real X
+/// window stayed at its natural size. So a geometry is also considered
+/// unsent until this compositor has actually sent it once; after that the
+/// mirror is what catches X changing the geometry on its own (a
+/// ConfigureNotify updates it), which the sent record cannot see.
+pub fn needs_configure(wanted: X11Geom, reported: X11Geom, sent: Option<X11Geom>) -> bool {
+    wanted != reported || sent != Some(wanted)
 }
 
 unsafe fn connect_listener(
@@ -234,6 +268,7 @@ impl XwaylandWindow {
             request_minimize: std::mem::zeroed(),
             map: std::mem::zeroed(),
             unmap: std::mem::zeroed(),
+            sent_geom: None,
         });
 
         let raw = Box::into_raw(xwindow);
@@ -309,18 +344,9 @@ impl XwaylandWindow {
             phys_y -= to_x11(WINE_MARGIN, s) as i16;
         }
 
-        if phys_x != (*self.xsurface).x
-            || phys_y != (*self.xsurface).y
-            || phys_width != (*self.xsurface).width
-            || phys_height != (*self.xsurface).height
-        {
-            ffi::wlr_xwayland_surface_configure(
-                self.xsurface,
-                phys_x,
-                phys_y,
-                phys_width,
-                phys_height,
-            );
+        let wanted = X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height };
+        if needs_configure(wanted, self.reported_geom(), self.sent_geom) {
+            self.send_configure(wanted);
         }
 
         if scheduled.activated != sent.activated {
@@ -354,6 +380,24 @@ impl XwaylandWindow {
         false
     }
 
+    /// The geometry wlroots currently reports for the X window.
+    pub unsafe fn reported_geom(&self) -> X11Geom {
+        X11Geom {
+            x: (*self.xsurface).x,
+            y: (*self.xsurface).y,
+            width: (*self.xsurface).width,
+            height: (*self.xsurface).height,
+        }
+    }
+
+    /// The one path to `wlr_xwayland_surface_configure`: every geometry
+    /// handed to X is recorded in `sent_geom` so `needs_configure` can tell
+    /// a geometry X has from one the compositor merely mirrored.
+    pub unsafe fn send_configure(&mut self, g: X11Geom) {
+        ffi::wlr_xwayland_surface_configure(self.xsurface, g.x, g.y, g.width, g.height);
+        self.sent_geom = Some(g);
+    }
+
     pub unsafe fn set_activated(&self, activated: bool) {
         if activated && (*self.xsurface).minimized {
             ffi::wlr_xwayland_surface_set_minimized(self.xsurface, false);
@@ -494,7 +538,12 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
 
     let surface = (*(*xwindow).xsurface).surface;
     if surface.is_null() || !ffi::river_wlr_surface_is_mapped(surface) {
-        ffi::wlr_xwayland_surface_configure((*xwindow).xsurface, (*event).x, (*event).y, (*event).width, (*event).height);
+        (*xwindow).send_configure(X11Geom {
+            x: (*event).x,
+            y: (*event).y,
+            width: (*event).width,
+            height: (*event).height,
+        });
         return;
     }
 
@@ -518,13 +567,12 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
     );
 
     if has_parent {
-        ffi::wlr_xwayland_surface_configure(
-            (*xwindow).xsurface,
-            (*event).x,
-            (*event).y,
-            (*event).width,
-            (*event).height,
-        );
+        (*xwindow).send_configure(X11Geom {
+            x: (*event).x,
+            y: (*event).y,
+            width: (*event).width,
+            height: (*event).height,
+        });
         let log_x = from_x11((*event).x as i32, s);
         let log_y = from_x11((*event).y as i32, s);
         let log_width = from_x11((*event).width as i32, s) as u32;
@@ -584,13 +632,7 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         phys_y -= to_x11(WINE_MARGIN, s) as i16;
     }
 
-    ffi::wlr_xwayland_surface_configure(
-        (*xwindow).xsurface,
-        phys_x,
-        phys_y,
-        phys_width,
-        phys_height,
-    );
+    (*xwindow).send_configure(X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height });
     let mut log_width = from_x11(phys_width as i32, s) as u32;
     let mut log_height = from_x11(phys_height as i32, s) as u32;
     if is_wine && !has_parent && !is_fullscreen {
@@ -778,6 +820,24 @@ mod tests {
         assert_eq!(to_x11(logical, resolve_x11_scale(Some(2.0), &last)), 3712);
     }
 
+    #[test]
+    fn configure_is_sent_until_it_has_actually_been_sent_once() {
+        let g = |x, y, w, h| X11Geom { x, y, width: w, height: h };
+        let fullscreen = g(0, 0, 1280, 720);
+        // The restore case: the mirror already says 1280x720 (pre-written by
+        // try_restore) but nothing was ever sent — X is at its natural size.
+        assert!(needs_configure(fullscreen, fullscreen, None));
+        // Something else was sent (the client's own pre-map request).
+        assert!(needs_configure(fullscreen, fullscreen, Some(g(0, 0, 103, 36))));
+        // Sent once and X reports it: nothing to do.
+        assert!(!needs_configure(fullscreen, fullscreen, Some(fullscreen)));
+        // X moved or resized itself since: the mirror disagrees, resend.
+        assert!(needs_configure(fullscreen, g(10, 10, 1280, 720), Some(fullscreen)));
+        assert!(needs_configure(fullscreen, g(0, 0, 640, 360), Some(fullscreen)));
+        // A different wanted geometry always goes out.
+        assert!(needs_configure(g(0, 0, 640, 360), fullscreen, Some(fullscreen)));
+    }
+
     #[test]
     fn output_change_grace_begins_and_is_re_armed() {
         note_output_change();