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

commit2967402670a9817c1fcc4362f8e94698316084ae
parent0bd052f5f7
authorLucas Galante <[email protected]>
date2026-09-10 13:22
perf: gate per-frame work — backdrop measurement, ovdbg probe, stream tick, tearing test, label and role allocations

Second pass after 0bd052f, on the work that ran on every rendered frame
whether or not anything it depended on had changed:

- Status-backdrop measurement ran per vblank ahead of the needs-frame gate:
  two Vecs, a String per segment, an O(segments × windows) scan and a full
  `update_status` rebuild-and-compare, every frame anything animated. It now
  runs when the window manager's new `layout_epoch` (bumped per transaction)
  or the camera changed, or 250 ms passed (the readback throttle inside it).
- `/tmp/cce-ovdbg` was open()+read() on every rendered frame of a release
  build. It is now behind `CCE_OVDBG=1`; the live toggle still works once
  the env is set.
- The window-stream timer re-armed at 200-500 ms forever with no
  subscribers. The stream hub now carries an eventfd the accept thread bumps
  when a subscriber joins; that fd is an event source which arms the 33 ms
  tick, and the tick lets itself lapse when the list is empty.
- The tearing page-flip test (an atomic TEST_ONLY commit) is no longer
  repeated every frame after it has failed for the current tearing episode.
- `Window::role()` and the `is_status_bar`/`is_grid`/`is_wallpaper` wrappers
  borrowed a String per call, several times per pointer-motion event and
  per window per transaction; they borrow the CStr now.
  `keep_status_bar_on_top` uses the wrapper instead of its own allocation.
- Overview cell labels built a label String (three allocations) per visible
  cell per frame and cloned it again as the cache key; `LabelCache::get_square`
  keys on (col, row, px) and formats only on a miss.

Verified in a headless shadow: a `backdrop` subscriber still receives its
reading, a `window focused` stream subscriber receives frames (3.4 MB in
3 s) and the main thread drops to single-digit wakeups/s once it leaves,
overview renders its A1..D4 labels, 0 transactions while idle with clock
and cpu segments running.

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

 CLAUDE.md                    | 11 ++++++
 src/server/output.rs         | 85 ++++++++++++++++++++++++++++++++++++--------
 src/server/run_server.rs     |  2 +-
 src/server/stream_server.rs  |  9 ++++-
 src/server/text.rs           | 23 +++++++++---
 src/server/window.rs         |  8 ++++-
 src/server/window_manager.rs | 67 +++++++++++++++++++++++++++++-----
 7 files changed, 174 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e17b04e..b3224be 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -520,6 +520,17 @@ grid has them.
   on every transaction: `save_state` reads `/proc` for every window, and a
   drag is one transaction per pointer event.
 
+  **Per-frame work is gated too.** `Output::render_and_commit` measures the
+  status backdrops only when the window manager's `layout_epoch` (bumped per
+  transaction) or the camera moved, or 250 ms passed — not every vblank.
+  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
+  failed tearing test is not repeated every frame of the same fullscreen
+  episode. `Window::role()` and its `is_status_bar`/`is_grid`/`is_wallpaper`
+  wrappers borrow the app id rather than allocating; keep it that way, they
+  run several times per pointer-motion event.
+
   **`backdrop` is the one per-subscriber topic** — it names the asking segment,
   because the whole point is that the two ends of a bar sit over different things.
   Lines are `<luma> <spread>` (0-100 each) or `unknown`. It answers a question a
diff --git a/src/server/output.rs b/src/server/output.rs
index d1ed77b..344fb03 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -186,6 +186,17 @@ pub struct Output {
     pub last_rendered_pan_x: f64,
     pub last_rendered_pan_y: f64,
     pub last_rendered_zoom: f64,
+    /// What the status-backdrop measurement last ran against: the window
+    /// manager's layout epoch, the camera, and when. It re-runs only when one
+    /// of those moved or `BACKDROP_REFRESH` has passed (for window content
+    /// under a segment), not on every vblank.
+    pub backdrop_epoch: u64,
+    pub backdrop_cam: (f64, f64, f64),
+    pub backdrop_measured_at: Option<std::time::Instant>,
+    /// A tearing page-flip test failed for the current tearing episode; do
+    /// not repeat the atomic TEST_ONLY commit every frame. Cleared when the
+    /// fullscreen client's tearing request goes away.
+    pub tearing_test_failed: bool,
     pub last_grid_viewport_w: i32,
     pub last_grid_viewport_h: i32,
     pub last_grid_zoom: f64,
@@ -511,6 +522,10 @@ impl Output {
             last_rendered_pan_x: f64::NAN,
             last_rendered_pan_y: f64::NAN,
             last_rendered_zoom: f64::NAN,
+            backdrop_epoch: u64::MAX,
+            backdrop_cam: (f64::NAN, f64::NAN, f64::NAN),
+            backdrop_measured_at: None,
+            tearing_test_failed: false,
             last_grid_viewport_w: 0,
             last_grid_viewport_h: 0,
             last_grid_zoom: 0.0,
@@ -582,8 +597,29 @@ impl Output {
         // defeating the per-(window, region) readback cache — a GPU texture
         // readback inside the render path, per pan frame. The settle's full
         // repaint frame runs the measurement with the final camera.
-        if !(*self.server).wm.viewport_is_active {
-            self.measure_status_backdrops();
+        //
+        // And not on every frame either: the desktop half of the reading is
+        // pure geometry, which only moves with a transaction (`layout_epoch`)
+        // or the camera; the window-content half is throttled inside
+        // `window_backdrop_sample` at `BACKDROP_REFRESH` anyway. Running the
+        // walk — Vecs, a String per segment, the O(segments × windows) scan,
+        // then `update_status` rebuilding and comparing the whole status
+        // snapshot — every vblank was the largest steady CPU cost of an idle
+        // desktop with anything animating on it.
+        {
+            let wm = &(*self.server).wm;
+            if !wm.viewport_is_active {
+                let cam = (wm.desk_pan_x, wm.desk_pan_y, wm.desk_zoom);
+                let due = self.backdrop_epoch != wm.layout_epoch
+                    || self.backdrop_cam != cam
+                    || self.backdrop_measured_at.map_or(true, |t| t.elapsed() >= BACKDROP_REFRESH);
+                if due {
+                    self.backdrop_epoch = wm.layout_epoch;
+                    self.backdrop_cam = cam;
+                    self.backdrop_measured_at = Some(std::time::Instant::now());
+                    self.measure_status_backdrops();
+                }
+            }
         }
 
         // A parked `ccectl screenshot` targeting this output forces a render
@@ -641,10 +677,14 @@ impl Output {
             }
         }
 
-        // Overview-delay debugging: while /tmp/cce-ovdbg exists (contents =
-        // comma-separated app_id substrings), dump the scene-side truth for
-        // matching windows every rendered frame. Toggle live with
-        // `echo firefox,cce-calendar > /tmp/cce-ovdbg`; `rm` to stop.
+        // Overview-delay debugging: with `CCE_OVDBG=1` in the compositor's
+        // environment, while /tmp/cce-ovdbg exists (contents = comma-separated
+        // app_id substrings), dump the scene-side truth for matching windows
+        // every rendered frame. Toggle live with
+        // `echo firefox,cce-calendar > /tmp/cce-ovdbg`; `rm` to stop. The env
+        // gate is what keeps a release build from doing an open()+read() of
+        // that path on every frame it ever renders.
+        if ovdbg_enabled() {
         if let Ok(filter) = std::fs::read_to_string("/tmp/cce-ovdbg") {
             let pats: Vec<&str> = filter.trim().split(',').filter(|p| !p.is_empty()).collect();
             for &window in wm.windows.iter() {
@@ -682,6 +722,7 @@ impl Output {
                 }
             }
         }
+        }
 
         let mut state = std::mem::zeroed();
         ffi::wlr_output_state_init(&mut state);
@@ -694,10 +735,17 @@ impl Output {
         }
 
         if self.rendering_current.tearing {
-            state.tearing_page_flip = true;
-            if !ffi::wlr_output_test_state(self.wlr_output, &state) {
-                state.tearing_page_flip = false;
+            // The test is an atomic TEST_ONLY commit; once it has said no for
+            // this episode, asking again every frame just taxes the game.
+            if !self.tearing_test_failed {
+                state.tearing_page_flip = true;
+                if !ffi::wlr_output_test_state(self.wlr_output, &state) {
+                    state.tearing_page_flip = false;
+                    self.tearing_test_failed = true;
+                }
             }
+        } else {
+            self.tearing_test_failed = false;
         }
 
         if !ffi::wlr_output_commit_state(self.wlr_output, &state) {
@@ -1557,11 +1605,7 @@ impl Output {
             let rel_x = (col as f64 * frame.period_px_exact_x).round() as i32;
             for row in 0..=cells.rows {
                 let rel_y = (row as f64 * frame.period_px_exact_y).round() as i32;
-                let text = crate::policy::cells::square_label(
-                    frame.first_col + col,
-                    frame.first_row + row,
-                );
-                let Some(label) = self.cell_labels.get(&text, px) else {
+                let Some(label) = self.cell_labels.get_square(frame.first_col + col, frame.first_row + row, px) else {
                     continue;
                 };
                 let (buf, lw, lh) = (label.buffer, label.width, label.height);
@@ -1690,6 +1734,19 @@ pub(crate) fn frame_debug() -> bool {
     *FLAG.get_or_init(|| std::env::var_os("CCE_FRAME_DEBUG").is_some())
 }
 
+/// `CCE_OVDBG=1` arms the `/tmp/cce-ovdbg` per-frame scene dump (see
+/// `render_and_commit`); without it the file is never even looked for.
+fn ovdbg_enabled() -> bool {
+    static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *FLAG.get_or_init(|| std::env::var_os("CCE_OVDBG").is_some())
+}
+
+/// Ceiling on how long a status segment's backdrop reading may go unmeasured
+/// while frames are being rendered; matches the readback throttle in
+/// `window_backdrop_sample`. A still desktop renders no frames and measures
+/// nothing at all.
+const BACKDROP_REFRESH: std::time::Duration = std::time::Duration::from_millis(250);
+
 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, so what this frame renders is
diff --git a/src/server/run_server.rs b/src/server/run_server.rs
index d58c043..323c745 100644
--- a/src/server/run_server.rs
+++ b/src/server/run_server.rs
@@ -181,7 +181,7 @@ pub fn run_server() {
     server.wm.status_sender = Some(status_sender);
 
     let stream_hub = crate::stream_server::spawn_stream_server(Some(socket_str.clone()));
-    server.wm.stream_hub = Some(stream_hub);
+    unsafe { server.wm.start_stream(stream_hub) };
 
 
     let started = unsafe { ffi::wlr_backend_start(server.backend) };
diff --git a/src/server/stream_server.rs b/src/server/stream_server.rs
index e168a65..815da80 100644
--- a/src/server/stream_server.rs
+++ b/src/server/stream_server.rs
@@ -49,6 +49,9 @@ pub struct Sub {
 #[derive(Clone)]
 pub struct StreamHub {
     pub subs: Arc<Mutex<Vec<Sub>>>,
+    /// Bumped by the accept thread after pushing a subscriber; the window
+    /// manager has it as an event source and arms its frame tick from it.
+    pub wake: Arc<std::os::fd::OwnedFd>,
 }
 
 pub fn get_stream_socket_path(display_socket: Option<&str>) -> String {
@@ -60,7 +63,10 @@ pub fn get_stream_socket_path(display_socket: Option<&str>) -> String {
 
 /// Spawn the accept thread; returns the hub for the main loop's timer.
 pub fn spawn_stream_server(display_socket: Option<String>) -> StreamHub {
-    let hub = StreamHub { subs: Arc::new(Mutex::new(Vec::new())) };
+    let hub = StreamHub {
+        subs: Arc::new(Mutex::new(Vec::new())),
+        wake: crate::ipc_server::new_wake_fd().expect("failed to create stream wake eventfd"),
+    };
     let accept_hub = hub.clone();
     std::thread::Builder::new()
         .name("cce-stream-server".into())
@@ -112,6 +118,7 @@ fn accept_loop(hub: StreamHub, display_socket: Option<String>) {
         if let Ok(mut subs) = hub.subs.lock() {
             subs.push(Sub { query, tx, needs_frame: true, last_sent: Instant::now() });
         }
+        crate::ipc_server::wake_fd(&hub.wake);
     }
 }
 
diff --git a/src/server/text.rs b/src/server/text.rs
index 1b82acf..2aef8bb 100644
--- a/src/server/text.rs
+++ b/src/server/text.rs
@@ -236,6 +236,10 @@ fn rasterize(text: &str, px: f32) -> Option<Label> {
 #[derive(Default)]
 pub struct LabelCache {
     entries: HashMap<(String, u32), Option<Label>>,
+    /// Grid square labels keyed by (col, row, px) so the per-frame overview
+    /// walk over every visible cell is a hash of three integers, with no
+    /// label String built or copied per cell per frame.
+    squares: HashMap<(i32, i32, u32), Option<Label>>,
 }
 
 impl LabelCache {
@@ -250,17 +254,26 @@ impl LabelCache {
             .as_ref()
     }
 
+    /// The grid square at (col, row) — `policy::cells::square_label` — at
+    /// `px`; the name is only formatted on a miss.
+    pub fn get_square(&mut self, col: i32, row: i32, px: f32) -> Option<&Label> {
+        self.squares
+            .entry((col, row, px.round() as u32))
+            .or_insert_with(|| rasterize(&crate::policy::cells::square_label(col, row), px))
+            .as_ref()
+    }
+
     /// Drop everything (font size changed, or the cache grew unreasonably).
     pub fn clear(&mut self) {
-        for (_, label) in self.entries.drain() {
-            if let Some(label) = label {
-                unsafe { ffi::wlr_buffer_drop(label.buffer) };
-            }
+        let named = self.entries.drain().map(|(_, l)| l);
+        let squares = self.squares.drain().map(|(_, l)| l);
+        for label in named.chain(squares).flatten() {
+            unsafe { ffi::wlr_buffer_drop(label.buffer) };
         }
     }
 
     pub fn len(&self) -> usize {
-        self.entries.len()
+        self.entries.len() + self.squares.len()
     }
 }
 
diff --git a/src/server/window.rs b/src/server/window.rs
index 7ff0ab3..e103d6f 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -558,7 +558,13 @@ impl Window {
         if self.grid_declared {
             return crate::policy::api::WindowRole::Grid;
         }
-        crate::policy::api::WindowRole::from_app_id(self.get_app_id_string().as_deref())
+        // Borrowed, not `get_app_id_string()`: this runs several times per
+        // pointer-motion event (`is_status_bar`/`is_grid`/`is_wallpaper` in
+        // the cursor passthrough) and per window per transaction, and each
+        // call used to heap-allocate a String just to prefix-match it.
+        let ptr = self.get_app_id();
+        let app_id = if ptr.is_null() { None } else { std::ffi::CStr::from_ptr(ptr).to_str().ok() };
+        crate::policy::api::WindowRole::from_app_id(app_id)
     }
 
     pub unsafe fn is_grid(&self) -> bool {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a2e1893..141c454 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -137,6 +137,13 @@ pub struct WindowManager {
     /// at most once per `SAVE_STATE_DELAY_MS`, not once per transaction.
     pub save_state_timer: *mut ffi::wl_event_source,
     pub save_state_pending: bool,
+    /// Bumped at the end of every transaction; outputs compare it to know
+    /// whether window geometry can have moved since they last measured the
+    /// status backdrops.
+    pub layout_epoch: u64,
+    /// The stream hub's wake eventfd as an event source: a new subscriber
+    /// arms `stream_timer`, which otherwise does not tick at all.
+    pub stream_source: *mut ffi::wl_event_source,
     /// Minute tick for the traveling light_source segment: re-arranges so
     /// its perimeter position follows the time of day.
     pub sun_timer: *mut ffi::wl_event_source,
@@ -516,6 +523,8 @@ impl WindowManager {
         self.ipc_wake = None;
         self.save_state_timer = std::ptr::null_mut();
         self.save_state_pending = false;
+        self.layout_epoch = 0;
+        self.stream_source = std::ptr::null_mut();
         self.sun_timer = std::ptr::null_mut();
         self.stream_hub = None;
         self.stream_timer = std::ptr::null_mut();
@@ -573,7 +582,8 @@ impl WindowManager {
             ffi::wl_event_source_remove(self.border_fade_timer);
             return Err("Failed to create stream timer event source");
         }
-        ffi::wl_event_source_timer_update(self.stream_timer, 200);
+        // Not armed here: `start_stream` arms it when a subscriber appears,
+        // and `handle_stream_timer` lets it lapse when the last one leaves.
 
         // Default until the config is parsed (which happens after this init).
         self.center_on_spawn = true;
@@ -1443,6 +1453,22 @@ impl WindowManager {
         }
     }
 
+    /// Own the window-stream hub and wake on its subscriber eventfd.
+    pub unsafe fn start_stream(&mut self, hub: crate::stream_server::StreamHub) {
+        let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+        self.stream_source = ffi::wl_event_loop_add_fd(
+            event_loop,
+            std::os::fd::AsRawFd::as_raw_fd(&*hub.wake),
+            ffi::WL_EVENT_READABLE as u32,
+            Some(handle_stream_wake),
+            self as *mut WindowManager as *mut _,
+        );
+        if self.stream_source.is_null() {
+            log::error!("failed to add the stream wake fd to the event loop; window streams will not run");
+        }
+        self.stream_hub = Some(hub);
+    }
+
     pub unsafe fn deinit(&mut self) {
         if !self.global.is_null() {
             ffi::wl_global_destroy(self.global);
@@ -1453,6 +1479,14 @@ impl WindowManager {
             self.ipc_source = std::ptr::null_mut();
         }
         self.ipc_wake = None;
+        if !self.stream_source.is_null() {
+            ffi::wl_event_source_remove(self.stream_source);
+            self.stream_source = std::ptr::null_mut();
+        }
+        if !self.stream_timer.is_null() {
+            ffi::wl_event_source_remove(self.stream_timer);
+            self.stream_timer = std::ptr::null_mut();
+        }
         if !self.save_state_timer.is_null() {
             ffi::wl_event_source_remove(self.save_state_timer);
             self.save_state_timer = std::ptr::null_mut();
@@ -2506,6 +2540,7 @@ impl WindowManager {
         if self.scheduled.dirty || self.scheduled.dirty_lazy || self.rendering_scheduled.dirty {
             self.add_dirty_idle();
         }
+        self.layout_epoch = self.layout_epoch.wrapping_add(1);
         self.schedule_save_state();
         if let Some(r) = rf0 {
             log::info!("[manage] render_finish total={}us", r.elapsed().as_micros());
@@ -3869,12 +3904,8 @@ impl WindowManager {
             if win_ptr.is_null() || (*win_ptr).closed {
                 continue;
             }
-            if let Some(app_id) = (*win_ptr).get_app_id_string() {
-                if app_id.starts_with("cce-status") {
-                    if matches!((*win_ptr).state, crate::window::WindowState::Mapped) {
-                        status_bar_windows.push(win_ptr);
-                    }
-                }
+            if (*win_ptr).is_status_bar() && matches!((*win_ptr).state, crate::window::WindowState::Mapped) {
+                status_bar_windows.push(win_ptr);
             }
         }
         for win_ptr in status_bar_windows {
@@ -5730,6 +5761,24 @@ unsafe extern "C" fn handle_ipc_event(fd: std::os::raw::c_int, _mask: u32, data:
 /// the writer threads — never blocking the compositor (a full channel means
 /// the client is slow and simply skips the frame). Fast cadence only while
 /// subscribers exist.
+/// A subscriber joined the stream hub: start (or keep) the frame tick.
+unsafe extern "C" fn handle_stream_wake(fd: std::os::raw::c_int, _mask: u32, data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let wm = data as *mut WindowManager;
+    if wm.is_null() {
+        return 0;
+    }
+    crate::ipc_server::drain_wake_fd(fd);
+    if !(*wm).stream_timer.is_null() {
+        ffi::wl_event_source_timer_update((*wm).stream_timer, 1);
+    }
+    0
+}
+
+/// Runs at ~30 Hz while there are subscribers and not at all otherwise:
+/// the tick simply does not re-arm once the subscriber list is empty, and
+/// `handle_stream_wake` restarts it when the accept thread adds one. (It
+/// used to re-arm at 200-500 ms forever, a 2-5 Hz idle wakeup for a feature
+/// that is rarely in use.)
 unsafe extern "C" fn handle_stream_timer(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let wm = &mut *(data as *mut WindowManager);
     let idle_rearm = |wm: &WindowManager, ms: i32| {
@@ -5738,15 +5787,15 @@ unsafe extern "C" fn handle_stream_timer(data: *mut std::ffi::c_void) -> std::os
         }
     };
     let Some(hub) = wm.stream_hub.clone() else {
-        idle_rearm(wm, 500);
         return 0;
     };
     let Ok(mut subs) = hub.subs.lock() else {
+        // A poisoned lock never heals; a retry is still cheaper than a
+        // permanently dead stream, and bounded to twice a second.
         idle_rearm(wm, 500);
         return 0;
     };
     if subs.is_empty() {
-        idle_rearm(wm, 200);
         return 0;
     }