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

src/server/idle.rs (35.5K)

  1 // SPDX-License-Identifier: GPL-3.0-only
  2 
  3 //! Idle timeouts: turn the displays off after `display_off` seconds without
  4 //! input, and run the sleep command after `sleep` seconds. Both are 0 (off)
  5 //! until `idle { }` in config.kdl sets them.
  6 //!
  7 //! Activity is whatever `Seat::handle_activity` already counts as activity
  8 //! for the `ext-idle-notify` clients — pointer motion, buttons, axes,
  9 //! gestures, tablet, and (since this module) keys. An `idle-inhibit`
 10 //! inhibitor on a mapped surface (a video player) pauses both timers, the
 11 //! same signal `wlr_idle_notifier_v1_set_inhibited` gets.
 12 //!
 13 //! The timeouts come from `idle { }` in config.kdl, but the System
 14 //! Interface's Power plan can override either per power mode: its applier
 15 //! writes [`PLAN_DISPLAY_OFF_FILE`] / [`PLAN_SLEEP_FILE`] under [`PLAN_DIR`] as root on plug,
 16 //! unplug and boot (seconds, 0 = never), and this module polls both once a
 17 //! second, so battery can darken the display sooner than the desk does
 18 //! without a reload. A missing file means the config's value.
 19 //!
 20 //! "Display off" is the soft-disable the wlr-output-power-management
 21 //! protocol already drives (`OutputStateValue::DisabledSoft` — the output
 22 //! stays in the layout, nothing is re-arranged, and no frame events fire
 23 //! while it is dark, so an idle desktop also stops rendering). Only outputs
 24 //! this module darkened (`Output::idle_off`) are woken again: one a client
 25 //! turned off with `wlopm` stays as that client left it.
 26 //!
 27 //! Resume: `systemctl suspend` returns as soon as the job is queued, so the
 28 //! command's exit says nothing. Instead the wlroots session's `active`
 29 //! signal — which fires when the seat comes back from suspend, and on a VT
 30 //! switch back — is treated as activity, so a lid-open shows the screen
 31 //! without waiting for a key.
 32 
 33 use crate::ffi;
 34 use crate::server::{Server, WlListener, wl_signal_add, wl_listener_remove};
 35 use crate::output::{Output, OutputStateValue};
 36 
 37 pub const DEFAULT_SLEEP_COMMAND: &str = "systemctl suspend";
 38 
 39 /// How long the sleep waits for the session to finish locking before it
 40 /// goes ahead anyway. The desktop is hidden from the moment the lock starts
 41 /// (`LockManager::lock_now`), so a lock still settling at that point is a
 42 /// blank screen, not an open one; staying awake forever on a wedged output
 43 /// would be the worse failure.
 44 const LOCK_BEFORE_SLEEP_MS: i32 = 3000;
 45 
 46 /// The `idle { }` block, in seconds; 0 disables a timeout.
 47 #[derive(Debug, Clone, PartialEq, Eq)]
 48 pub struct IdleConfig {
 49     pub display_off_s: i64,
 50     pub sleep_s: i64,
 51     pub sleep_command: Option<String>,
 52 }
 53 
 54 impl Default for IdleConfig {
 55     fn default() -> Self {
 56         Self { display_off_s: 0, sleep_s: 0, sleep_command: None }
 57     }
 58 }
 59 
 60 /// Re-arming the timers on every pointer-motion event would be a pair of
 61 /// `timerfd_settime` calls per event; once a second is plenty for timeouts
 62 /// measured in minutes.
 63 const REARM_MIN_MS: u64 = 1000;
 64 
 65 /// The Power plan's per-mode timeouts, written by `cce-power-apply`
 66 /// (`cce_settings::power_plan::IDLE_DISPLAY_OFF_PATH` / `IDLE_SLEEP_PATH`;
 67 /// the paths are repeated here because that crate is an app, not a
 68 /// dependency). Seconds, 0 = never; absent = use the config.
 69 pub const PLAN_DIR: &str = "/run/cce";
 70 pub const PLAN_DISPLAY_OFF_FILE: &str = "idle_display_off";
 71 pub const PLAN_SLEEP_FILE: &str = "idle_sleep";
 72 
 73 /// Where one plan file lives. `CCE_IDLE_PLAN_DIR` moves the directory for
 74 /// one process, for testing: /run/cce is root's, and a shadow session must
 75 /// not read the live machine's plan files either.
 76 fn plan_path(file: &str) -> String {
 77     format!("{}/{}", plan_dir(), file)
 78 }
 79 
 80 fn plan_dir() -> String {
 81     std::env::var("CCE_IDLE_PLAN_DIR").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| PLAN_DIR.to_string())
 82 }
 83 
 84 /// How often the plan files are stat'ed when the directory cannot be
 85 /// watched (`watch_plan_dir`). A mode change is a plug or an unplug, so a
 86 /// second is instant to a person.
 87 const PLAN_POLL_MS: i32 = 1000;
 88 
 89 /// One plan file's contents as a timeout: seconds on a line, nothing else.
 90 pub fn parse_plan_secs(text: &str) -> Option<i64> {
 91     text.trim().parse::<u32>().ok().map(i64::from)
 92 }
 93 
 94 /// The timeout in force: the plan's when it has one, else the config's.
 95 fn effective_ms(cfg_ms: i64, plan_ms: Option<i64>) -> i64 {
 96     plan_ms.unwrap_or(cfg_ms)
 97 }
 98 
 99 /// A change stamp for one plan file: its mtime in ns plus one, or 0 when it
100 /// is absent, so appearing, vanishing and rewriting all read as a change.
101 fn plan_stamp(path: &str) -> u128 {
102     std::fs::metadata(path)
103         .ok()
104         .and_then(|m| m.modified().ok())
105         .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
106         .map(|d| d.as_nanos() + 1)
107         .unwrap_or(0)
108 }
109 
110 fn read_plan_ms(path: &str) -> Option<i64> {
111     parse_plan_secs(&std::fs::read_to_string(path).ok()?).map(|s| s * 1000)
112 }
113 
114 pub struct IdleManager {
115     pub server: *mut Server,
116     display_timer: *mut ffi::wl_event_source,
117     sleep_timer: *mut ffi::wl_event_source,
118     /// The timeouts in force, in ms; 0 = disabled. The plan's when it has
119     /// one, else the config's (`effective_ms`).
120     display_off_ms: i64,
121     sleep_ms: i64,
122     /// The `idle { }` block's own values, kept apart so a plan override can
123     /// be lifted again when its file goes away.
124     cfg_display_off_ms: i64,
125     cfg_sleep_ms: i64,
126     /// The Power plan's per-mode override for each, from the files under
127     /// /run/cce; None while a file is absent or unparsable.
128     plan_display_off_ms: Option<i64>,
129     plan_sleep_ms: Option<i64>,
130     /// Polls the plan files when they cannot be watched; see `PLAN_POLL_MS`.
131     plan_timer: *mut ffi::wl_event_source,
132     /// An inotify fd on the plan directory, and its event-loop source: the
133     /// plan files are re-read when the directory reports a change, and
134     /// nothing runs at rest. -1 / null while polling instead. Until
135     /// 2026-10-05 the timer above stat'ed both files every second, forever —
136     /// the one thing that still ticked in an idle compositor.
137     plan_inotify: i32,
138     plan_inotify_source: *mut ffi::wl_event_source,
139     /// `plan_stamp` of each file at the last poll: the files are only
140     /// re-read when one changes.
141     plan_stamps: [u128; 2],
142     /// `None` means `DEFAULT_SLEEP_COMMAND`. (An `Option<String>` is
143     /// null-niche safe under `Server::new`'s zeroed init; a bare `String`
144     /// is not.)
145     sleep_command: Option<String>,
146     /// An idle-inhibitor is active: timers are held disarmed.
147     inhibited: bool,
148     /// Who holds one, by app id (a window) or surface kind, deduplicated,
149     /// in creation order; empty when nobody does. `Option` for the same
150     /// reason as `sleep_command`: null-niche safe under the zeroed init.
151     inhibitors: Option<Vec<String>>,
152     /// The Wayland half of `inhibitors`, as `IdleInhibitManager` last
153     /// reported it; `external` is the other half.
154     wayland_inhibitors: Option<Vec<String>>,
155     /// External leases (see the module doc), in grant order. `Option` for
156     /// the zeroed init, like `inhibitors`.
157     external: Option<Vec<ExternalLease>>,
158     /// Fires at the earliest lease expiry; disarmed when there are none.
159     lease_timer: *mut ffi::wl_event_source,
160     /// The display timeout fired and outputs were darkened by us.
161     displays_off: bool,
162     /// The sleep command was spawned; cleared by the next activity.
163     sleeping: bool,
164     /// A sleep is waiting for the session lock to complete (`on_locked`),
165     /// or for `lock_fallback_timer`, whichever comes first.
166     sleep_after_lock: bool,
167     lock_fallback_timer: *mut ffi::wl_event_source,
168     /// Monotonic ms of the last (re)arm and of the last activity.
169     armed_at_ms: u64,
170     last_activity_ms: u64,
171     session_active: ffi::wl_listener,
172     session_listening: bool,
173 }
174 
175 /// One `idle inhibit` lease.
176 #[derive(Debug, Clone, PartialEq, Eq)]
177 pub struct ExternalLease {
178     pub token: String,
179     pub who: String,
180     pub expires_ms: u64,
181 }
182 
183 /// The longest lease one request may take: a holder that wants longer
184 /// renews. Bounds how long a dead holder's lease can outlive it.
185 pub const MAX_LEASE_S: u64 = 600;
186 
187 /// `idle inhibit <token> <ttl_s> <who...>`: the lease it asks for, or the
188 /// usage error. `who` is what `idle status` reports (`steam`, `org.mozilla.firefox`).
189 pub fn parse_lease(args: &[&str], now: u64) -> Result<ExternalLease, String> {
190     let usage = || "error: idle inhibit <token> <ttl_s 1-600> <who>\n".to_string();
191     let [token, ttl, who @ ..] = args else { return Err(usage()) };
192     if who.is_empty() {
193         return Err(usage());
194     }
195     let ttl: u64 = ttl.parse().map_err(|_| usage())?;
196     if ttl == 0 || ttl > MAX_LEASE_S {
197         return Err(usage());
198     }
199     Ok(ExternalLease { token: token.to_string(), who: who.join(" "), expires_ms: now + ttl * 1000 })
200 }
201 
202 /// The inhibitor names `idle status` and the log show: the Wayland holders,
203 /// then each external holder once, as `portal:<who>`.
204 pub fn merge_inhibitors(wayland: &[String], external: &[ExternalLease]) -> Vec<String> {
205     let mut names: Vec<String> = wayland.to_vec();
206     for lease in external {
207         let name = format!("portal:{}", lease.who);
208         if !names.contains(&name) {
209             names.push(name);
210         }
211     }
212     names
213 }
214 
215 fn now_ms() -> u64 {
216     let ts = crate::util::timestamp();
217     ts.tv_sec as u64 * 1000 + ts.tv_nsec as u64 / 1_000_000
218 }
219 
220 impl IdleManager {
221     pub unsafe fn init(&mut self, server: *mut Server) -> Result<(), &'static str> {
222         self.server = server;
223         let event_loop = ffi::wl_display_get_event_loop((*server).wl_server);
224         self.display_timer = ffi::wl_event_loop_add_timer(
225             event_loop,
226             Some(handle_display_timeout),
227             self as *mut IdleManager as *mut _,
228         );
229         if self.display_timer.is_null() {
230             return Err("Failed to create idle display timer");
231         }
232         self.sleep_timer = ffi::wl_event_loop_add_timer(
233             event_loop,
234             Some(handle_sleep_timeout),
235             self as *mut IdleManager as *mut _,
236         );
237         if self.sleep_timer.is_null() {
238             ffi::wl_event_source_remove(self.display_timer);
239             self.display_timer = std::ptr::null_mut();
240             return Err("Failed to create idle sleep timer");
241         }
242         self.lock_fallback_timer = ffi::wl_event_loop_add_timer(
243             event_loop,
244             Some(handle_lock_fallback),
245             self as *mut IdleManager as *mut _,
246         );
247         if self.lock_fallback_timer.is_null() {
248             ffi::wl_event_source_remove(self.display_timer);
249             ffi::wl_event_source_remove(self.sleep_timer);
250             self.display_timer = std::ptr::null_mut();
251             self.sleep_timer = std::ptr::null_mut();
252             return Err("Failed to create idle lock-fallback timer");
253         }
254         self.plan_timer = ffi::wl_event_loop_add_timer(
255             event_loop,
256             Some(handle_plan_poll),
257             self as *mut IdleManager as *mut _,
258         );
259         if self.plan_timer.is_null() {
260             ffi::wl_event_source_remove(self.display_timer);
261             ffi::wl_event_source_remove(self.sleep_timer);
262             self.display_timer = std::ptr::null_mut();
263             self.sleep_timer = std::ptr::null_mut();
264             return Err("Failed to create idle plan-poll timer");
265         }
266         self.lease_timer = ffi::wl_event_loop_add_timer(
267             event_loop,
268             Some(handle_lease_expiry),
269             self as *mut IdleManager as *mut _,
270         );
271         if self.lease_timer.is_null() {
272             ffi::wl_event_source_remove(self.display_timer);
273             ffi::wl_event_source_remove(self.sleep_timer);
274             ffi::wl_event_source_remove(self.plan_timer);
275             self.display_timer = std::ptr::null_mut();
276             self.sleep_timer = std::ptr::null_mut();
277             self.plan_timer = std::ptr::null_mut();
278             return Err("Failed to create idle lease timer");
279         }
280         self.display_off_ms = 0;
281         self.sleep_ms = 0;
282         self.cfg_display_off_ms = 0;
283         self.cfg_sleep_ms = 0;
284         self.plan_display_off_ms = None;
285         self.plan_sleep_ms = None;
286         self.plan_stamps = [0, 0];
287         // Zeroed by `Server::new`: 0 is a real fd (stdin), so say "none".
288         self.plan_inotify = -1;
289         self.plan_inotify_source = std::ptr::null_mut();
290         if !self.watch_plan_dir(event_loop) {
291             ffi::wl_event_source_timer_update(self.plan_timer, PLAN_POLL_MS);
292         }
293         self.sleep_command = None;
294         self.inhibited = false;
295         self.inhibitors = None;
296         self.wayland_inhibitors = None;
297         self.external = None;
298         self.displays_off = false;
299         self.sleeping = false;
300         self.sleep_after_lock = false;
301         self.armed_at_ms = 0;
302         self.last_activity_ms = now_ms();
303 
304         // Headless and nested backends have no session; only DRM does.
305         let session = (*server).session;
306         if !session.is_null() {
307             let listener = &mut self.session_active as *mut ffi::wl_listener as *mut WlListener;
308             (*listener).notify = Some(handle_session_active);
309             wl_signal_add(ffi::river_wlr_session_get_active_signal(session), &mut self.session_active);
310             self.session_listening = true;
311         }
312         Ok(())
313     }
314 
315     pub unsafe fn deinit(&mut self) {
316         if self.session_listening {
317             wl_listener_remove(&mut self.session_active);
318             self.session_listening = false;
319         }
320         if !self.display_timer.is_null() {
321             ffi::wl_event_source_remove(self.display_timer);
322             self.display_timer = std::ptr::null_mut();
323         }
324         if !self.sleep_timer.is_null() {
325             ffi::wl_event_source_remove(self.sleep_timer);
326             self.sleep_timer = std::ptr::null_mut();
327         }
328         if !self.plan_timer.is_null() {
329             ffi::wl_event_source_remove(self.plan_timer);
330             self.plan_timer = std::ptr::null_mut();
331         }
332         if !self.lease_timer.is_null() {
333             ffi::wl_event_source_remove(self.lease_timer);
334             self.lease_timer = std::ptr::null_mut();
335         }
336         self.unwatch_plan_dir();
337         if !self.lock_fallback_timer.is_null() {
338             ffi::wl_event_source_remove(self.lock_fallback_timer);
339             self.lock_fallback_timer = std::ptr::null_mut();
340         }
341     }
342 
343     /// Apply an `idle { }` block (config load and `ccectl reload`). A plan
344     /// override in force stays in force: the config is the base it lifts to.
345     pub unsafe fn configure(&mut self, cfg: &IdleConfig) {
346         self.cfg_display_off_ms = cfg.display_off_s.max(0) * 1000;
347         self.cfg_sleep_ms = cfg.sleep_s.max(0) * 1000;
348         self.sleep_command = cfg.sleep_command.clone();
349         self.refresh_effective();
350         log::info!(
351             "idle timeouts: display_off={}s sleep={}s command={:?}{}",
352             self.display_off_ms / 1000,
353             self.sleep_ms / 1000,
354             self.sleep_command(),
355             self.plan_note()
356         );
357         self.rearm(true);
358     }
359 
360     /// Recompute the timeouts in force from the config and the plan.
361     fn refresh_effective(&mut self) {
362         self.display_off_ms = effective_ms(self.cfg_display_off_ms, self.plan_display_off_ms);
363         self.sleep_ms = effective_ms(self.cfg_sleep_ms, self.plan_sleep_ms);
364     }
365 
366     /// True when the Power plan overrides at least one timeout.
367     fn plan_active(&self) -> bool {
368         self.plan_display_off_ms.is_some() || self.plan_sleep_ms.is_some()
369     }
370 
371     /// For log lines: which values the plan is imposing, or nothing.
372     fn plan_note(&self) -> String {
373         if !self.plan_active() {
374             return String::new();
375         }
376         let show = |v: Option<i64>| v.map(|ms| format!("{}s", ms / 1000)).unwrap_or_else(|| "config".to_string());
377         format!(
378             " (power plan: display_off={} sleep={}, config {}s/{}s)",
379             show(self.plan_display_off_ms),
380             show(self.plan_sleep_ms),
381             self.cfg_display_off_ms / 1000,
382             self.cfg_sleep_ms / 1000
383         )
384     }
385 
386     /// Watch the plan directory for any file appearing, changing or going
387     /// away. False — and the caller polls — when the directory does not
388     /// exist or cannot be watched.
389     unsafe fn watch_plan_dir(&mut self, event_loop: *mut ffi::wl_event_loop) -> bool {
390         let Ok(dir) = std::ffi::CString::new(plan_dir()) else { return false };
391         let fd = libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC);
392         if fd < 0 {
393             return false;
394         }
395         let mask = libc::IN_CLOSE_WRITE
396             | libc::IN_MOVED_TO
397             | libc::IN_MOVED_FROM
398             | libc::IN_CREATE
399             | libc::IN_DELETE
400             | libc::IN_DELETE_SELF
401             | libc::IN_MOVE_SELF;
402         if libc::inotify_add_watch(fd, dir.as_ptr(), mask) < 0 {
403             libc::close(fd);
404             return false;
405         }
406         let source = ffi::wl_event_loop_add_fd(
407             event_loop,
408             fd,
409             ffi::WL_EVENT_READABLE as u32,
410             Some(handle_plan_inotify),
411             self as *mut IdleManager as *mut _,
412         );
413         if source.is_null() {
414             libc::close(fd);
415             return false;
416         }
417         self.plan_inotify = fd;
418         self.plan_inotify_source = source;
419         true
420     }
421 
422     unsafe fn unwatch_plan_dir(&mut self) {
423         if !self.plan_inotify_source.is_null() {
424             ffi::wl_event_source_remove(self.plan_inotify_source);
425             self.plan_inotify_source = std::ptr::null_mut();
426         }
427         if self.plan_inotify >= 0 {
428             libc::close(self.plan_inotify);
429             self.plan_inotify = -1;
430         }
431     }
432 
433     /// From `plan_timer` or the directory watch: re-read the plan files when either changed, and
434     /// put the new timeouts in force from now.
435     pub unsafe fn poll_plan(&mut self) {
436         let (off_path, sleep_path) = (plan_path(PLAN_DISPLAY_OFF_FILE), plan_path(PLAN_SLEEP_FILE));
437         let stamps = [plan_stamp(&off_path), plan_stamp(&sleep_path)];
438         if stamps == self.plan_stamps {
439             return;
440         }
441         self.plan_stamps = stamps;
442         self.plan_display_off_ms = read_plan_ms(&off_path);
443         self.plan_sleep_ms = read_plan_ms(&sleep_path);
444         self.refresh_effective();
445         log::info!(
446             "idle timeouts: display_off={}s sleep={}s{}",
447             self.display_off_ms / 1000,
448             self.sleep_ms / 1000,
449             if self.plan_active() { self.plan_note() } else { " (power plan lifted, config values)".to_string() }
450         );
451         self.rearm(true);
452     }
453 
454     pub fn sleep_command(&self) -> &str {
455         self.sleep_command.as_deref().unwrap_or(DEFAULT_SLEEP_COMMAND)
456     }
457 
458     /// Input arrived (or the session came back). Wakes darkened outputs
459     /// and restarts both countdowns.
460     pub unsafe fn on_activity(&mut self) {
461         let now = now_ms();
462         self.last_activity_ms = now;
463         let changed = self.displays_off || self.sleeping;
464         if self.displays_off {
465             self.set_displays(true);
466         }
467         self.sleeping = false;
468         // Someone is here: a sleep still waiting for its lock is called off.
469         // The lock itself stands.
470         if self.sleep_after_lock {
471             self.sleep_after_lock = false;
472             ffi::wl_event_source_timer_update(self.lock_fallback_timer, 0);
473             log::info!("idle: activity while locking; the sleep is off, the lock stays");
474         }
475         self.rearm(changed);
476     }
477 
478     /// From `IdleInhibitManager::check_active`: the set of clients holding
479     /// an inhibitor changed. The names are what `ccectl idle status` and the
480     /// log report, so a display that never darkens can be traced to the app
481     /// keeping it on rather than to a bare `inhibited=true`.
482     pub unsafe fn set_inhibitors(&mut self, names: Vec<String>) {
483         self.wayland_inhibitors = Some(names);
484         self.apply_inhibitors();
485     }
486 
487     /// Recompute the holder list from both halves and act on a change.
488     unsafe fn apply_inhibitors(&mut self) {
489         let names = merge_inhibitors(
490             self.wayland_inhibitors.as_deref().unwrap_or(&[]),
491             self.external.as_deref().unwrap_or(&[]),
492         );
493         if self.inhibitors.as_deref().unwrap_or(&[]) == names.as_slice() {
494             return;
495         }
496         let inhibited = !names.is_empty();
497         if inhibited {
498             log::info!("idle: inhibited by {}", names.join(", "));
499         } else {
500             log::info!("idle: no inhibitors left");
501         }
502         self.inhibitors = Some(names);
503         let flipped = self.inhibited != inhibited;
504         self.inhibited = inhibited;
505         // A change of holder while still inhibited leaves the timers as they
506         // are: disarmed. Only the flag flipping rearms.
507         if flipped {
508             self.rearm(true);
509         }
510     }
511 
512     /// The inhibitor names as the status line prints them.
513     fn inhibited_by(&self) -> String {
514         self.inhibitors.as_deref().unwrap_or(&[]).join(",")
515     }
516 
517     /// Arm (or disarm, when inhibited or unconfigured) both timers from
518     /// now. Throttled unless `force`: pointer motion calls this per event.
519     unsafe fn rearm(&mut self, force: bool) {
520         let now = now_ms();
521         if !force && now.saturating_sub(self.armed_at_ms) < REARM_MIN_MS {
522             return;
523         }
524         self.armed_at_ms = now;
525         let active = !self.inhibited;
526         let display_ms = if active { self.display_off_ms } else { 0 };
527         let sleep_ms = if active { self.sleep_ms } else { 0 };
528         if !self.display_timer.is_null() {
529             ffi::wl_event_source_timer_update(self.display_timer, display_ms.min(i32::MAX as i64) as i32);
530         }
531         if !self.sleep_timer.is_null() {
532             ffi::wl_event_source_timer_update(self.sleep_timer, sleep_ms.min(i32::MAX as i64) as i32);
533         }
534     }
535 
536     /// Darken (`on == false`) every enabled output, or wake the ones this
537     /// module darkened. Goes through the same scheduled-state path as the
538     /// output-power protocol; the next transaction commits it.
539     pub unsafe fn set_displays(&mut self, on: bool) {
540         let server = &mut *self.server;
541         let head = &mut server.om.outputs as *mut ffi::wl_list;
542         let mut link = (*head).next;
543         let mut touched = 0;
544         while link != head {
545             let output = &mut *crate::container_of!(link, Output, link);
546             link = (*link).next;
547             if output.wlr_output.is_null() {
548                 continue;
549             }
550             if on {
551                 if output.idle_off {
552                     output.idle_off = false;
553                     if output.scheduled.state == OutputStateValue::DisabledSoft {
554                         output.scheduled.state = OutputStateValue::Enabled;
555                         touched += 1;
556                     }
557                 }
558             } else if output.scheduled.state == OutputStateValue::Enabled {
559                 output.scheduled.state = OutputStateValue::DisabledSoft;
560                 output.idle_off = true;
561                 touched += 1;
562             }
563         }
564         self.displays_off = !on;
565         log::info!("idle: displays {} ({} output(s))", if on { "on" } else { "off" }, touched);
566         if touched > 0 {
567             server.wm.dirty_windowing();
568         }
569     }
570 
571     /// Run the sleep command (`sh -c`), detached; the server's SIGCHLD
572     /// handler reaps it.
573     pub unsafe fn sleep_now(&mut self) {
574         let cmd = self.sleep_command().to_string();
575         log::info!("idle: sleeping via `{}`", cmd);
576         self.sleeping = true;
577         match nix::unistd::fork() {
578             Ok(nix::unistd::ForkResult::Child) => {
579                 crate::process::cleanup_child();
580                 let sh = std::ffi::CString::new("/bin/sh").unwrap();
581                 let dash_c = std::ffi::CString::new("-c").unwrap();
582                 let cmd_c = std::ffi::CString::new(cmd).unwrap_or_else(|_| std::ffi::CString::new("true").unwrap());
583                 let args = [sh.as_c_str(), dash_c.as_c_str(), cmd_c.as_c_str()];
584                 let _ = nix::unistd::execv(&sh, &args);
585                 std::process::exit(1);
586             }
587             Ok(nix::unistd::ForkResult::Parent { .. }) => {}
588             Err(e) => {
589                 log::error!("idle: failed to fork for sleep command: {}", e);
590                 self.sleeping = false;
591             }
592         }
593     }
594 
595     /// Lock the session, then sleep once it is locked.
596     ///
597     /// Every compositor-initiated sleep goes through here (the idle timeout,
598     /// `ccectl idle sleep`): a sleep used to run with the session unlocked,
599     /// so the desktop was there for whoever woke the machine. A lid close is
600     /// logind's sleep, not ours, and is covered by `sleep_lock`.
601     pub unsafe fn lock_then_sleep(&mut self) {
602         if self.sleeping || self.sleep_after_lock {
603             return;
604         }
605         let lock = &mut (*self.server).lock_manager;
606         lock.lock_now();
607         if lock.state == crate::lock_manager::LockState::Locked {
608             self.sleep_now();
609             return;
610         }
611         log::info!("idle: locking before sleep");
612         self.sleep_after_lock = true;
613         ffi::wl_event_source_timer_update(self.lock_fallback_timer, LOCK_BEFORE_SLEEP_MS);
614     }
615 
616     /// The session finished locking (`LockManager::send_locked`).
617     pub unsafe fn on_locked(&mut self) {
618         if self.sleep_after_lock {
619             self.sleep_after_lock = false;
620             ffi::wl_event_source_timer_update(self.lock_fallback_timer, 0);
621             self.sleep_now();
622         }
623     }
624 
625     /// `ccectl idle` report.
626     /// Grant or renew a lease (`idle inhibit`).
627     unsafe fn grant_lease(&mut self, lease: ExternalLease) {
628         let leases = self.external.get_or_insert_with(Vec::new);
629         match leases.iter_mut().find(|l| l.token == lease.token) {
630             Some(existing) => *existing = lease,
631             None => {
632                 log::info!("idle: lease {} granted to {}", lease.token, lease.who);
633                 leases.push(lease);
634             }
635         }
636         self.arm_lease_timer();
637         self.apply_inhibitors();
638     }
639 
640     /// End leases: one by token, or every one (`None`).
641     unsafe fn end_leases(&mut self, token: Option<&str>) {
642         if let Some(leases) = self.external.as_mut() {
643             leases.retain(|l| token.is_some_and(|t| l.token != t));
644         }
645         self.arm_lease_timer();
646         self.apply_inhibitors();
647     }
648 
649     /// Drop the leases whose time is up.
650     unsafe fn expire_leases(&mut self) {
651         let now = now_ms();
652         if let Some(leases) = self.external.as_mut() {
653             leases.retain(|l| {
654                 let live = l.expires_ms > now;
655                 if !live {
656                     log::warn!("idle: lease {} ({}) lapsed without renewal", l.token, l.who);
657                 }
658                 live
659             });
660         }
661         self.arm_lease_timer();
662         self.apply_inhibitors();
663     }
664 
665     unsafe fn arm_lease_timer(&mut self) {
666         if self.lease_timer.is_null() {
667             return;
668         }
669         let next = self.external.as_deref().unwrap_or(&[]).iter().map(|l| l.expires_ms).min();
670         let ms = match next {
671             // A timer of 0 disarms, so an already-due lease fires in 1 ms.
672             Some(at) => at.saturating_sub(now_ms()).clamp(1, i32::MAX as u64) as i32,
673             None => 0,
674         };
675         ffi::wl_event_source_timer_update(self.lease_timer, ms);
676     }
677 
678     pub fn status(&self) -> String {
679         let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
680         format!(
681             "display_off={}s sleep={}s command={:?} idle={}s inhibited={} inhibited_by={:?} displays_off={} sleeping={} plan_display_off={} plan_sleep={}\n",
682             self.display_off_ms / 1000,
683             self.sleep_ms / 1000,
684             self.sleep_command(),
685             idle_s,
686             self.inhibited,
687             self.inhibited_by(),
688             self.displays_off,
689             self.sleeping,
690             plan_field(self.plan_display_off_ms),
691             plan_field(self.plan_sleep_ms)
692         )
693     }
694 
695     /// `ccectl idle …` — see `cce_ctl.rs` for the surface.
696     pub unsafe fn ipc(&mut self, args: &[&str]) -> String {
697         match args {
698             [] | ["status"] => self.status(),
699             ["wake"] => {
700                 self.on_activity();
701                 "ok\n".to_string()
702             }
703             ["display", "off"] => {
704                 self.set_displays(false);
705                 "ok\n".to_string()
706             }
707             ["display", "on"] => {
708                 self.set_displays(true);
709                 "ok\n".to_string()
710             }
711             ["sleep"] => {
712                 self.lock_then_sleep();
713                 "ok\n".to_string()
714             }
715             ["inhibit", rest @ ..] => match parse_lease(rest, now_ms()) {
716                 Ok(lease) => {
717                     self.grant_lease(lease);
718                     "ok\n".to_string()
719                 }
720                 Err(e) => e,
721             },
722             ["uninhibit", token] => {
723                 self.end_leases(Some(*token));
724                 "ok\n".to_string()
725             }
726             ["inhibit-clear"] => {
727                 self.end_leases(None);
728                 "ok\n".to_string()
729             }
730             ["timeouts", display, sleep] => {
731                 match (display.parse::<i64>(), sleep.parse::<i64>()) {
732                     (Ok(d), Ok(s)) if d >= 0 && s >= 0 => {
733                         let cfg = IdleConfig { display_off_s: d, sleep_s: s, sleep_command: self.sleep_command.clone() };
734                         self.configure(&cfg);
735                         if self.plan_active() {
736                             format!(
737                                 "ok, as the config base; the power plan keeps display_off={}s sleep={}s in force until its mode changes\n",
738                                 self.display_off_ms / 1000,
739                                 self.sleep_ms / 1000
740                             )
741                         } else {
742                             "ok\n".to_string()
743                         }
744                     }
745                     _ => "error: idle timeouts <display_off_s> <sleep_s> (non-negative seconds, 0 = off)\n".to_string(),
746                 }
747             }
748             _ => "error: usage: idle [status|wake|display on|display off|sleep|timeouts <display_off_s> <sleep_s>|inhibit <token> <ttl_s> <who>|uninhibit <token>|inhibit-clear]\n".to_string(),
749         }
750     }
751 }
752 
753 /// `none`, or the plan's seconds, for the status line.
754 fn plan_field(ms: Option<i64>) -> String {
755     ms.map(|v| format!("{}s", v / 1000)).unwrap_or_else(|| "none".to_string())
756 }
757 
758 /// The plan directory changed: drain the events, then re-read. The
759 /// directory itself going away ends the watch, and polling takes over.
760 unsafe extern "C" fn handle_plan_inotify(_fd: i32, _mask: u32, data: *mut std::ffi::c_void) -> std::os::raw::c_int {
761     let idle = &mut *(data as *mut IdleManager);
762     let mut buf = [0u8; 4096];
763     let mut dir_gone = false;
764     loop {
765         let n = libc::read(idle.plan_inotify, buf.as_mut_ptr() as *mut _, buf.len());
766         if n <= 0 {
767             break;
768         }
769         let mut off = 0usize;
770         while off + std::mem::size_of::<libc::inotify_event>() <= n as usize {
771             let ev = std::ptr::read_unaligned(buf.as_ptr().add(off) as *const libc::inotify_event);
772             if ev.mask & (libc::IN_IGNORED | libc::IN_DELETE_SELF | libc::IN_MOVE_SELF) != 0 {
773                 dir_gone = true;
774             }
775             off += std::mem::size_of::<libc::inotify_event>() + ev.len as usize;
776         }
777     }
778     if dir_gone {
779         log::info!("idle: power-plan directory went away; polling for it");
780         idle.unwatch_plan_dir();
781         if !idle.plan_timer.is_null() {
782             ffi::wl_event_source_timer_update(idle.plan_timer, PLAN_POLL_MS);
783         }
784     }
785     idle.poll_plan();
786     0
787 }
788 
789 unsafe extern "C" fn handle_plan_poll(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
790     let idle = &mut *(data as *mut IdleManager);
791     idle.poll_plan();
792     // The directory may exist now: watch it, and stop polling.
793     let event_loop = ffi::wl_display_get_event_loop((*idle.server).wl_server);
794     if idle.watch_plan_dir(event_loop) {
795         log::info!("idle: watching the power-plan directory");
796         idle.poll_plan();
797         return 0;
798     }
799     // wl timers fire once; re-arm for the next look.
800     if !idle.plan_timer.is_null() {
801         ffi::wl_event_source_timer_update(idle.plan_timer, PLAN_POLL_MS);
802     }
803     0
804 }
805 
806 unsafe extern "C" fn handle_lease_expiry(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
807     let idle = &mut *(data as *mut IdleManager);
808     idle.expire_leases();
809     0
810 }
811 
812 unsafe extern "C" fn handle_display_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
813     let idle = &mut *(data as *mut IdleManager);
814     if !idle.inhibited && !idle.displays_off {
815         log::info!("idle: display timeout reached");
816         idle.set_displays(false);
817     }
818     0
819 }
820 
821 unsafe extern "C" fn handle_sleep_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
822     let idle = &mut *(data as *mut IdleManager);
823     if !idle.inhibited && !idle.sleeping {
824         log::info!("idle: sleep timeout reached");
825         idle.lock_then_sleep();
826     }
827     0
828 }
829 
830 unsafe extern "C" fn handle_lock_fallback(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
831     let idle = &mut *(data as *mut IdleManager);
832     if idle.sleep_after_lock {
833         log::warn!("idle: the lock did not complete in {}ms; sleeping anyway (the desktop is already hidden)", LOCK_BEFORE_SLEEP_MS);
834         idle.sleep_after_lock = false;
835         idle.sleep_now();
836     }
837     0
838 }
839 
840 unsafe extern "C" fn handle_session_active(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
841     let idle = &mut *crate::container_of!(listener, IdleManager, session_active);
842     let session = (*idle.server).session;
843     if session.is_null() || !ffi::river_wlr_session_get_active(session) {
844         return;
845     }
846     log::info!("idle: session active (resume / VT switch), waking");
847     idle.on_activity();
848 }
849 
850 #[cfg(test)]
851 mod tests {
852     use super::*;
853 
854     #[test]
855     fn a_plan_file_is_seconds_and_nothing_else() {
856         assert_eq!(parse_plan_secs("600\n"), Some(600));
857         assert_eq!(parse_plan_secs(" 0 "), Some(0));
858         assert_eq!(parse_plan_secs("-5"), None);
859         assert_eq!(parse_plan_secs("10m"), None);
860         assert_eq!(parse_plan_secs(""), None);
861     }
862 
863     #[test]
864     fn the_plan_wins_while_present_and_the_config_returns_after() {
865         assert_eq!(effective_ms(600_000, Some(120_000)), 120_000);
866         assert_eq!(effective_ms(600_000, Some(0)), 0);
867         assert_eq!(effective_ms(600_000, None), 600_000);
868         assert_eq!(plan_field(Some(120_000)), "120s");
869         assert_eq!(plan_field(None), "none");
870     }
871 
872     #[test]
873     fn a_lease_request_is_token_ttl_and_who() {
874         let lease = parse_lease(&["portal-7", "90", "steam"], 1_000).unwrap();
875         assert_eq!(lease, ExternalLease { token: "portal-7".into(), who: "steam".into(), expires_ms: 91_000 });
876         let spaced = parse_lease(&["ss-2", "30", "Firefox", "video"], 0).unwrap();
877         assert_eq!(spaced.who, "Firefox video", "who keeps its spaces");
878         for bad in [&["t", "0", "x"][..], &["t", "601", "x"], &["t", "ten", "x"], &["t", "30"], &[]] {
879             assert!(parse_lease(bad, 0).is_err(), "{bad:?} must be refused");
880         }
881     }
882 
883     #[test]
884     fn external_holders_follow_the_wayland_ones_once_each() {
885         let lease = |t: &str, w: &str| ExternalLease { token: t.into(), who: w.into(), expires_ms: 0 };
886         let names = merge_inhibitors(
887             &["mpv".to_string()],
888             &[lease("a", "steam"), lease("b", "firefox"), lease("c", "steam")],
889         );
890         assert_eq!(names, ["mpv", "portal:steam", "portal:firefox"]);
891         assert!(merge_inhibitors(&[], &[]).is_empty());
892     }
893 
894     #[test]
895     fn an_absent_plan_file_stamps_as_zero() {
896         assert_eq!(plan_stamp("/nonexistent/cce/idle_display_off"), 0);
897     }
898 }