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 }