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

commit0bd052f5f72ad98736c830706b96436805161d5b
parentf690a441ef
authorLucas Galante <[email protected]>
date2026-09-10 12:53
perf: stop ticking while idle — fd-driven IPC, poll()ed status server, throttled state saves, gated dirtying

Measured on the live session with nothing happening: the compositor's main
thread at 15-20% of a core, ~230 wakeups/s, and a full manage/arrange/render
transaction 6-10 times a second. Four causes, all fixed here:

- IPC drain was a 10 ms timer polling an mpsc channel forever (100
  wakeups/s at total idle). The IPC thread now bumps an eventfd after each
  send and that fd is a wl_event_loop fd source (`handle_ipc_event`); the
  loop sleeps until a command exists.
- The status server thread was a `try_recv` loop with a 20 ms sleep (50
  wakeups/s, subscribers or not). It now `poll()`s its listener, every
  subscriber socket and a wake eventfd the `StatusSender` bumps. The
  subscription line is read while the accepted socket is still blocking
  (bounded by a read timeout): poll() hands over the connection before the
  client's first line has necessarily landed.
- `save_state` ran on every transaction: per window a `/proc/<pid>/cmdline`
  read and a PATH-wide canonicalize, before the JSON dedup that only gated
  the write. `render_finish` now calls `schedule_save_state`, a one-shot
  timer that writes at most once a second; a direct save (exit paths)
  supersedes a pending one.
- Two commit paths dirtied windowing unconditionally: every commit of a
  status segment (`handle_window_commit` — the clock ticking once a second
  cost a full arrange each time, as did the cpu meter) and every title
  change (`notify_title` — a terminal running a busy program retitles
  several times a second). The first now fires only when the committed
  surface size changed; the second only when a mode rule matches on
  `title=`, since the built-in policy is the only manager and nothing else
  in the manage sequence reads a title — the status bar's `title` topic and
  the state file are fed directly instead. The status-segment branch of the
  xdg acked-configure path gets the same size gate.

`CCE_DIRTY_TRACE=1` logs each dirty_windowing/dirty_rendering call with its
`#[track_caller]` site — that is how the two commit paths were found, and it
costs nothing when unset.

Verified in a headless shadow with clock + cpu segments, cce-files and a
foot retitling 3x/s: 0 transactions in 10 s (was 13/s with the same
clients), status thread 0 wakeups/s (was 50), ccectl round trip unchanged,
title pushes still reach a `title` subscriber at the retitle rate, state
file written once a second under the churn. The live session picks the
binary up at next login.

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

 CLAUDE.md                    |  26 +++++++-
 src/server/ipc_server.rs     |  70 +++++++++++++++++++---
 src/server/run_server.rs     |   2 +-
 src/server/status_server.rs  | 100 +++++++++++++++++++++++++------
 src/server/window.rs         |  41 ++++++++++++-
 src/server/window_manager.rs | 138 +++++++++++++++++++++++++++++++++----------
 src/server/xdg_toplevel.rs   |  13 +++-
 7 files changed, 326 insertions(+), 64 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 012e6a4..e17b04e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -310,8 +310,13 @@ treats them as opaque.
   WM state, the camera fields, window lists, the IPC command dispatcher
   `process_ipc_command()`, the `Policy::action` snapshot builder
   (`build_action_ctx`) and the `Compositor` command applier. IPC requests arrive on
-  an mpsc channel drained by a wlroots event-loop timer (`handle_ipc_timer`) so all
-  mutation happens on the main thread. Decision logic (camera math, action
+  an mpsc channel; the IPC thread bumps an eventfd after each send, and that fd is a
+  `wl_event_loop_add_fd` source (`handle_ipc_event`) which drains the channel, so all
+  mutation happens on the main thread and the loop sleeps until a command exists.
+  (It was a 10 ms polling timer until 2026-09-10 — 100 wakeups/s at total idle. The
+  status server thread had the same shape, a `try_recv` loop with a 20 ms sleep; it
+  now `poll()`s its sockets plus a wake eventfd. Nothing in the compositor should
+  tick while idle: a timer that re-arms itself unconditionally is a bug.) Decision logic (camera math, action
   dispatch, snapping, refocus, grid geometry) lives in `cce-window-manager`.
 - **`window.rs`** (~4.9k lines) — per-window model and rendering (borders, blur,
   viewport transforms).
@@ -498,6 +503,23 @@ grid has them.
   change. This feeds the status bar (`cce-status-interface`). The main loop pushes
   updates through a `StatusSender` mpsc handle.
 
+  **What may start a transaction.** `dirty_windowing()` schedules a full
+  manage/arrange/render pass, and on an idle desktop the answer to "why is the
+  window manager busy" is always some call site that dirties on a routine
+  commit. Two were found on 2026-09-10 and gated: a status segment's *every*
+  commit (`handle_window_commit`, now only when the surface size changed — the
+  clock ticking once a second used to cost an arrange each time) and a title
+  change (`notify_title`, now only when a mode rule matches on `title=`; the
+  built-in policy is the only manager, `wm.object` is never bound, so nothing
+  else in the manage sequence reads a title — the status bar's `title` topic
+  and the state file are fed directly instead). `CCE_DIRTY_TRACE=1` logs one
+  debug line per dirty call with its `#[track_caller]` site; it is the tool
+  for this question, and costs nothing when unset. `CCE_DIRTY_BACKTRACE=1`
+  adds a full backtrace per call (expensive). The state file is written by a
+  one-shot timer (`schedule_save_state`, at most once a second) rather than
+  on every transaction: `save_state` reads `/proc` for every window, and a
+  drag is one transaction per pointer 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/ipc_server.rs b/src/server/ipc_server.rs
index 0943007..21fd68c 100644
--- a/src/server/ipc_server.rs
+++ b/src/server/ipc_server.rs
@@ -1,7 +1,9 @@
 // Monolithic IPC Server socket listener for CCE
 use std::io::{Read, Write};
+use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
 use std::os::unix::net::{UnixListener, UnixStream};
 use std::sync::mpsc;
+use std::sync::Arc;
 use std::thread;
 
 pub struct IpcRequest {
@@ -9,6 +11,55 @@ pub struct IpcRequest {
     pub reply_tx: mpsc::Sender<String>,
 }
 
+/// The server-thread end of the request channel. Every `send` is followed by
+/// a write to the wake eventfd, which the compositor has registered with its
+/// wl_event_loop — that is what gets a request dispatched. The drain used to
+/// be a 10 ms timer polling `try_recv` forever, ~100 wakeups/s on an idle
+/// desktop; now the main thread sleeps until a command actually arrives.
+#[derive(Clone)]
+struct IpcSender {
+    tx: mpsc::Sender<IpcRequest>,
+    wake: Arc<OwnedFd>,
+}
+
+impl IpcSender {
+    fn send(&self, req: IpcRequest) -> bool {
+        if self.tx.send(req).is_err() {
+            return false;
+        }
+        wake_fd(&self.wake);
+        true
+    }
+}
+
+/// Bump an eventfd. Errors are ignored on purpose: EAGAIN means the counter
+/// is already saturated (the reader is about to run anyway), and EBADF only
+/// happens at shutdown.
+pub fn wake_fd(fd: &OwnedFd) {
+    let one: u64 = 1;
+    unsafe {
+        libc::write(fd.as_raw_fd(), &one as *const u64 as *const libc::c_void, 8);
+    }
+}
+
+/// Clear an eventfd after its readable event fired.
+pub fn drain_wake_fd(fd: std::os::raw::c_int) {
+    let mut v: u64 = 0;
+    unsafe {
+        libc::read(fd, &mut v as *mut u64 as *mut libc::c_void, 8);
+    }
+}
+
+/// A non-blocking, close-on-exec eventfd for cross-thread wakeups into the
+/// wl_event_loop.
+pub fn new_wake_fd() -> std::io::Result<Arc<OwnedFd>> {
+    let raw = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
+    if raw < 0 {
+        return Err(std::io::Error::last_os_error());
+    }
+    Ok(Arc::new(unsafe { OwnedFd::from_raw_fd(raw) }))
+}
+
 fn get_ipc_socket_path(display_socket: Option<&str>) -> String {
     if let Some(display) = display_socket {
         format!("/tmp/cce-{}.sock", display)
@@ -17,20 +68,25 @@ fn get_ipc_socket_path(display_socket: Option<&str>) -> String {
     }
 }
 
-pub fn spawn_ipc_server(display_socket: Option<String>) -> mpsc::Receiver<IpcRequest> {
+/// Spawn the IPC listener thread. Returns the request receiver and the
+/// eventfd that is bumped after every request is queued; the caller adds the
+/// fd to its event loop and drains the receiver when it fires.
+pub fn spawn_ipc_server(display_socket: Option<String>) -> (mpsc::Receiver<IpcRequest>, Arc<OwnedFd>) {
     let (tx, rx) = mpsc::channel::<IpcRequest>();
-    
+    let wake = new_wake_fd().expect("Failed to create IPC wake eventfd");
+    let sender = IpcSender { tx, wake: wake.clone() };
+
     thread::Builder::new()
         .name("cce-ipc-server".to_string())
         .spawn(move || {
-            ipc_server_main(tx, display_socket);
+            ipc_server_main(sender, display_socket);
         })
         .expect("Failed to spawn CCE IPC server thread");
 
-    rx
+    (rx, wake)
 }
 
-fn ipc_server_main(tx: mpsc::Sender<IpcRequest>, display_socket: Option<String>) {
+fn ipc_server_main(tx: IpcSender, display_socket: Option<String>) {
     let socket_path = get_ipc_socket_path(display_socket.as_deref());
     let _ = std::fs::remove_file(&socket_path);
 
@@ -64,7 +120,7 @@ fn ipc_server_main(tx: mpsc::Sender<IpcRequest>, display_socket: Option<String>)
     }
 }
 
-fn handle_client(mut stream: UnixStream, tx: mpsc::Sender<IpcRequest>) {
+fn handle_client(mut stream: UnixStream, tx: IpcSender) {
     let mut buf = [0u8; 4096];
     match stream.read(&mut buf) {
         Ok(0) => {}
@@ -86,7 +142,7 @@ fn handle_client(mut stream: UnixStream, tx: mpsc::Sender<IpcRequest>) {
                     std::time::Duration::from_millis(1000)
                 };
                 let (reply_tx, reply_rx) = mpsc::channel();
-                if tx.send(IpcRequest { command: cmd, reply_tx }).is_ok() {
+                if tx.send(IpcRequest { command: cmd, reply_tx }) {
                     if let Ok(reply) = reply_rx.recv_timeout(timeout) {
                         let _ = stream.write_all(reply.as_bytes());
                     } else {
diff --git a/src/server/run_server.rs b/src/server/run_server.rs
index 5dc8fbc..d58c043 100644
--- a/src/server/run_server.rs
+++ b/src/server/run_server.rs
@@ -175,7 +175,7 @@ pub fn run_server() {
 
     std::env::set_var("WAYLAND_DISPLAY", &socket_str);
 
-    server.wm.start_ipc(Some(socket_str.clone()));
+    unsafe { server.wm.start_ipc(Some(socket_str.clone())) };
 
     let status_sender = crate::status_server::spawn_status_server(Some(socket_str.clone()));
     server.wm.status_sender = Some(status_sender);
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index f0fc413..6f16b44 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -8,8 +8,12 @@
 // owns the socket and handles all I/O independently of the Wayland event loop.
 
 use std::io::{BufRead, Write};
+use std::os::fd::{AsRawFd, OwnedFd};
 use std::os::unix::net::{UnixListener, UnixStream};
 use std::sync::mpsc;
+use std::sync::Arc;
+
+use crate::ipc_server::{drain_wake_fd, new_wake_fd, wake_fd};
 
 /// A status update sent from the main loop to the server thread.
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -90,21 +94,39 @@ struct Client {
 }
 
 /// Handle to the status server for sending updates from the main loop.
+///
+/// Every send bumps `wake`, the eventfd the server thread `poll()`s on
+/// alongside its sockets. The thread used to spin on `try_recv` with a 20 ms
+/// sleep — 50 wakeups/s forever, subscribers or not; now it blocks until a
+/// socket or the main loop has something for it.
 #[derive(Debug, Clone)]
 pub struct StatusSender {
     tx: mpsc::Sender<StatusMsg>,
+    wake: Arc<OwnedFd>,
 }
 
 impl StatusSender {
     pub fn send(&self, update: StatusUpdate) {
         // If the channel is full or the receiver is gone, just drop it.
-        let _ = self.tx.send(StatusMsg::State(update));
+        if self.tx.send(StatusMsg::State(update)).is_ok() {
+            wake_fd(&self.wake);
+        }
     }
 
     /// Fire a one-shot menu-dismiss at every `dismiss` subscriber except the
     /// segment with this app_id (pass "-" to exempt nobody).
     pub fn send_menu_dismiss(&self, except_app_id: &str) {
-        let _ = self.tx.send(StatusMsg::MenuDismiss { except_app_id: except_app_id.to_string() });
+        if self.tx.send(StatusMsg::MenuDismiss { except_app_id: except_app_id.to_string() }).is_ok() {
+            wake_fd(&self.wake);
+        }
+    }
+}
+
+impl Drop for StatusSender {
+    /// Dropping the last handle disconnects the channel; the thread only
+    /// notices when it next wakes, so give it one.
+    fn drop(&mut self) {
+        wake_fd(&self.wake);
     }
 }
 
@@ -119,18 +141,45 @@ pub fn get_status_socket_path(display_socket: Option<&str>) -> String {
 /// Spawn the status server thread. Returns a StatusSender for the main loop.
 pub fn spawn_status_server(display_socket: Option<String>) -> StatusSender {
     let (tx, rx) = mpsc::channel::<StatusMsg>();
+    let wake = new_wake_fd().expect("failed to create status wake eventfd");
+    let thread_wake = wake.clone();
 
     std::thread::Builder::new()
         .name("cce-status-server".into())
         .spawn(move || {
-            status_server_main(rx, display_socket);
+            status_server_main(rx, thread_wake, display_socket);
         })
         .expect("failed to spawn status server thread");
 
-    StatusSender { tx }
+    StatusSender { tx, wake }
+}
+
+/// Block until the wake eventfd, the listener, or any subscriber socket is
+/// readable. Returns `(wake, accept, per-client readiness)`; a client is
+/// "ready" on data, hangup or error alike, since all three are handled by
+/// reading it.
+fn wait_for_activity(wake: &OwnedFd, listener: &UnixListener, clients: &[Client]) -> Option<(bool, bool, Vec<bool>)> {
+    let mut fds: Vec<libc::pollfd> = Vec::with_capacity(2 + clients.len());
+    for fd in [wake.as_raw_fd(), listener.as_raw_fd()] {
+        fds.push(libc::pollfd { fd, events: libc::POLLIN, revents: 0 });
+    }
+    for client in clients {
+        fds.push(libc::pollfd { fd: client.stream.as_raw_fd(), events: libc::POLLIN, revents: 0 });
+    }
+    let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) };
+    if n < 0 {
+        let err = std::io::Error::last_os_error();
+        if err.kind() == std::io::ErrorKind::Interrupted {
+            return Some((false, false, vec![false; clients.len()]));
+        }
+        log::error!("[status] poll failed: {}", err);
+        return None;
+    }
+    let ready = |f: &libc::pollfd| f.revents != 0;
+    Some((ready(&fds[0]), ready(&fds[1]), fds[2..].iter().map(ready).collect()))
 }
 
-fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<String>) {
+fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display_socket: Option<String>) {
     let socket_path = get_status_socket_path(display_socket.as_deref());
     // Remove stale socket
     let _ = std::fs::remove_file(&socket_path);
@@ -155,19 +204,34 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
     let mut latest: Option<StatusUpdate> = None;
 
     loop {
-        let mut activity = false;
+        let Some((wake_ready, accept_ready, client_ready)) = wait_for_activity(&wake, &listener, &clients) else {
+            // poll() itself failing is not something a retry fixes fast;
+            // back off so the error line cannot flood the log.
+            std::thread::sleep(std::time::Duration::from_millis(100));
+            continue;
+        };
+        if wake_ready {
+            drain_wake_fd(wake.as_raw_fd());
+        }
+
         let mut has_new_update = false;
 
-        // Accept new connections (non-blocking)
+        // Accept new connections (the listener is non-blocking)
         for _ in 0..5 {
+            if !accept_ready {
+                break;
+            }
             match listener.accept() {
                 Ok((stream, _addr)) => {
+                    // Read the subscription line while the socket is still
+                    // blocking (bounded by a read timeout): poll() hands us
+                    // the connection the instant it lands, which can be
+                    // before the client's first line is in the buffer.
+                    let sub = read_subscription(&stream);
                     if let Err(e) = stream.set_nonblocking(true) {
                         log::error!("[status] failed to set non-blocking on client: {}", e);
                         continue;
                     }
-                    // Read the subscription line
-                    let sub = read_subscription(&stream);
                     if sub != Subscription::Unknown {
                         log::info!("[status] new subscriber for {:?}", sub);
                         let client = Client {
@@ -176,7 +240,6 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
                             last_line: None,
                         };
                         clients.push(client);
-                        activity = true;
                         has_new_update = true; // push the latest status to the new client
                     }
                 }
@@ -205,6 +268,10 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
             let mut buf = [0u8; 64];
             let mut dead_clients = Vec::new();
             for (i, client) in clients.iter_mut().enumerate() {
+                // Only sockets poll() flagged; the rest are quiet, not dead.
+                if !client_ready.get(i).copied().unwrap_or(false) {
+                    continue;
+                }
                 loop {
                     use std::io::Read;
                     match client.stream.read(&mut buf) {
@@ -237,12 +304,10 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
             match rx.try_recv() {
                 Ok(StatusMsg::State(update)) => {
                     latest = Some(update);
-                    activity = true;
                     has_new_update = true;
                 }
                 Ok(StatusMsg::MenuDismiss { except_app_id }) => {
                     dismiss_events.push(except_app_id);
-                    activity = true;
                 }
                 Err(mpsc::TryRecvError::Empty) => break,
                 Err(mpsc::TryRecvError::Disconnected) => {
@@ -333,20 +398,17 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
                 }
             }
         }
-
-        if !activity {
-            // Small sleep to avoid busy-looping when nothing is happening
-            std::thread::sleep(std::time::Duration::from_millis(20));
-        }
     }
 }
 
 fn read_subscription(stream: &UnixStream) -> Subscription {
     let mut reader = std::io::BufReader::new(stream);
     let mut line = String::new();
-    // Try to read with a small timeout
+    // Bounded blocking read: a subscriber writes its one line right after
+    // connecting, so this returns at once in practice; the timeout is for a
+    // client that connects and says nothing.
     stream
-        .set_read_timeout(Some(std::time::Duration::from_millis(100)))
+        .set_read_timeout(Some(std::time::Duration::from_millis(200)))
         .ok();
     match reader.read_line(&mut line) {
         Ok(_) => Subscription::from_str(&line),
diff --git a/src/server/window.rs b/src/server/window.rs
index 630dafc..7ff0ab3 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -482,6 +482,10 @@ pub struct Window {
     /// timer after a capture: the damage gate for `stream_server` frames.
     /// Starts true so a fresh subscriber gets an immediate first frame.
     pub stream_dirty: bool,
+    /// Surface size at the last commit of a status segment, so
+    /// `handle_window_commit` re-arranges only when the segment actually
+    /// changed size rather than on every content refresh.
+    pub status_commit_size: (i32, i32),
     pub commit: ffi::wl_listener,
     pub was_fullscreen: bool,
     pub saved_width: i32,
@@ -753,6 +757,7 @@ impl Window {
             self_resized: false,
             status_collapsed_len: 0,
             stream_dirty: true,
+            status_commit_size: (0, 0),
             commit: std::mem::zeroed(),
             was_fullscreen: false,
             saved_width: 0,
@@ -2714,7 +2719,21 @@ impl Window {
     pub unsafe fn notify_title(&mut self) {
         self.wm_scheduled.dirty_title = true;
         self.try_restore();
-        (*self.server).wm.dirty_windowing();
+        // A title is arrangement input only through a mode rule that matches
+        // on it (`title=` in a rule); the built-in policy is what runs — no
+        // external manager is ever bound to `wm.object` (see the bind
+        // handler) — so nothing else in the manage sequence reads it. A
+        // terminal running a busy program retitles several times a second,
+        // and each retitle used to cost a full manage/arrange/render pass.
+        // Without a title rule the title's other consumers are the status
+        // bar's `title` topic and the saved-state file, so feed those directly.
+        let wm = &mut (*self.server).wm;
+        if wm.mode_rules.iter().any(|r| r.title_pattern.is_some()) {
+            wm.dirty_windowing();
+        } else {
+            wm.update_status();
+            wm.schedule_save_state();
+        }
 
         if !self.foreign_toplevel_handle.is_null() {
             let title = self.get_title();
@@ -5324,7 +5343,25 @@ unsafe extern "C" fn handle_window_commit(listener: *mut ffi::wl_listener, _data
     if (*window).x11_buffer_scale() != 1.0 {
         (*window).scale_only_render_finish();
     }
+    // A status segment that changed size needs the bar re-arranged around
+    // it. One that merely repainted (the clock, once a second; the cpu
+    // meter) does not — and this used to dirty on every commit, which made
+    // the status bar alone run a full manage/arrange/render transaction for
+    // each of its ticks, all day. Compare the committed surface size against
+    // the last commit's; the xdg commit handler tracks box_geom the same way.
     if was_status {
-        (*(*window).server).wm.dirty_windowing();
+        let surface = (*window).root_surface();
+        if !surface.is_null() {
+            let size = (
+                ffi::river_wlr_surface_get_width(surface),
+                ffi::river_wlr_surface_get_height(surface),
+            );
+            if size != (*window).status_commit_size {
+                (*window).status_commit_size = size;
+                (*(*window).server).wm.dirty_windowing();
+            }
+        } else {
+            (*(*window).server).wm.dirty_windowing();
+        }
     }
 }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index d1366d9..a2e1893 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -128,7 +128,15 @@ pub struct WindowManager {
     pub pointer_binds: Vec<crate::config::PointerBind>,
     pub gesture_binds: Vec<crate::config::GestureBind>,
     pub ipc_rx: Option<std::sync::mpsc::Receiver<crate::ipc_server::IpcRequest>>,
-    pub ipc_timer: *mut ffi::wl_event_source,
+    /// The IPC thread's wake eventfd as a wl_event_loop fd source: fires once
+    /// per queued request, so the drain runs only when there is something to
+    /// drain (it was a 10 ms polling timer before).
+    pub ipc_source: *mut ffi::wl_event_source,
+    pub ipc_wake: Option<std::sync::Arc<std::os::fd::OwnedFd>>,
+    /// One-shot timer behind `schedule_save_state`: the state file is written
+    /// 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,
     /// 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,
@@ -141,9 +149,9 @@ pub struct WindowManager {
     pub pending_screenshot: Option<crate::screenshot::PendingScreenshot>,
     /// The reply channel of the IPC command currently being dispatched, so a
     /// command that cannot answer yet can carry it away and answer later
-    /// (only `screenshot` does). Set by `handle_ipc_timer` around the
+    /// (only `screenshot` does). Set by `handle_ipc_event` around the
     /// dispatch; if it is still here afterwards, the command answered
-    /// synchronously and the timer sends its return value.
+    /// synchronously and the drain sends its return value.
     pub pending_ipc_reply: Option<std::sync::mpsc::Sender<String>>,
     pub startup: Vec<crate::config::StartupConfig>,
     pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
@@ -308,6 +316,21 @@ fn dirty_backtrace_debug() -> bool {
     *FLAG.get_or_init(|| std::env::var_os("CCE_DIRTY_BACKTRACE").is_some())
 }
 
+/// `CCE_DIRTY_TRACE=1` — one debug line per `dirty_windowing` /
+/// `dirty_rendering` call naming the call site (`#[track_caller]`, so it
+/// costs nothing when off). The cheap way to answer "what keeps the window
+/// manager running transactions on an idle desktop".
+fn dirty_trace() -> bool {
+    static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *FLAG.get_or_init(|| std::env::var_os("CCE_DIRTY_TRACE").is_some())
+}
+
+/// How long after a transaction the state file is written. One write per
+/// second is plenty for a file whose job is surviving a crash, and the
+/// per-window `/proc` reads in `save_state` are far too heavy to run on
+/// every transaction (a drag is one transaction per pointer event).
+const SAVE_STATE_DELAY_MS: i32 = 1000;
+
 /// `CCE_ARRANGE_DEBUG=1` — the arrange pass and its per-window dump. A status
 /// bar commit runs a full arrange every second, so at debug level this alone
 /// wrote ~15-20 lines/second (and allocated a title + app_id String per window
@@ -489,7 +512,10 @@ impl WindowManager {
         self.pointer_binds = Vec::new();
         self.gesture_binds = Vec::new();
         self.ipc_rx = None;
-        self.ipc_timer = std::ptr::null_mut();
+        self.ipc_source = std::ptr::null_mut();
+        self.ipc_wake = None;
+        self.save_state_timer = std::ptr::null_mut();
+        self.save_state_pending = false;
         self.sun_timer = std::ptr::null_mut();
         self.stream_hub = None;
         self.stream_timer = std::ptr::null_mut();
@@ -516,17 +542,10 @@ impl WindowManager {
         }
 
         self.ipc_rx = None;
-        self.ipc_timer = ffi::wl_event_loop_add_timer(event_loop, Some(handle_ipc_timer), self as *mut WindowManager as *mut _);
-        if self.ipc_timer.is_null() {
-            ffi::wl_event_source_remove(self.timeout);
-            return Err("Failed to create IPC timer event source");
-        }
-        ffi::wl_event_source_timer_update(self.ipc_timer, 10);
 
         self.sun_timer = ffi::wl_event_loop_add_timer(event_loop, Some(handle_sun_timer), self as *mut WindowManager as *mut _);
         if self.sun_timer.is_null() {
             ffi::wl_event_source_remove(self.timeout);
-            ffi::wl_event_source_remove(self.ipc_timer);
             return Err("Failed to create sun timer event source");
         }
         ffi::wl_event_source_timer_update(self.sun_timer, 60_000);
@@ -534,7 +553,6 @@ impl WindowManager {
         self.clean_exit_timer = ffi::wl_event_loop_add_timer(event_loop, Some(handle_clean_exit_timeout), self as *mut WindowManager as *mut _);
         if self.clean_exit_timer.is_null() {
             ffi::wl_event_source_remove(self.timeout);
-            ffi::wl_event_source_remove(self.ipc_timer);
             return Err("Failed to create clean exit timer event source");
         }
         self.clean_exit_in_progress = false;
@@ -543,7 +561,6 @@ impl WindowManager {
             ffi::wl_event_loop_add_timer(event_loop, Some(handle_border_fade_tick), self as *mut WindowManager as *mut _);
         if self.border_fade_timer.is_null() {
             ffi::wl_event_source_remove(self.timeout);
-            ffi::wl_event_source_remove(self.ipc_timer);
             ffi::wl_event_source_remove(self.clean_exit_timer);
             return Err("Failed to create border fade timer event source");
         }
@@ -552,7 +569,6 @@ impl WindowManager {
         self.stream_timer = ffi::wl_event_loop_add_timer(event_loop, Some(handle_stream_timer), self as *mut WindowManager as *mut _);
         if self.stream_timer.is_null() {
             ffi::wl_event_source_remove(self.timeout);
-            ffi::wl_event_source_remove(self.ipc_timer);
             ffi::wl_event_source_remove(self.clean_exit_timer);
             ffi::wl_event_source_remove(self.border_fade_timer);
             return Err("Failed to create stream timer event source");
@@ -813,6 +829,11 @@ impl WindowManager {
         if self.shutting_down {
             return;
         }
+        // A direct save supersedes a scheduled one.
+        self.save_state_pending = false;
+        if !self.save_state_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.save_state_timer, 0);
+        }
         let Some(path_str) = crate::config::default_state_path() else {
             log::error!("Could not resolve state file path");
             return;
@@ -1403,10 +1424,22 @@ impl WindowManager {
         }
     }
 
-    pub fn start_ipc(&mut self, display_socket: Option<String>) {
+    pub unsafe fn start_ipc(&mut self, display_socket: Option<String>) {
         if self.ipc_rx.is_none() {
-            let rx = crate::ipc_server::spawn_ipc_server(display_socket);
+            let (rx, wake) = crate::ipc_server::spawn_ipc_server(display_socket);
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.ipc_source = ffi::wl_event_loop_add_fd(
+                event_loop,
+                std::os::fd::AsRawFd::as_raw_fd(&*wake),
+                ffi::WL_EVENT_READABLE as u32,
+                Some(handle_ipc_event),
+                self as *mut WindowManager as *mut _,
+            );
+            if self.ipc_source.is_null() {
+                log::error!("failed to add the IPC wake fd to the event loop; ccectl will not work");
+            }
             self.ipc_rx = Some(rx);
+            self.ipc_wake = Some(wake);
         }
     }
 
@@ -1415,6 +1448,15 @@ impl WindowManager {
             ffi::wl_global_destroy(self.global);
             self.global = std::ptr::null_mut();
         }
+        if !self.ipc_source.is_null() {
+            ffi::wl_event_source_remove(self.ipc_source);
+            self.ipc_source = std::ptr::null_mut();
+        }
+        self.ipc_wake = None;
+        if !self.save_state_timer.is_null() {
+            ffi::wl_event_source_remove(self.save_state_timer);
+            self.save_state_timer = std::ptr::null_mut();
+        }
         if !self.timeout.is_null() {
             ffi::wl_event_source_remove(self.timeout);
             self.timeout = std::ptr::null_mut();
@@ -1937,6 +1979,7 @@ impl WindowManager {
         ffi::wl_event_source_timer_update(self.border_fade_timer, 16);
     }
 
+    #[track_caller]
     pub unsafe fn dirty_windowing(&mut self) {
         // Capturing and symbolizing a backtrace costs far more than the event it
         // annotates, and this fires on routine commits — the session runs at
@@ -1946,6 +1989,9 @@ impl WindowManager {
             let bt = std::backtrace::Backtrace::force_capture();
             log::debug!("dirty_windowing called from backtrace:\n{}", bt);
         }
+        if dirty_trace() {
+            log::debug!("dirty_windowing from {}", std::panic::Location::caller());
+        }
         self.scheduled.dirty = true;
         self.add_dirty_idle();
     }
@@ -1961,7 +2007,11 @@ impl WindowManager {
         self.remove_dirty_idle();
     }
  
+    #[track_caller]
     pub unsafe fn dirty_rendering(&mut self) {
+        if dirty_trace() {
+            log::debug!("dirty_rendering from {}", std::panic::Location::caller());
+        }
         self.rendering_scheduled.dirty = true;
         self.add_dirty_idle();
     }
@@ -2456,15 +2506,35 @@ impl WindowManager {
         if self.scheduled.dirty || self.scheduled.dirty_lazy || self.rendering_scheduled.dirty {
             self.add_dirty_idle();
         }
-        let sv0 = if manage_debug() { Some(std::time::Instant::now()) } else { None };
-        self.save_state();
-        if let (Some(r), Some(s)) = (rf0, sv0) {
-            log::info!(
-                "[manage] render_finish total={}us save_state={}us",
-                r.elapsed().as_micros(),
-                s.elapsed().as_micros()
+        self.schedule_save_state();
+        if let Some(r) = rf0 {
+            log::info!("[manage] render_finish total={}us", r.elapsed().as_micros());
+        }
+    }
+
+    /// Write the state file soon, once, no matter how many transactions land
+    /// in the meantime. The first call after a save arms the timer; later
+    /// calls before it fires are absorbed, so a burst of transactions costs
+    /// one save at most `SAVE_STATE_DELAY_MS` behind the last change.
+    pub unsafe fn schedule_save_state(&mut self) {
+        if self.shutting_down || self.save_state_pending {
+            return;
+        }
+        if self.save_state_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.save_state_timer = ffi::wl_event_loop_add_timer(
+                event_loop,
+                Some(handle_save_state_timer),
+                self as *mut WindowManager as *mut _,
             );
+            if self.save_state_timer.is_null() {
+                log::error!("failed to create the save-state timer; saving synchronously");
+                self.save_state();
+                return;
+            }
         }
+        self.save_state_pending = true;
+        ffi::wl_event_source_timer_update(self.save_state_timer, SAVE_STATE_DELAY_MS);
     }
 }
 
@@ -4776,7 +4846,7 @@ impl WindowManager {
                         // claimed success for captures that then failed (an
                         // unsupported readback format, a failed commit) and
                         // named a file that never appeared. Taking the
-                        // channel is what tells `handle_ipc_timer` not to
+                        // channel is what tells `handle_ipc_event` not to
                         // answer, so the returned string goes nowhere.
                         self.pending_screenshot = Some(crate::screenshot::PendingScreenshot::new(
                             target_out,
@@ -5618,12 +5688,24 @@ unsafe extern "C" fn handle_sun_timer(data: *mut std::ffi::c_void) -> std::os::r
     0
 }
 
-unsafe extern "C" fn handle_ipc_timer(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+unsafe extern "C" fn handle_save_state_timer(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let wm = data as *mut WindowManager;
     if wm.is_null() {
         return 0;
     }
-    
+    (*wm).save_state_pending = false;
+    (*wm).save_state();
+    0
+}
+
+/// The IPC wake fd fired: clear it and dispatch every queued request.
+unsafe extern "C" fn handle_ipc_event(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 let Some(ref rx) = (*wm).ipc_rx {
         while let Ok(req) = rx.try_recv() {
             // Lend the reply channel to the dispatch: a command whose real
@@ -5638,10 +5720,6 @@ unsafe extern "C" fn handle_ipc_timer(data: *mut std::ffi::c_void) -> std::os::r
             }
         }
     }
-    
-    if !(*wm).ipc_timer.is_null() {
-        ffi::wl_event_source_timer_update((*wm).ipc_timer, 10);
-    }
 
     0
 }
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 3902ae9..78b7740 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -863,9 +863,16 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
             if matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup) || is_status || is_overlay || is_utility {
                 (*window).set_dimensions(new_geometry.width as u32, new_geometry.height as u32);
                 if is_status {
-                    (*window).box_geom.width = new_geometry.width;
-                    (*window).box_geom.height = new_geometry.height;
-                    (*(*window).server).wm.dirty_windowing();
+                    // Only a size that actually moved re-arranges. A status
+                    // segment acks a configure and commits at its old size
+                    // for every content refresh; each of those used to run
+                    // a full manage/arrange/render transaction.
+                    let (w, h) = (new_geometry.width, new_geometry.height);
+                    if w != (*window).box_geom.width || h != (*window).box_geom.height {
+                        (*window).box_geom.width = w;
+                        (*window).box_geom.height = h;
+                        (*(*window).server).wm.dirty_windowing();
+                    }
                 }
             }