git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit44d6a4cd1ba470b571c56070beb4b4c4b34204a1
parent6f7ec78406
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-04 14:35
Lower PCIe ASPM before every sleep

A suspend begun on the charger slept under the AC mode, because user
space is frozen and an unplug while asleep reaches no udev rule. Its
`aspm "performance"` keeps the PCIe links out of L1 substates and the
package out of S0ix, and this machine has only s2idle: sleeps begun on
AC drew up to 4 W against about 1 W for those begun on battery, and on
2026-10-03 a full battery was empty within 34 hours of closing the lid.

`cce-power-apply sleep` drops ASPM to powersupersave when the running
mode sets anything else, run by the new cce-power-apply-sleep.service
(before sleep.target, X-CceEnable). The resume unit's `apply` restores
the mode's value; a mode that sets no ASPM is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 cce-power-apply-sleep.service | 26 ++++++++++++++++++++++
 src/bin/cce-power-apply.rs    | 50 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 76 insertions(+)

diff --git a/cce-power-apply-sleep.service b/cce-power-apply-sleep.service
new file mode 100644
index 0000000..c84a57c
--- /dev/null
+++ b/cce-power-apply-sleep.service
@@ -0,0 +1,26 @@
+[Unit]
+Description=Lower PCIe power management to its deepest before sleeping
+Documentation=file:/etc/cce/power.kdl
+# The mode a suspend begins under stays in force until the machine wakes:
+# user space is frozen, so an unplug while asleep reaches no udev rule and
+# no cce-power-apply run. A suspend begun on AC therefore slept under the
+# AC mode's `aspm "performance"`, which keeps the PCIe links out of L1
+# substates and the package out of S0ix -- this machine has only s2idle --
+# and on 2026-10-03 a full battery was empty within 34 hours of closing the
+# lid. `cce-power-apply sleep` drops ASPM to powersupersave first;
+# cce-power-apply-resume.service puts the running mode's value back.
+#
+# Wanted by and ordered before sleep.target, which every sleep verb pulls in
+# ahead of its systemd-*.service, so the oneshot finishes before user space
+# is frozen. Like the resume unit it does nothing until enabled, which
+# X-CceEnable tells `ccebuild install-system` to do (systemd ignores X- keys).
+Before=sleep.target
+ConditionPathExists=/etc/cce/power.kdl
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/cce-power-apply sleep
+
+[Install]
+WantedBy=sleep.target
+X-CceEnable=yes
diff --git a/src/bin/cce-power-apply.rs b/src/bin/cce-power-apply.rs
index 080c3c2..752562e 100644
--- a/src/bin/cce-power-apply.rs
+++ b/src/bin/cce-power-apply.rs
@@ -15,6 +15,10 @@
 //!   again when it finishes and re-applies if it moved. Per-lever failures
 //!   are logged and do not fail the run: a missing NVIDIA driver must not
 //!   hide the CPU profile that did land.
+//! - `sleep` — lower PCIe ASPM to `powersupersave` before the machine
+//!   sleeps, when the running mode sets it to anything else. Run by
+//!   `cce-power-apply-sleep.service`; the resume unit's `apply` puts the
+//!   mode's own value back.
 //! - `apply-mode <mode>` — apply one mode by name, whatever is plugged in.
 //! - `set <mode> <lever> <value|unset>` — record one lever on a mode and,
 //!   when that mode is the one running, apply it now. Run by the Power page
@@ -35,6 +39,7 @@ use cce_settings::power_plan::{
 fn usage() -> ! {
     eprintln!(
         "usage: cce-power-apply apply [ac|battery]\n       \
+                cce-power-apply sleep\n       \
                 cce-power-apply apply-mode <mode>\n       \
                 cce-power-apply set <mode> <lever> <value|unset>\n       \
                 cce-power-apply assign <ac|battery> <mode>\n       \
@@ -115,6 +120,50 @@ fn cmd_apply(forced: Option<&str>) -> i32 {
     }
 }
 
+/// The ASPM policy every sleep starts under, whatever mode is running.
+const SLEEP_ASPM: &str = "powersupersave";
+
+/// Ready the machine to sleep. This laptop has only s2idle, where the
+/// package reaches S0ix only if its PCIe links can drop into L1 substates,
+/// and the AC mode's `aspm "performance"` forbids that. Nothing re-applies
+/// the plan while the machine sleeps, so a suspend begun on the charger kept
+/// that mode through an unplug: the battery history before 2026-10-04 shows
+/// sleeps begun on battery drawing about 1 W, and those begun on AC up to
+/// 4 W, enough to empty a full battery in a day and a half closed.
+///
+/// Only ASPM moves. The rest of a mode either has no effect in s2idle or,
+/// like the NVIDIA power limit, costs seconds of nvidia-smi on every lid
+/// close. And only when the running mode sets it: the resume unit's `apply`
+/// then restores that mode's value, so a mode that leaves ASPM alone is not
+/// woken into a policy it never chose.
+fn cmd_sleep() -> i32 {
+    let plan = match load_plan() {
+        Ok(p) => p,
+        Err(code) => return code,
+    };
+    let source = current_source();
+    let mode = plan.assigned(source);
+    match plan.get(mode, Lever::Aspm) {
+        None => {
+            println!("cce-power-apply: sleep: {} [{}] sets no aspm; left as is", source.key(), mode.key());
+            0
+        }
+        Some(v) if v == SLEEP_ASPM => 0,
+        Some(v) => match apply_lever(Lever::Aspm, SLEEP_ASPM) {
+            Ok(()) => {
+                println!("cce-power-apply: sleep: aspm {} -> {} ({} [{}] restores it on wake)", v, SLEEP_ASPM, source.key(), mode.key());
+                0
+            }
+            Err(e) => {
+                // Never fail the unit: a sleep that drains faster beats one
+                // systemd refuses to start.
+                eprintln!("cce-power-apply: sleep: aspm: {}", e);
+                0
+            }
+        },
+    }
+}
+
 fn cmd_apply_mode(rest: &[String]) -> i32 {
     let [mode] = rest else { usage() };
     let mode = Mode::parse(mode).unwrap_or_else(|| usage());
@@ -192,6 +241,7 @@ fn main() {
     let args: Vec<String> = std::env::args().skip(1).collect();
     let code = match args.first().map(String::as_str) {
         Some("apply") => cmd_apply(args.get(1).map(String::as_str)),
+        Some("sleep") => cmd_sleep(),
         Some("apply-mode") => cmd_apply_mode(&args[1..]),
         Some("set") => cmd_set(&args[1..]),
         Some("assign") => cmd_assign(&args[1..]),