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

commitf1c1fbcf5c841a9ce55164759e0f44510e5e54e0
parentbb74f3717a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 09:54
perf: cheaper zoom flights and transactions; a refused commit is retried

Measured in a scale-2 shadow (installed build -> this one): 6 overview
flights with four windows spread over the desk, compositor CPU 440-540 ms
-> 180-200 ms; window modes and the end-of-flight frame identical; idle
still 0 context switches.

- draw_grid: a forced rebuild (every frame of a zoom flight) resized,
  recoloured and moved every pooled cell rect and rim, and each scenefx
  setter on a live node re-walked the scene for the region it touched.
  The grid tree is disabled around the rebuild -- scene_node_update
  returns at once under a disabled ancestor -- and enabled after the
  cell labels: two walks for the whole rebuild.
- render_and_commit: a refused commit (the panel's EBUSY bursts, 38,784
  in one session) left its damage pending with nothing scheduling
  another frame, so the screen stayed stale until something else moved.
  It schedules the next frame; the error is logged once per 10 s with a
  count. The cause of the EBUSY itself (i915 refusing a commit while the
  previous one finishes, by the look of it) is not found.
- The arrange snapshot read each window's app_id and title through
  allocating getters four times and scanned the mode rules twice; it
  reads them borrowed once (Window::app_id_str/title_str, rule_for,
  mode_for_window). The per-commit handler borrows the app_id too.
- x11_scale_for, every frame for every X11 window, borrows WM_CLASS
  class, instance and title instead of copying them.
- scenefx: the software-cursor damage history counts only the 8 newest
  footprints on the output being rendered (a swapchain holds at most 4
  buffers), not all 16 forever across every output.

Not changed: the schedule_frame after every transaction
(commit_output_state, from the river port) -- a transaction that damages
nothing is rare, and the frame also delivers frame callbacks, grid
patches and fades that a skipped frame could stall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                       | 11 +++++++++++
 scenefx/types/scene/wlr_scene.c | 17 +++++++++++++++--
 src/server/output.rs            | 42 ++++++++++++++++++++++++++++++++++++++++-
 src/server/window.rs            | 14 ++++++++++++++
 src/server/window_manager.rs    | 41 ++++++++++++++++++++++++++++------------
 src/server/xdg_toplevel.rs      |  3 ++-
 src/server/xwayland_window.rs   |  6 ++++--
 7 files changed, 116 insertions(+), 18 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a28531c0..2aa8befe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1093,6 +1093,17 @@ headless seat has no keyboard and Chromium crashes in
   unchanged check compares compact JSON, and a write goes to
   `state.json.tmp` and is renamed over, so a crash cannot truncate it.
 
+  **A forced grid rebuild runs with the grid tree disabled** (`draw_grid`,
+  since 2026-10-06): every frame of a zoom flight resizes and moves every
+  pooled cell rect and rim, and a scene setter on a live node re-walks the
+  scene for what it touched, while under a disabled ancestor it returns at
+  once — so the tree goes off around the rebuild and on after (6 overview
+  flights in a shadow: 440-540 ms of compositor CPU -> 180-200 ms). **A
+  refused commit schedules the next frame** (`render_and_commit`): the
+  damage stays pending, but nothing else asked for a frame, so the EBUSY
+  bursts the panel's commits hit left the screen stale until something
+  else moved; the error is logged once per 10 s with a count.
+
   **Per-frame work is gated too.** The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
   before the file is even looked for. The window-stream tick runs only while
   the stream hub has subscribers (the accept thread's eventfd arms it), and a
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index fb97ec5f..f75672f4 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -46,9 +46,18 @@ static bool cce_scene_blur_debug(void);
 #define HIGHLIGHT_DAMAGE_FADEOUT_TIME   250
 
 #define CURSOR_HISTORY_SIZE 16
+// Only this many of the most recent footprints are damage: a stale cursor
+// can only be baked into a buffer the swapchain still hands back, and a
+// wlroots swapchain holds at most WLR_SWAPCHAIN_CAP (4) — so twice that is
+// a margin, not a guess. Until 2026-10-06 all 16 counted, forever: the
+// trail of a moving cursor was re-rendered (and re-blurred) every frame, and
+// after it stopped, every unrelated frame still pulled in the old trail.
+#define CURSOR_HISTORY_DAMAGE 8
 static struct {
 	int x, y, w, h;
 	bool valid;
+	// The footprint is in this output's coordinates.
+	struct wlr_output *output;
 } g_cursor_history[CURSOR_HISTORY_SIZE];
 static int g_cursor_history_index = 0;
 
@@ -4422,14 +4431,18 @@ bool wlr_scene_output_build_state(struct wlr_scene_output *scene_output,
 				g_cursor_history[g_cursor_history_index].w = cursor->width;
 				g_cursor_history[g_cursor_history_index].h = cursor->height;
 				g_cursor_history[g_cursor_history_index].valid = true;
+				g_cursor_history[g_cursor_history_index].output = output;
 				g_cursor_history_index =
 					(g_cursor_history_index + 1) % CURSOR_HISTORY_SIZE;
 			}
 
-			for (int i = 0; i < CURSOR_HISTORY_SIZE; i++) {
-				if (!g_cursor_history[i].valid) {
+			// Newest first, this output's only, the most recent few.
+			for (int k = 1, taken = 0; k <= CURSOR_HISTORY_SIZE && taken < CURSOR_HISTORY_DAMAGE; k++) {
+				int i = (g_cursor_history_index - k + CURSOR_HISTORY_SIZE) % CURSOR_HISTORY_SIZE;
+				if (!g_cursor_history[i].valid || g_cursor_history[i].output != output) {
 					continue;
 				}
+				taken++;
 				pixman_region32_union_rect(&render_data.damage, &render_data.damage,
 						g_cursor_history[i].x - 16,
 						g_cursor_history[i].y - 16,
diff --git a/src/server/output.rs b/src/server/output.rs
index 86aaea12..00790384 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -718,6 +718,12 @@ impl Output {
 
         if !ffi::wlr_output_commit_state(self.wlr_output, &state) {
             ffi::wlr_output_state_finish(&mut state);
+            // The damage this frame carried is still pending (only a
+            // successful commit clears it), but nothing else asks for
+            // another frame: a refused commit (the panel's EBUSY bursts)
+            // left the screen stale until something unrelated moved. Retry
+            // at the next vblank.
+            ffi::wlr_output_schedule_frame(self.wlr_output);
             return Err("Failed to commit state");
         }
 
@@ -1042,6 +1048,17 @@ impl Output {
             rect
         };
 
+        // A forced rebuild resizes, recolours and moves every pooled cell
+        // rect and rim — hundreds at overview zoom, every frame of a zoom
+        // flight. Each setter on a live node re-walks the scene for the
+        // region it touched; under a disabled ancestor it returns at once
+        // (scene_node_update). So the tree is off while the pools are
+        // redrawn and on again after: two walks for the whole rebuild.
+        let suspended = force && !grid_tree.is_null();
+        if suspended {
+            ffi::wlr_scene_node_set_enabled(grid_tree as *mut ffi::wlr_scene_node, false);
+        }
+
         match &spec {
             crate::policy::api::BackgroundSpec::Grid(grid) => {
                 let frame = crate::policy::background::grid_frame(
@@ -1164,6 +1181,9 @@ impl Output {
         }
 
         self.draw_cell_labels();
+        if suspended {
+            ffi::wlr_scene_node_set_enabled(grid_tree as *mut ffi::wlr_scene_node, true);
+        }
     }
 
     /// Name every visible desktop square, chess style, while overview is open.
@@ -1430,6 +1450,26 @@ impl Output {
     }
 }
 
+/// Log a failed frame, at most once per 10 s with a count: a refused commit
+/// can repeat at frame rate for minutes (38,784 lines in one session), and
+/// wlroots logs each one itself as well.
+fn log_render_error(e: &str) {
+    use std::sync::atomic::{AtomicU64, Ordering};
+    use std::sync::Mutex;
+    static SUPPRESSED: AtomicU64 = AtomicU64::new(0);
+    static LAST: Mutex<Option<std::time::Instant>> = Mutex::new(None);
+    let mut last = LAST.lock().unwrap();
+    if last.is_some_and(|t| t.elapsed().as_secs() < 10) {
+        SUPPRESSED.fetch_add(1, Ordering::Relaxed);
+        return;
+    }
+    *last = Some(std::time::Instant::now());
+    match SUPPRESSED.swap(0, Ordering::Relaxed) {
+        0 => log::error!("{}", e),
+        n => log::error!("{} (and {} more in the last 10 s)", e, n),
+    }
+}
+
 unsafe extern "C" fn handle_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let output = &mut *crate::container_of!(listener, Output, frame);
     // The camera steps here, on the vblank, to where it should be at the
@@ -1445,7 +1485,7 @@ unsafe extern "C" fn handle_frame(listener: *mut ffi::wl_listener, _data: *mut s
         None
     };
     if let Err(e) = output.render_and_commit() {
-        log::error!("{}", e);
+        log_render_error(e);
     }
     if let Some(start) = render_start {
         // Epoch ms mod 100000 — the shared tracer time base (see cce-ui's
diff --git a/src/server/window.rs b/src/server/window.rs
index 8a45ad9c..27dc7d69 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1095,6 +1095,20 @@ impl Window {
         }
     }
 
+    /// The app_id borrowed, for hot paths that only compare it (the per-
+    /// commit and per-transaction passes): `get_app_id_string` allocates.
+    /// `None` when absent or not UTF-8.
+    pub unsafe fn app_id_str(&self) -> Option<&str> {
+        let ptr = self.get_app_id();
+        if ptr.is_null() { None } else { std::ffi::CStr::from_ptr(ptr).to_str().ok() }
+    }
+
+    /// The title borrowed — see `app_id_str`.
+    pub unsafe fn title_str(&self) -> Option<&str> {
+        let ptr = self.get_title();
+        if ptr.is_null() { None } else { std::ffi::CStr::from_ptr(ptr).to_str().ok() }
+    }
+
     pub unsafe fn get_app_id_string(&self) -> Option<String> {
         let ptr = self.get_app_id();
         if ptr.is_null() {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index fc5ab1b9..e3dc4358 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -3277,19 +3277,32 @@ impl WindowManager {
     pub fn sent_outputs_compat(&self) {}
 
     pub unsafe fn get_rule_for_window(&self, win: *mut Window) -> Option<&crate::config::ModeRule> {
-        let app_id = (*win).get_app_id_string();
-        let title = (*win).get_title_string();
-        self.mode_rules
-            .iter()
-            .find(|rule| mode_rule_matches(rule, app_id.as_deref(), title.as_deref()))
+        self.rule_for((*win).app_id_str(), (*win).title_str())
+    }
+
+    /// The first mode rule matching this app_id and title. Borrowed: the
+    /// arrange snapshot asks once per window per transaction — once a vblank
+    /// during a drag — and used to allocate both strings, twice.
+    fn rule_for(&self, app_id: Option<&str>, title: Option<&str>) -> Option<&crate::config::ModeRule> {
+        self.mode_rules.iter().find(|rule| mode_rule_matches(rule, app_id, title))
     }
 
     pub unsafe fn get_mode_for_window(&self, win: *mut Window) -> crate::tiling::TilingMode {
+        self.mode_for_window(win, (*win).app_id_str(), || self.get_rule_for_window(win))
+    }
+
+    /// `get_mode_for_window` with the app_id already in hand and the mode
+    /// rule asked for only if it is needed.
+    unsafe fn mode_for_window<'a>(
+        &'a self,
+        win: *mut Window,
+        app_id: Option<&str>,
+        rule: impl FnOnce() -> Option<&'a crate::config::ModeRule>,
+    ) -> crate::tiling::TilingMode {
         if (*win).is_status_bar() {
             return crate::tiling::TilingMode::Status;
         }
-        let app_id = (*win).get_app_id_string();
-        if app_id.as_deref() == Some("cce-notifier") || app_id.as_deref() == Some("cce-notification-daemon") || app_id.as_deref() == Some("clear-notification-daemon") {
+        if app_id == Some("cce-notifier") || app_id == Some("cce-notification-daemon") || app_id == Some("clear-notification-daemon") {
             return crate::tiling::TilingMode::Popup;
         }
         // An explicit set_popup via the cce window-management protocol beats the
@@ -3299,7 +3312,7 @@ impl WindowManager {
         if (*win).tiling_mode == crate::tiling::TilingMode::Popup {
             return crate::tiling::TilingMode::Popup;
         }
-        if app_id.as_deref().map_or(false, |id| id.starts_with("cce-cloud")) {
+        if app_id.map_or(false, |id| id.starts_with("cce-cloud")) {
             return crate::tiling::TilingMode::Overlay;
         }
 
@@ -3313,7 +3326,7 @@ impl WindowManager {
             return crate::tiling::TilingMode::Floating;
         }
 
-        if let Some(rule) = self.get_rule_for_window(win) {
+        if let Some(rule) = rule() {
             return rule.mode;
         }
 
@@ -3977,8 +3990,12 @@ impl WindowManager {
             if win_ptr.is_null() || (*win_ptr).closed {
                 continue;
             }
+            // Read once per window: the rule lookup, the status test and the
+            // mode below all need them (they each re-read and allocated).
+            let app_id = (*win_ptr).app_id_str();
+            let rule = self.rule_for(app_id, (*win_ptr).title_str());
             let rule_ssd = if !(*win_ptr).mode_locked {
-                self.get_rule_for_window(win_ptr).and_then(|rule| rule.ssd)
+                rule.and_then(|rule| rule.ssd)
             } else {
                 None
             };
@@ -3987,7 +4004,7 @@ impl WindowManager {
             // surface is thicker than the bar) keep the frozen value and
             // raise the segment above its siblings and the windows the open
             // menu now overlaps.
-            if (*win_ptr).get_app_id_string().map_or(false, |id| id.starts_with("cce-status")) {
+            if app_id.map_or(false, |id| id.starts_with("cce-status")) {
                 let bg = (*win_ptr).box_geom;
                 let (len, thickness) = match (*win_ptr).status_edge {
                     crate::policy::arrange::StatusEdge::Left
@@ -4008,7 +4025,7 @@ impl WindowManager {
                 role: (*win_ptr).role(),
                 minimized: (*win_ptr).minimized,
                 closing_or_init: matches!((*win_ptr).state, crate::window::WindowState::Closing | crate::window::WindowState::Init),
-                mode: self.get_mode_for_window(win_ptr),
+                mode: self.mode_for_window(win_ptr, app_id, || rule),
                 status_collapsed_len: (*win_ptr).status_collapsed_len,
                 rule_ssd,
                 being_moved: self.is_window_being_moved(win_ptr),
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index bf3cde55..3ffcf3bd 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -681,7 +681,8 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
         }
     }
 
-    let app_id = (*window).get_app_id_string().unwrap_or_default();
+    // Borrowed: this runs on every commit of every client.
+    let app_id = (*window).app_id_str().unwrap_or_default();
     let mut ignore_transparent = (*(*window).server).wm.layout.window_backdrop_blur_ignore_transparent;
     if app_id.starts_with("cce-status") {
         ignore_transparent = (*(*window).server).wm.layout.status_backdrop_blur_ignore_transparent;
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 0eacd480..de512171 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -218,8 +218,10 @@ pub unsafe fn x11_scale_for(
         return 1.0;
     }
     if !xsurface.is_null() && !(*server).wm.xwayland_hidpi_except.is_empty() {
-        let text = |p: *const libc::c_char| -> String {
-            if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
+        // Borrowed (no copy for valid UTF-8): this runs every frame for
+        // every X11 window, and allocated all three fields each time.
+        let text = |p: *const libc::c_char| -> std::borrow::Cow<'_, str> {
+            if p.is_null() { std::borrow::Cow::Borrowed("") } else { std::ffi::CStr::from_ptr(p).to_string_lossy() }
         };
         let class = text((*xsurface).class);
         let instance = text((*xsurface).instance);