Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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();
+ }
}
}