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

commit81e060f20e954115049a70077c9e7607af536178
parent347427f498
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 19:28
idle: name the clients holding an inhibitor

`ccectl idle status` said inhibited=true and nothing more, so a display
that never darkened could not be traced to the app keeping it on. The
inhibit manager now visits every inhibitor (not just the first) and
hands the idle manager a deduplicated name per holder: a window's app
id, its title when it set none, `layer:<namespace>` for a layer
surface, and the bare kind for the lock surface, shell surfaces and X11
override-redirects. The status line gains inhibited_by="a,b" after
inhibited=, and the log names the set at info level when it changes.
Timers still rearm only when the flag flips.

Verified in a shadow with mpv: inhibited_by="mpv" while playing, empty
once it exits, with matching log lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 src/cce_ctl.rs                     |  2 +-
 src/server/idle.rs                 | 38 ++++++++++++++++----
 src/server/idle_inhibit_manager.rs | 72 +++++++++++++++++++++++++++++++-------
 3 files changed, 92 insertions(+), 20 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 36781b11..bea4e2e0 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -64,7 +64,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  overview                   # toggle; the exit lands on the focused window, not the pointer");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  outputs [--json]           # list outputs: mode, scale, logical size, physical mm, px/mm, and where the mm came from");
-    print("  idle [status]              # idle timeouts: configured seconds, idle time, inhibited/displays_off/sleeping");
+    print("  idle [status]              # idle timeouts: configured seconds, idle time, inhibited and by whom (app ids), displays_off/sleeping");
     print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, run the sleep command, darken/wake outputs");
     print("  idle timeouts <display_off_s> <sleep_s>  # set the timeouts live (0 = off); config.kdl `idle { }` on reload");
     print("  status-hide-mode [true|false]");
diff --git a/src/server/idle.rs b/src/server/idle.rs
index 57fcc01f..e8e0c8b6 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -61,6 +61,10 @@ pub struct IdleManager {
     sleep_command: Option<String>,
     /// An idle-inhibitor is active: timers are held disarmed.
     inhibited: bool,
+    /// Who holds one, by app id (a window) or surface kind, deduplicated,
+    /// in creation order; empty when nobody does. `Option` for the same
+    /// reason as `sleep_command`: null-niche safe under the zeroed init.
+    inhibitors: Option<Vec<String>>,
     /// The display timeout fired and outputs were darkened by us.
     displays_off: bool,
     /// The sleep command was spawned; cleared by the next activity.
@@ -103,6 +107,7 @@ impl IdleManager {
         self.sleep_ms = 0;
         self.sleep_command = None;
         self.inhibited = false;
+        self.inhibitors = None;
         self.displays_off = false;
         self.sleeping = false;
         self.armed_at_ms = 0;
@@ -165,15 +170,33 @@ impl IdleManager {
         self.rearm(changed);
     }
 
-    /// From `IdleInhibitManager::check_active`: an inhibitor appeared or
-    /// the last one went away.
-    pub unsafe fn set_inhibited(&mut self, inhibited: bool) {
-        if self.inhibited == inhibited {
+    /// From `IdleInhibitManager::check_active`: the set of clients holding
+    /// an inhibitor changed. The names are what `ccectl idle status` and the
+    /// log report, so a display that never darkens can be traced to the app
+    /// keeping it on rather than to a bare `inhibited=true`.
+    pub unsafe fn set_inhibitors(&mut self, names: Vec<String>) {
+        if self.inhibitors.as_deref().unwrap_or(&[]) == names.as_slice() {
             return;
         }
+        let inhibited = !names.is_empty();
+        if inhibited {
+            log::info!("idle: inhibited by {}", names.join(", "));
+        } else {
+            log::info!("idle: no inhibitors left");
+        }
+        self.inhibitors = Some(names);
+        let flipped = self.inhibited != inhibited;
         self.inhibited = inhibited;
-        log::debug!("idle: inhibited={}", inhibited);
-        self.rearm(true);
+        // A change of holder while still inhibited leaves the timers as they
+        // are: disarmed. Only the flag flipping rearms.
+        if flipped {
+            self.rearm(true);
+        }
+    }
+
+    /// The inhibitor names as the status line prints them.
+    fn inhibited_by(&self) -> String {
+        self.inhibitors.as_deref().unwrap_or(&[]).join(",")
     }
 
     /// Arm (or disarm, when inhibited or unconfigured) both timers from
@@ -258,12 +281,13 @@ impl IdleManager {
     pub fn status(&self) -> String {
         let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
         format!(
-            "display_off={}s sleep={}s command={:?} idle={}s inhibited={} displays_off={} sleeping={}\n",
+            "display_off={}s sleep={}s command={:?} idle={}s inhibited={} inhibited_by={:?} displays_off={} sleeping={}\n",
             self.display_off_ms / 1000,
             self.sleep_ms / 1000,
             self.sleep_command(),
             idle_s,
             self.inhibited,
+            self.inhibited_by(),
             self.displays_off,
             self.sleeping
         )
diff --git a/src/server/idle_inhibit_manager.rs b/src/server/idle_inhibit_manager.rs
index 84cc1357..2c33633f 100644
--- a/src/server/idle_inhibit_manager.rs
+++ b/src/server/idle_inhibit_manager.rs
@@ -46,36 +46,84 @@ impl IdleInhibitManager {
         }
     }
 
+    /// Recount the live inhibitors and hand the idle manager the list of who
+    /// holds one, so `ccectl idle status` can name them. Every inhibitor is
+    /// visited (no early exit): two apps holding one are both reported.
     pub unsafe fn check_active(&self) {
-        let mut inhibited = false;
-        
+        let mut names: Vec<String> = Vec::new();
+
         let inhibitors_head = &self.inhibitors as *const ffi::wl_list as *mut crate::server::WlList;
         let mut curr = (*inhibitors_head).next;
         while curr != inhibitors_head {
             let next = (*curr).next;
             let inhibitor = crate::container_of!(curr, IdleInhibitor, link);
-            
+
             let surface = (*(*inhibitor).wlr_inhibitor).surface;
             if let Some(node_data) = SceneNodeData::from_surface(surface) {
-                match node_data.data {
-                    crate::scene_node_data::SceneNodeDataVal::Window(_) |
-                    crate::scene_node_data::SceneNodeDataVal::ShellSurface(_) |
-                    crate::scene_node_data::SceneNodeDataVal::LockSurface(_) |
-                    crate::scene_node_data::SceneNodeDataVal::LayerSurface(_) |
-                    crate::scene_node_data::SceneNodeDataVal::OverrideRedirect(_) => {
-                        inhibited = true;
-                        break;
+                use crate::scene_node_data::SceneNodeDataVal::*;
+                let name = match node_data.data {
+                    Window(w) => inhibitor_name(
+                        "window",
+                        (*w).get_app_id_string().as_deref(),
+                        (*w).get_title_string().as_deref(),
+                    ),
+                    ShellSurface(_) => inhibitor_name("shell-surface", None, None),
+                    LockSurface(_) => inhibitor_name("lock-surface", None, None),
+                    LayerSurface(l) => {
+                        let ns = (*(*l).wlr_layer_surface).namespace;
+                        let ns = (!ns.is_null())
+                            .then(|| std::ffi::CStr::from_ptr(ns).to_string_lossy().into_owned());
+                        inhibitor_name("layer", ns.as_deref(), None)
                     }
+                    OverrideRedirect(_) => inhibitor_name("xwayland-override-redirect", None, None),
+                };
+                if !names.contains(&name) {
+                    names.push(name);
                 }
             }
             curr = next;
         }
 
+        let inhibited = !names.is_empty();
         let notifier = (*self.server).input_manager.idle_notifier;
         if !notifier.is_null() {
             ffi::wlr_idle_notifier_v1_set_inhibited(notifier, inhibited);
         }
-        (*self.server).idle.set_inhibited(inhibited);
+        (*self.server).idle.set_inhibitors(names);
+    }
+}
+
+/// What the status line calls one inhibitor: a window's app id, its title
+/// when the client never set one, and the bare kind when it set neither; a
+/// layer surface shows as `layer:<namespace>`. Kinds are stable words, so a
+/// script can tell a browser holding the display from the lock screen.
+pub fn inhibitor_name(kind: &str, id: Option<&str>, title: Option<&str>) -> String {
+    let id = id.filter(|s| !s.is_empty());
+    let title = title.filter(|s| !s.is_empty());
+    match (kind, id, title) {
+        ("window", Some(id), _) => id.to_string(),
+        ("window", None, Some(title)) => format!("window:{}", title),
+        (kind, Some(id), _) => format!("{}:{}", kind, id),
+        (kind, None, _) => kind.to_string(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::inhibitor_name;
+
+    #[test]
+    fn a_window_is_its_app_id_then_its_title_then_the_kind() {
+        assert_eq!(inhibitor_name("window", Some("cce-browser"), Some("Video")), "cce-browser");
+        assert_eq!(inhibitor_name("window", None, Some("Video")), "window:Video");
+        assert_eq!(inhibitor_name("window", Some(""), None), "window");
+    }
+
+    #[test]
+    fn other_surfaces_are_their_kind_with_an_optional_id() {
+        assert_eq!(inhibitor_name("layer", Some("bar"), None), "layer:bar");
+        assert_eq!(inhibitor_name("layer", None, None), "layer");
+        assert_eq!(inhibitor_name("lock-surface", None, None), "lock-surface");
     }
 }