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

commit73965b6a63797f6e4ed8bee449eadfb7bf946053
parent8a165541e9
authorLucas Galante <[email protected]>
date2026-09-22 14:36
fix(window): a restored window keeps its size until the client answers

Chrome came back a whole grid cell wider and taller on every login, and
the growth compounded: 3x4 cells became 4x5, then 5x6.

render_start reset the window's scheduled size from the client's
committed xdg geometry on every render pass, for every mode. Before a
client's first ack that geometry is not a response to anything — it is
the size the client asked for on its own. Chromium restores its
remembered bounds with set_window_geometry before it ever acks, and
those bounds are its window PLUS its CSD shadow insets (live: 1436x1118
for a 1404x1076 window), so they always overhang the cell block the
restore just gave it. That wish became box_geom via render_finish, the
next Tiled arrange covered every cell the overhang touched
(snap::tiled_span floors the low edge and CEILS the high one), and
save_state persisted the grown block — which the next session's insets
then overhung in turn. A one-way ratchet with the save/restore loop as
its escapement.

So adopt the client's geometry only once it has ANSWERED a configure
(XdgToplevel::acked_once, set on any ack). A window with no restored
size still seeds its block from the client's first wish, which is where
a freshly launched app's size comes from, and a client that refuses a
too-small restore is still absorbed — one round trip later, after its
ack.

Verified in a shadow against a real Chrome: a restore of 932x260 held
across three logout/login cycles (before: 500x260 -> 932x260 ->
1404x804), a fresh launch still sized itself 1404x804, and a 460x260
restore — under Chrome's 500px minimum — settled at 500x260 without
flapping.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/server/window.rs       | 24 ++++++++++++++++++++++--
 src/server/xdg_toplevel.rs | 11 +++++++++++
 2 files changed, 33 insertions(+), 2 deletions(-)

diff --git a/src/server/window.rs b/src/server/window.rs
index 1ab5941..004f577 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -2891,8 +2891,28 @@ impl Window {
                         }
                         _ => {}
                     }
-                    self.rendering_scheduled.width = (*toplevel).geometry.width as u32;
-                    self.rendering_scheduled.height = (*toplevel).geometry.height as u32;
+                    // The client's committed geometry is the authority on
+                    // this window's size — but only once the client has
+                    // ANSWERED a configure. Before its first ack, `geometry`
+                    // holds the size the client asked for on its own:
+                    // Chromium restores its remembered bounds with
+                    // `set_window_geometry` before it ever acks, and those
+                    // bounds are its window PLUS its CSD shadow insets, so
+                    // they always overhang the cell block the restore just
+                    // gave it. Adopting that wish made it `box_geom` (see
+                    // `render_finish`), the next Tiled arrange covered every
+                    // cell the overhang touched (`snap::tiled_span` floors the
+                    // low edge and CEILS the high one), the grown size was
+                    // saved, and Chrome came back a whole cell wider and
+                    // taller on every login — a one-way ratchet, since each
+                    // session's insets sit on top of the last session's block.
+                    // A window with no restored size still seeds its block
+                    // from the client's first wish, which is where a freshly
+                    // launched app's size comes from.
+                    if !self.restored || (*toplevel).acked_once {
+                        self.rendering_scheduled.width = (*toplevel).geometry.width as u32;
+                        self.rendering_scheduled.height = (*toplevel).geometry.height as u32;
+                    }
                 }
             }
             WindowImpl::Xwayland(xwindow) => {
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index dbeabdf..0d84279 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -24,6 +24,11 @@ pub struct XdgToplevel {
     /// change detector for the deferred pointer refresh (see `handle_commit`).
     pub last_surface_size: (i32, i32),
     pub configure_state: ConfigureState,
+    /// Has the client ever answered a configure? Until it has,
+    /// `geometry` is the size the CLIENT asked for, not a response to one
+    /// — see `Window::render_start`, which refuses to adopt a wish over a
+    /// restored size.
+    pub acked_once: bool,
 
     pub destroy: ffi::wl_listener,
     pub ack_configure: ffi::wl_listener,
@@ -64,6 +69,7 @@ impl XdgToplevel {
             geometry: std::mem::zeroed(),
             last_surface_size: (0, 0),
             configure_state: ConfigureState::Idle,
+            acked_once: false,
 
             destroy: std::mem::zeroed(),
             ack_configure: std::mem::zeroed(),
@@ -532,6 +538,11 @@ unsafe extern "C" fn handle_ack_configure(
     let acked_configure = data as *mut ffi::wlr_xdg_surface_configure;
     let serial = (*acked_configure).serial;
 
+    // Any ack, matching serial or not, proves the client is answering
+    // configures: from here its geometry is a response, and `render_start`
+    // may adopt it again.
+    (*toplevel).acked_once = true;
+
     match (*toplevel).configure_state {
         ConfigureState::Inflight(s) => {
             if serial == s {