Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
perf: an idle compositor stops ticking; cheaper state saves and tracing
Measured in a scale-2 shadow (installed build -> this one): context
switches with no clients, 10 s at rest, 10 -> 0.
- idle.rs stat'ed the two Power-plan files every second, forever -- the
last thing that ticked in an idle compositor. The plan directory is
now an inotify fd source; the 1 s poll is only the fallback while the
directory is missing, and switches to the watch when it appears.
Checked: a plan file written, removed, the directory deleted and
recreated, a file written after -- each picked up.
- save_state cached nothing: every save (once a second while anything
moves) read each window's /proc cmdline and stat'ed every PATH entry
in path_shadowed_name. argv is cached per pid (pruned to live
windows); foot's shell cwd is still read fresh. The unchanged check
compares compact JSON instead of pretty-printing first, and the file
is written to state.json.tmp and renamed over it, so a crash mid-write
cannot leave a truncated state.json. Window moves still save each
time; foot's saved argv is unchanged.
- The always-on [LinkDbg] tracer runs three times a transaction and
allocated each window's app_id to prefix-match it; it uses the
borrowed is_status_bar() (the same "cce-status" prefix).
- wl_signal_add logged two info lines per listener -- ~6,000 of the
~12,900 lines in the live cce.log -- and every swipe-update event
logged another; trace and debug now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 12 ++++-
src/server/cursor.rs | 2 +-
src/server/idle.rs | 117 ++++++++++++++++++++++++++++++++++++++++---
src/server/server.rs | 5 +-
src/server/window_manager.rs | 66 +++++++++++++++++-------
5 files changed, 173 insertions(+), 29 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index b18c2590..a28531c0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -804,6 +804,12 @@ handler; `systemctl suspend` returns as soon as the job is queued, so resume
is detected from the wlroots session's `active` signal instead (the
`river_wlr_session_get_active_signal` shim — `wlr_session` is opaque to
bindgen), treated as activity so a lid-open lights the screen without a key.
+The Power plan's per-mode overrides (`/run/cce/idle_display_off`,
+`idle_sleep`; `CCE_IDLE_PLAN_DIR` moves them) are followed by an inotify watch
+on that directory, an fd source on the event loop, so nothing ticks at rest;
+the 1 s stat poll that was the only mechanism until 2026-10-05 is now the
+fallback while the directory is missing, and it switches back to the watch
+once the directory appears.
Note that until 2026-09-16 the hardware pointer handlers (`handle_motion`,
`handle_motion_absolute`, `handle_button`, `handle_axis`) and `handle_group_key`
never called `handle_activity` at all — only tablet, touch and gestures did —
@@ -1081,7 +1087,11 @@ headless seat has no keyboard and Chromium crashes in
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.
+ drag is one transaction per pointer event. Each window's argv is cached by
+ pid (`proc_args_cache`, pruned to live windows each save) — `proc_args`
+ stats every `PATH` entry; only foot's shell cwd is read fresh. The
+ unchanged check compares compact JSON, and a write goes to
+ `state.json.tmp` and is renamed over, so a crash cannot truncate it.
**Per-frame work is gated too.** The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
before the file is even looked for. The window-stream tick runs only while
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 01d294e7..f710e923 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3816,7 +3816,7 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
cursor.gesture_dx += (*event).dx;
cursor.gesture_dy += (*event).dy;
- log::info!(
+ log::debug!(
"handle_swipe_update: fingers={}, dx={}, dy={}, accumulated_dx={}, accumulated_dy={}",
(*event).fingers,
(*event).dx,
diff --git a/src/server/idle.rs b/src/server/idle.rs
index bf810c2a..34f43507 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -74,13 +74,16 @@ pub const PLAN_SLEEP_FILE: &str = "idle_sleep";
/// one process, for testing: /run/cce is root's, and a shadow session must
/// not read the live machine's plan files either.
fn plan_path(file: &str) -> String {
- let dir = std::env::var("CCE_IDLE_PLAN_DIR").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| PLAN_DIR.to_string());
- format!("{}/{}", dir, file)
+ format!("{}/{}", plan_dir(), file)
}
-/// How often the plan files are stat'ed. A mode change is a plug or an
-/// unplug, so a second is instant to a person and two stats a second is
-/// nothing.
+fn plan_dir() -> String {
+ std::env::var("CCE_IDLE_PLAN_DIR").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| PLAN_DIR.to_string())
+}
+
+/// How often the plan files are stat'ed when the directory cannot be
+/// watched (`watch_plan_dir`). A mode change is a plug or an unplug, so a
+/// second is instant to a person.
const PLAN_POLL_MS: i32 = 1000;
/// One plan file's contents as a timeout: seconds on a line, nothing else.
@@ -124,8 +127,15 @@ pub struct IdleManager {
/// /run/cce; None while a file is absent or unparsable.
plan_display_off_ms: Option<i64>,
plan_sleep_ms: Option<i64>,
- /// Polls the plan files; see `PLAN_POLL_MS`.
+ /// Polls the plan files when they cannot be watched; see `PLAN_POLL_MS`.
plan_timer: *mut ffi::wl_event_source,
+ /// An inotify fd on the plan directory, and its event-loop source: the
+ /// plan files are re-read when the directory reports a change, and
+ /// nothing runs at rest. -1 / null while polling instead. Until
+ /// 2026-10-05 the timer above stat'ed both files every second, forever —
+ /// the one thing that still ticked in an idle compositor.
+ plan_inotify: i32,
+ plan_inotify_source: *mut ffi::wl_event_source,
/// `plan_stamp` of each file at the last poll: the files are only
/// re-read when one changes.
plan_stamps: [u128; 2],
@@ -212,7 +222,12 @@ impl IdleManager {
self.plan_display_off_ms = None;
self.plan_sleep_ms = None;
self.plan_stamps = [0, 0];
- ffi::wl_event_source_timer_update(self.plan_timer, PLAN_POLL_MS);
+ // Zeroed by `Server::new`: 0 is a real fd (stdin), so say "none".
+ self.plan_inotify = -1;
+ self.plan_inotify_source = std::ptr::null_mut();
+ if !self.watch_plan_dir(event_loop) {
+ ffi::wl_event_source_timer_update(self.plan_timer, PLAN_POLL_MS);
+ }
self.sleep_command = None;
self.inhibited = false;
self.inhibitors = None;
@@ -250,6 +265,7 @@ impl IdleManager {
ffi::wl_event_source_remove(self.plan_timer);
self.plan_timer = std::ptr::null_mut();
}
+ self.unwatch_plan_dir();
if !self.lock_fallback_timer.is_null() {
ffi::wl_event_source_remove(self.lock_fallback_timer);
self.lock_fallback_timer = std::ptr::null_mut();
@@ -299,7 +315,54 @@ impl IdleManager {
)
}
- /// From `plan_timer`: re-read the plan files when either changed, and
+ /// Watch the plan directory for any file appearing, changing or going
+ /// away. False — and the caller polls — when the directory does not
+ /// exist or cannot be watched.
+ unsafe fn watch_plan_dir(&mut self, event_loop: *mut ffi::wl_event_loop) -> bool {
+ let Ok(dir) = std::ffi::CString::new(plan_dir()) else { return false };
+ let fd = libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC);
+ if fd < 0 {
+ return false;
+ }
+ let mask = libc::IN_CLOSE_WRITE
+ | libc::IN_MOVED_TO
+ | libc::IN_MOVED_FROM
+ | libc::IN_CREATE
+ | libc::IN_DELETE
+ | libc::IN_DELETE_SELF
+ | libc::IN_MOVE_SELF;
+ if libc::inotify_add_watch(fd, dir.as_ptr(), mask) < 0 {
+ libc::close(fd);
+ return false;
+ }
+ let source = ffi::wl_event_loop_add_fd(
+ event_loop,
+ fd,
+ ffi::WL_EVENT_READABLE as u32,
+ Some(handle_plan_inotify),
+ self as *mut IdleManager as *mut _,
+ );
+ if source.is_null() {
+ libc::close(fd);
+ return false;
+ }
+ self.plan_inotify = fd;
+ self.plan_inotify_source = source;
+ true
+ }
+
+ unsafe fn unwatch_plan_dir(&mut self) {
+ if !self.plan_inotify_source.is_null() {
+ ffi::wl_event_source_remove(self.plan_inotify_source);
+ self.plan_inotify_source = std::ptr::null_mut();
+ }
+ if self.plan_inotify >= 0 {
+ libc::close(self.plan_inotify);
+ self.plan_inotify = -1;
+ }
+ }
+
+ /// From `plan_timer` or the directory watch: re-read the plan files when either changed, and
/// put the new timeouts in force from now.
pub unsafe fn poll_plan(&mut self) {
let (off_path, sleep_path) = (plan_path(PLAN_DISPLAY_OFF_FILE), plan_path(PLAN_SLEEP_FILE));
@@ -547,9 +610,47 @@ fn plan_field(ms: Option<i64>) -> String {
ms.map(|v| format!("{}s", v / 1000)).unwrap_or_else(|| "none".to_string())
}
+/// The plan directory changed: drain the events, then re-read. The
+/// directory itself going away ends the watch, and polling takes over.
+unsafe extern "C" fn handle_plan_inotify(_fd: i32, _mask: u32, data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+ let idle = &mut *(data as *mut IdleManager);
+ let mut buf = [0u8; 4096];
+ let mut dir_gone = false;
+ loop {
+ let n = libc::read(idle.plan_inotify, buf.as_mut_ptr() as *mut _, buf.len());
+ if n <= 0 {
+ break;
+ }
+ let mut off = 0usize;
+ while off + std::mem::size_of::<libc::inotify_event>() <= n as usize {
+ let ev = std::ptr::read_unaligned(buf.as_ptr().add(off) as *const libc::inotify_event);
+ if ev.mask & (libc::IN_IGNORED | libc::IN_DELETE_SELF | libc::IN_MOVE_SELF) != 0 {
+ dir_gone = true;
+ }
+ off += std::mem::size_of::<libc::inotify_event>() + ev.len as usize;
+ }
+ }
+ if dir_gone {
+ log::info!("idle: power-plan directory went away; polling for it");
+ idle.unwatch_plan_dir();
+ if !idle.plan_timer.is_null() {
+ ffi::wl_event_source_timer_update(idle.plan_timer, PLAN_POLL_MS);
+ }
+ }
+ idle.poll_plan();
+ 0
+}
+
unsafe extern "C" fn handle_plan_poll(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
let idle = &mut *(data as *mut IdleManager);
idle.poll_plan();
+ // The directory may exist now: watch it, and stop polling.
+ let event_loop = ffi::wl_display_get_event_loop((*idle.server).wl_server);
+ if idle.watch_plan_dir(event_loop) {
+ log::info!("idle: watching the power-plan directory");
+ idle.poll_plan();
+ return 0;
+ }
// wl timers fire once; re-arm for the next look.
if !idle.plan_timer.is_null() {
ffi::wl_event_source_timer_update(idle.plan_timer, PLAN_POLL_MS);
diff --git a/src/server/server.rs b/src/server/server.rs
index e6a2f536..d0616ca0 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -250,13 +250,13 @@ pub unsafe fn wl_list_remove_and_reinit(elm: *mut WlList) {
}
pub unsafe fn wl_signal_add(signal: *mut ffi::wl_signal, listener: *mut ffi::wl_listener) {
- log::info!("wl_signal_add: signal={:?}, listener={:?}", signal, listener);
+ log::trace!("wl_signal_add: signal={:?}, listener={:?}", signal, listener);
if signal.is_null() {
log::error!("wl_signal_add: signal is null!");
return;
}
let sig_list = &mut (*signal).listener_list as *mut ffi::wl_list as *mut WlList;
- log::info!("wl_signal_add: sig_list={:?}, prev={:?}, next={:?}", sig_list, (*sig_list).prev, (*sig_list).next);
+ log::trace!("wl_signal_add: sig_list={:?}, prev={:?}, next={:?}", sig_list, (*sig_list).prev, (*sig_list).next);
let listener_custom = listener as *mut WlListener;
wl_list_insert((*sig_list).prev, &mut (*listener_custom).link);
}
@@ -1016,6 +1016,7 @@ impl Default for Server {
std::ptr::write(&mut (*server.as_mut_ptr()).wm.startup_pids, Vec::new());
std::ptr::write(&mut (*server.as_mut_ptr()).wm.status_sender, None);
std::ptr::write(&mut (*server.as_mut_ptr()).wm.last_saved_state_json, None);
+ std::ptr::write(&mut (*server.as_mut_ptr()).wm.proc_args_cache, std::collections::HashMap::new());
std::ptr::write(&mut (*server.as_mut_ptr()).wm.min_sizes, crate::min_sizes::MinSizes::default());
std::ptr::write(&mut (*server.as_mut_ptr()).layer_shell.surfaces, crate::slotmap::SlotMap::new());
std::ptr::write(&mut (*server.as_mut_ptr()).inspector, crate::inspector::Inspector::new());
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index f025d1fa..fc5ab1b9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -521,7 +521,15 @@ pub struct WindowManager {
/// serialization is byte-identical keeps the file exactly as current as
/// before while making an idle session silent on disk. `None` until the
/// first write, so a fresh start always writes once.
+ ///
+ /// Compared as compact JSON: the pretty form is only built for a write.
pub last_saved_state_json: Option<String>,
+ /// `proc_args` per window pid. A save reads every window's argv, and each
+ /// read is `/proc/<pid>/cmdline` plus a stat per `PATH` entry and two
+ /// canonicalizes (`path_shadowed_name`) — once a second while anything
+ /// moves. A live window's pid cannot be reused, and the entries of pids
+ /// no longer on a window are dropped at each save.
+ pub proc_args_cache: std::collections::HashMap<i32, Vec<String>>,
/// X11 apps' learned minimum sizes, persisted in `min-sizes.json`.
pub min_sizes: crate::min_sizes::MinSizes,
/// One-shot placement hints (`place-next <app_id> <x> <y>` over IPC):
@@ -1285,6 +1293,7 @@ impl WindowManager {
// (app_id, title, program) of each live shy window, whose own
// entries are scrubbed after the loop — see the skip below.
let mut shy: Vec<(String, String, String)> = Vec::new();
+ let mut live_pids: Vec<i32> = Vec::new();
for &w in self.windows.iter() {
if w.is_null() || (*w).closed || matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init) {
@@ -1324,7 +1333,17 @@ impl WindowManager {
let title = (*w).get_title_string().unwrap_or_default();
let pid = (*w).unreliable_pid();
- let mut args = proc_args(pid);
+ live_pids.push(pid);
+ let mut args = match self.proc_args_cache.get(&pid) {
+ Some(args) if pid > 0 => args.clone(),
+ _ => {
+ let args = proc_args(pid);
+ if pid > 0 && !args.is_empty() {
+ self.proc_args_cache.insert(pid, args.clone());
+ }
+ args
+ }
+ };
// A shy helper (`Window::is_shy`) is never restored — its app
// places it — so saving it can only do harm: it takes the app's
// one slot in `last_window_states`, holding a geometry nothing
@@ -1440,20 +1459,30 @@ impl WindowManager {
grid: Some(crate::policy::state::SavedGrid::from_params(&self.layout.snap_params())),
};
- if let Ok(json_str) = serde_json::to_string_pretty(&state) {
- if self.last_saved_state_json.as_deref() == Some(json_str.as_str()) {
- return;
- }
- log::debug!("Saving state to {}", path_str);
- let path = std::path::Path::new(&path_str);
- if let Some(parent) = path.parent() {
- let _ = std::fs::create_dir_all(parent);
- }
- match std::fs::write(path, &json_str) {
- // Only remember it once it is actually on disk, so a failed
- // write is retried on the next transaction rather than latched.
- Ok(()) => self.last_saved_state_json = Some(json_str),
- Err(e) => log::error!("Failed to write state file: {}", e),
+ self.proc_args_cache.retain(|pid, _| live_pids.contains(pid));
+
+ // Unchanged is skipped, compared compactly: the pretty form costs
+ // more and is only wanted for the file.
+ let Ok(compact) = serde_json::to_string(&state) else { return };
+ if self.last_saved_state_json.as_deref() == Some(compact.as_str()) {
+ return;
+ }
+ let Ok(json_str) = serde_json::to_string_pretty(&state) else { return };
+ log::debug!("Saving state to {}", path_str);
+ let path = std::path::Path::new(&path_str);
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ // Whole or not at all: a crash mid-write used to leave a truncated
+ // state.json, which the next login could not restore from.
+ let tmp = path.with_extension("json.tmp");
+ match std::fs::write(&tmp, &json_str).and_then(|()| std::fs::rename(&tmp, path)) {
+ // Only remember it once it is actually on disk, so a failed
+ // write is retried on the next transaction rather than latched.
+ Ok(()) => self.last_saved_state_json = Some(compact),
+ Err(e) => {
+ let _ = std::fs::remove_file(&tmp);
+ log::error!("Failed to write state file: {}", e);
}
}
}
@@ -2742,7 +2771,10 @@ impl WindowManager {
if (*w).is_linked() {
continue;
}
- if (*w).get_app_id_string().map_or(false, |id| id.starts_with("cce-status")) {
+ // Borrowed (`is_status_bar`), not `get_app_id_string`: this runs
+ // three times a transaction — once a vblank during a drag — and
+ // allocated a String per window to prefix-match it.
+ if (*w).is_status_bar() {
log::info!("[LinkDbg] UNLINKED-MAPPED at {}: app={:?} link.prev_self={} link.prev_null={}",
phase,
(*w).get_app_id_string(),
@@ -2784,7 +2816,7 @@ impl WindowManager {
if !matches!((*w).state, crate::window::WindowState::Mapped) {
continue;
}
- if !(*w).get_app_id_string().map_or(false, |id| id.starts_with("cce-status")) {
+ if !(*w).is_status_bar() {
continue;
}
let node = &(*w).node.link as *const ffi::wl_list as *mut WlList;