Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
idle: the power plan can override either timeout per mode
The idle manager keeps the idle { } values apart from the values in
force and polls /run/cce/idle_display_off and /run/cce/idle_sleep once a
second (stat only; re-read on change). A present file overrides the
config, an absent or unparsable one means the config's value, so an
unplug shortens the countdown within a second and nothing reloads.
cce-power-apply writes the files from the Power page's Display Off
After and Sleep After levers.
`ccectl idle status` reports plan_display_off= and plan_sleep= beside
the values in force; `ccectl idle timeouts` still edits the config base
and its reply says so while a plan override holds. CCE_IDLE_PLAN_DIR
moves the directory for one process, for shadow testing: /run/cce is
root's, and a shadow must not follow the live machine's files.
WORKSPACE.md documents it beside the animations switch.
Verified in a shadow: override, base edit under override, unparsable
file, and removal each landed within one poll.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WORKSPACE.md | 19 +++++
src/cce_ctl.rs | 4 +-
src/server/idle.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 224 insertions(+), 13 deletions(-)
diff --git a/WORKSPACE.md b/WORKSPACE.md
index 59f35386..0ea14170 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -384,6 +384,25 @@ border-reveal/adjust-dim fades, and the fullscreen-toggle resize. **A new
animation should ask `enabled()` too.** Like the close fade, the cce-ui half
reaches a client only once that client has been rebuilt against the toolkit.
+## Idle timeouts per power mode
+
+The compositor's display-off and sleep countdowns come from `idle { }` in
+config.kdl, and the Power page can override either per power mode with the
+**Display Off After** and **Sleep After** levers (`idle_display_off_secs`,
+`idle_sleep_secs` in `/etc/cce/power.kdl`). Same mechanism as the
+animations switch: `cce-power-apply` writes **`/run/cce/idle_display_off`**
+and **`/run/cce/idle_sleep`** as root (seconds on a line, 0 = never), and the
+idle manager (`idle.rs`, `PLAN_DIR` + `PLAN_DISPLAY_OFF_FILE` /
+`PLAN_SLEEP_FILE`) polls both once a second, so an unplug shortens the
+countdown within a second and nothing reloads. `CCE_IDLE_PLAN_DIR=<dir>`
+moves the directory for one compositor, for testing in a shadow, which must
+not follow the live machine's files. A file that is absent means the config's value, and
+`ccectl idle timeouts` edits that config base, which the plan keeps
+overriding while its mode holds the lever; `ccectl idle status` reports the
+values in force plus `plan_display_off=` / `plan_sleep=` (`none` or seconds).
+The paths are spelled in both crates (the app cannot be a compositor
+dependency), so a rename must land on both sides.
+
## Repo hygiene
The repo root and `cce-compositor/scratch/` are littered with **ad-hoc debugging artifacts** — many
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index bea4e2e0..2522a4dd 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -64,9 +64,9 @@ 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 and by whom (app ids), displays_off/sleeping");
+ print(" idle [status] # idle timeouts in force, idle time, inhibited and by whom (app ids), displays_off/sleeping, and the power plan's override of each (plan_*)");
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(" idle timeouts <display_off_s> <sleep_s> # set the config timeouts live (0 = off); config.kdl `idle { }` on reload; a Power-plan file under /run/cce overrides either while it exists");
print(" status-hide-mode [true|false]");
print(" adjust-position-mode [true|false|query]");
print(" exit [force] # log out; waits for windows to close, cancels if one stays (a save prompt); force skips the wait");
diff --git a/src/server/idle.rs b/src/server/idle.rs
index e8e0c8b6..1309cb95 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -10,6 +10,13 @@
//! inhibitor on a mapped surface (a video player) pauses both timers, the
//! same signal `wlr_idle_notifier_v1_set_inhibited` gets.
//!
+//! The timeouts come from `idle { }` in config.kdl, but the System
+//! Interface's Power plan can override either per power mode: its applier
+//! writes [`PLAN_DISPLAY_OFF_FILE`] / [`PLAN_SLEEP_FILE`] under [`PLAN_DIR`] as root on plug,
+//! unplug and boot (seconds, 0 = never), and this module polls both once a
+//! second, so battery can darken the display sooner than the desk does
+//! without a reload. A missing file means the config's value.
+//!
//! "Display off" is the soft-disable the wlr-output-power-management
//! protocol already drives (`OutputStateValue::DisabledSoft` — the output
//! stays in the layout, nothing is re-arranged, and no frame events fire
@@ -48,13 +55,73 @@ impl Default for IdleConfig {
/// measured in minutes.
const REARM_MIN_MS: u64 = 1000;
+/// The Power plan's per-mode timeouts, written by `cce-power-apply`
+/// (`cce_settings::power_plan::IDLE_DISPLAY_OFF_PATH` / `IDLE_SLEEP_PATH`;
+/// the paths are repeated here because that crate is an app, not a
+/// dependency). Seconds, 0 = never; absent = use the config.
+pub const PLAN_DIR: &str = "/run/cce";
+pub const PLAN_DISPLAY_OFF_FILE: &str = "idle_display_off";
+pub const PLAN_SLEEP_FILE: &str = "idle_sleep";
+
+/// Where one plan file lives. `CCE_IDLE_PLAN_DIR` moves the directory for
+/// 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)
+}
+
+/// 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.
+const PLAN_POLL_MS: i32 = 1000;
+
+/// One plan file's contents as a timeout: seconds on a line, nothing else.
+pub fn parse_plan_secs(text: &str) -> Option<i64> {
+ text.trim().parse::<u32>().ok().map(i64::from)
+}
+
+/// The timeout in force: the plan's when it has one, else the config's.
+fn effective_ms(cfg_ms: i64, plan_ms: Option<i64>) -> i64 {
+ plan_ms.unwrap_or(cfg_ms)
+}
+
+/// A change stamp for one plan file: its mtime in ns plus one, or 0 when it
+/// is absent, so appearing, vanishing and rewriting all read as a change.
+fn plan_stamp(path: &str) -> u128 {
+ std::fs::metadata(path)
+ .ok()
+ .and_then(|m| m.modified().ok())
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+ .map(|d| d.as_nanos() + 1)
+ .unwrap_or(0)
+}
+
+fn read_plan_ms(path: &str) -> Option<i64> {
+ parse_plan_secs(&std::fs::read_to_string(path).ok()?).map(|s| s * 1000)
+}
+
pub struct IdleManager {
pub server: *mut Server,
display_timer: *mut ffi::wl_event_source,
sleep_timer: *mut ffi::wl_event_source,
- /// Timeouts in ms; 0 = disabled.
+ /// The timeouts in force, in ms; 0 = disabled. The plan's when it has
+ /// one, else the config's (`effective_ms`).
display_off_ms: i64,
sleep_ms: i64,
+ /// The `idle { }` block's own values, kept apart so a plan override can
+ /// be lifted again when its file goes away.
+ cfg_display_off_ms: i64,
+ cfg_sleep_ms: i64,
+ /// The Power plan's per-mode override for each, from the files under
+ /// /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`.
+ plan_timer: *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],
/// `None` means `DEFAULT_SLEEP_COMMAND`. (An `Option<String>` is
/// null-niche safe under `Server::new`'s zeroed init; a bare `String`
/// is not.)
@@ -103,8 +170,26 @@ impl IdleManager {
self.display_timer = std::ptr::null_mut();
return Err("Failed to create idle sleep timer");
}
+ self.plan_timer = ffi::wl_event_loop_add_timer(
+ event_loop,
+ Some(handle_plan_poll),
+ self as *mut IdleManager as *mut _,
+ );
+ if self.plan_timer.is_null() {
+ ffi::wl_event_source_remove(self.display_timer);
+ ffi::wl_event_source_remove(self.sleep_timer);
+ self.display_timer = std::ptr::null_mut();
+ self.sleep_timer = std::ptr::null_mut();
+ return Err("Failed to create idle plan-poll timer");
+ }
self.display_off_ms = 0;
self.sleep_ms = 0;
+ self.cfg_display_off_ms = 0;
+ self.cfg_sleep_ms = 0;
+ 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);
self.sleep_command = None;
self.inhibited = false;
self.inhibitors = None;
@@ -137,18 +222,72 @@ impl IdleManager {
ffi::wl_event_source_remove(self.sleep_timer);
self.sleep_timer = std::ptr::null_mut();
}
+ if !self.plan_timer.is_null() {
+ ffi::wl_event_source_remove(self.plan_timer);
+ self.plan_timer = std::ptr::null_mut();
+ }
}
- /// Apply an `idle { }` block (config load and `ccectl reload`).
+ /// Apply an `idle { }` block (config load and `ccectl reload`). A plan
+ /// override in force stays in force: the config is the base it lifts to.
pub unsafe fn configure(&mut self, cfg: &IdleConfig) {
- self.display_off_ms = cfg.display_off_s.max(0) * 1000;
- self.sleep_ms = cfg.sleep_s.max(0) * 1000;
+ self.cfg_display_off_ms = cfg.display_off_s.max(0) * 1000;
+ self.cfg_sleep_ms = cfg.sleep_s.max(0) * 1000;
self.sleep_command = cfg.sleep_command.clone();
+ self.refresh_effective();
+ log::info!(
+ "idle timeouts: display_off={}s sleep={}s command={:?}{}",
+ self.display_off_ms / 1000,
+ self.sleep_ms / 1000,
+ self.sleep_command(),
+ self.plan_note()
+ );
+ self.rearm(true);
+ }
+
+ /// Recompute the timeouts in force from the config and the plan.
+ fn refresh_effective(&mut self) {
+ self.display_off_ms = effective_ms(self.cfg_display_off_ms, self.plan_display_off_ms);
+ self.sleep_ms = effective_ms(self.cfg_sleep_ms, self.plan_sleep_ms);
+ }
+
+ /// True when the Power plan overrides at least one timeout.
+ fn plan_active(&self) -> bool {
+ self.plan_display_off_ms.is_some() || self.plan_sleep_ms.is_some()
+ }
+
+ /// For log lines: which values the plan is imposing, or nothing.
+ fn plan_note(&self) -> String {
+ if !self.plan_active() {
+ return String::new();
+ }
+ let show = |v: Option<i64>| v.map(|ms| format!("{}s", ms / 1000)).unwrap_or_else(|| "config".to_string());
+ format!(
+ " (power plan: display_off={} sleep={}, config {}s/{}s)",
+ show(self.plan_display_off_ms),
+ show(self.plan_sleep_ms),
+ self.cfg_display_off_ms / 1000,
+ self.cfg_sleep_ms / 1000
+ )
+ }
+
+ /// From `plan_timer`: 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));
+ let stamps = [plan_stamp(&off_path), plan_stamp(&sleep_path)];
+ if stamps == self.plan_stamps {
+ return;
+ }
+ self.plan_stamps = stamps;
+ self.plan_display_off_ms = read_plan_ms(&off_path);
+ self.plan_sleep_ms = read_plan_ms(&sleep_path);
+ self.refresh_effective();
log::info!(
- "idle timeouts: display_off={}s sleep={}s command={:?}",
- cfg.display_off_s.max(0),
- cfg.sleep_s.max(0),
- self.sleep_command()
+ "idle timeouts: display_off={}s sleep={}s{}",
+ self.display_off_ms / 1000,
+ self.sleep_ms / 1000,
+ if self.plan_active() { self.plan_note() } else { " (power plan lifted, config values)".to_string() }
);
self.rearm(true);
}
@@ -281,7 +420,7 @@ 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={} inhibited_by={:?} displays_off={} sleeping={}\n",
+ "display_off={}s sleep={}s command={:?} idle={}s inhibited={} inhibited_by={:?} displays_off={} sleeping={} plan_display_off={} plan_sleep={}\n",
self.display_off_ms / 1000,
self.sleep_ms / 1000,
self.sleep_command(),
@@ -289,7 +428,9 @@ impl IdleManager {
self.inhibited,
self.inhibited_by(),
self.displays_off,
- self.sleeping
+ self.sleeping,
+ plan_field(self.plan_display_off_ms),
+ plan_field(self.plan_sleep_ms)
)
}
@@ -318,7 +459,15 @@ impl IdleManager {
(Ok(d), Ok(s)) if d >= 0 && s >= 0 => {
let cfg = IdleConfig { display_off_s: d, sleep_s: s, sleep_command: self.sleep_command.clone() };
self.configure(&cfg);
- "ok\n".to_string()
+ if self.plan_active() {
+ format!(
+ "ok, as the config base; the power plan keeps display_off={}s sleep={}s in force until its mode changes\n",
+ self.display_off_ms / 1000,
+ self.sleep_ms / 1000
+ )
+ } else {
+ "ok\n".to_string()
+ }
}
_ => "error: idle timeouts <display_off_s> <sleep_s> (non-negative seconds, 0 = off)\n".to_string(),
}
@@ -328,6 +477,21 @@ impl IdleManager {
}
}
+/// `none`, or the plan's seconds, for the status line.
+fn plan_field(ms: Option<i64>) -> String {
+ ms.map(|v| format!("{}s", v / 1000)).unwrap_or_else(|| "none".to_string())
+}
+
+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();
+ // 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);
+ }
+ 0
+}
+
unsafe extern "C" fn handle_display_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
let idle = &mut *(data as *mut IdleManager);
if !idle.inhibited && !idle.displays_off {
@@ -355,3 +519,31 @@ unsafe extern "C" fn handle_session_active(listener: *mut ffi::wl_listener, _dat
log::info!("idle: session active (resume / VT switch), waking");
idle.on_activity();
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_plan_file_is_seconds_and_nothing_else() {
+ assert_eq!(parse_plan_secs("600\n"), Some(600));
+ assert_eq!(parse_plan_secs(" 0 "), Some(0));
+ assert_eq!(parse_plan_secs("-5"), None);
+ assert_eq!(parse_plan_secs("10m"), None);
+ assert_eq!(parse_plan_secs(""), None);
+ }
+
+ #[test]
+ fn the_plan_wins_while_present_and_the_config_returns_after() {
+ assert_eq!(effective_ms(600_000, Some(120_000)), 120_000);
+ assert_eq!(effective_ms(600_000, Some(0)), 0);
+ assert_eq!(effective_ms(600_000, None), 600_000);
+ assert_eq!(plan_field(Some(120_000)), "120s");
+ assert_eq!(plan_field(None), "none");
+ }
+
+ #[test]
+ fn an_absent_plan_file_stamps_as_zero() {
+ assert_eq!(plan_stamp("/nonexistent/cce/idle_display_off"), 0);
+ }
+}