From 9ae3a44bd4cc597f8a3d2f409996ad749ab00794 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:10:52 -0700 Subject: [PATCH 1/2] A laptop woken at the passphrase prompt powers off at 3% instead of running flat The battery's ACPI driver wakes an s2idle machine when the charge falls to the firmware's alarm level or goes critical (drivers/acpi/battery.c, acpi_battery_update). The laptop then waits at the state passphrase prompt, lid still shut, and the power service waits on the suspended state partition, as every program started then does, so its 3% power-off never comes and the laptop runs flat awake. kryptikd already answers the lock while the partition is suspended, from sysfs alone. From each sleep's end until the passphrase is given (or from its start, for a daemon started again at the prompt) it now looks at the battery every 30 s, by power.sh's rule: the fullest of the machine's own batteries at or under 3% and running down. Seen twice running, it powers off with reboot(2). The zones are not stopped first: they are frozen, the partition and their volumes have no key, everything was written out before the seal, and a sync would wait on the partition for good. critical_in is held to tools/tests/power.sh's cases in a unit test, and the twice-running count to its own. --- compartments/kryptikd/src/serve.rs | 34 ++++++++- compartments/kryptikd/src/suspend.rs | 64 +++++++++++++++++ compartments/kryptikd/src/suspend/tests.rs | 80 ++++++++++++++++++++++ docs/design/laptop.md | 11 +++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 721e1bfa..5ba5d0a8 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1923,6 +1923,8 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec, pauses: &mut /// How often the login prompt's update notice is checked. const NOTICE_EVERY: Duration = Duration::from_secs(3600); +/// How often the battery is looked at while the state partition is suspended: power.sh's round. +const SEALED_EVERY: Duration = Duration::from_secs(30); pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // First, before anything that could read /etc or /var: a daemon started again while the state @@ -1977,7 +1979,30 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // The login prompt carries what `kryptik update status` says of a stale statement, on the // installed system's instance: checked as it starts, then hourly, as days are what count. let mut notice_due = (!cfg.developer).then(Instant::now); + // From each wake until the passphrase is given, power.sh waits on the state partition, so + // its 3% rule is kept here: a laptop the battery's alarm woke, its lid shut, would + // otherwise run flat at the prompt. A daemon started again at the prompt looks too. + let mut sealed_due = (!cfg.developer && crate::suspend::state_suspended()).then(Instant::now); + let mut sealed_low = 0; loop { + if sealed_due.is_some_and(|due| Instant::now() >= due) { + sealed_due = None; + // A sleep under way ends with a wake, which looks afresh. + if jobs.iter().any(|j| j.what == JobKind::Sleep) || !crate::suspend::state_suspended() { + sealed_low = 0; + } else { + let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical()); + sealed_low = low; + sealed_due = Some(Instant::now() + SEALED_EVERY); + if off { + eprintln!("kryptikd serve: the battery is at {}% or under, the state partition suspended: powering off", crate::suspend::CRITICAL); + let e = crate::suspend::power_off(); + // Once: the next look would be refused the same way. + eprintln!("kryptikd serve: the machine did not power off: {e}"); + sealed_due = None; + } + } + } if notice_due.is_some_and(|due| Instant::now() >= due) { // The update state is on the state partition: while that is suspended the notice // keeps, and the daemon stays free to answer the screen lock. @@ -1992,13 +2017,20 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { notice_due = Some(Instant::now() + NOTICE_EVERY); } reap(&mut pending, &mut jobs, &mut proxies); + let mut slept = false; jobs.retain_mut(|j| match j.exited { Some(st) => { + slept |= j.what == JobKind::Sleep; finish_job(j, st); false } None => true, }); + // Woken or refused, a sleep leaves the state partition suspended. + if slept && !cfg.developer { + sealed_due = Some(Instant::now()); + sealed_low = 0; + } // fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline. let mut fds: Vec = vec![libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }]; @@ -2013,7 +2045,7 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { let left = p.deadline().saturating_duration_since(now).as_millis() as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } - if let Some(due) = notice_due { + for due in [notice_due, sealed_due].into_iter().flatten() { let left = due.saturating_duration_since(now).as_millis().min(i32::MAX as u128) as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index 9402d3d8..7a2e4f66 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -238,6 +238,70 @@ pub fn sleep() -> Result<(), String> { .map_err(|e| format!("/sys/power/state: {e}")) } +/// The charge, in percent, at or under which a battery running down powers the machine off +/// (power.sh's CRITICAL). +pub const CRITICAL: u64 = 3; + +fn attr(dir: &Path, name: &str) -> Option { + fs::read_to_string(dir.join(name)).ok().map(|s| s.trim().to_string()) +} + +/// A battery's charge in percent: its capacity, or the energy or the charge it holds against +/// what it holds full. +fn percent(bat: &Path) -> Option { + if let Some(c) = attr(bat, "capacity").filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) { + return c.parse().ok(); + } + ["energy", "charge"].iter().find_map(|k| { + let now = attr(bat, &format!("{k}_now"))?.parse::().ok()?; + let full = attr(bat, &format!("{k}_full"))?.parse::().ok().filter(|&f| f > 0)?; + Some((now.saturating_mul(100) / full).min(100)) + }) +} + +/// Whether the battery is critical as power.sh judges it, from what `sys` says: the fullest of +/// the machine's own batteries (not a mouse's, scope Device) at or under CRITICAL, and running +/// down: one says it discharges, or none says anything and no adapter is online. power.sh waits +/// on the state partition while it is suspended, as anything started then does, so the daemon +/// keeps the rule meanwhile; the two must agree. +pub fn critical_in(sys: &Path) -> bool { + let Ok(rd) = fs::read_dir(sys.join("class/power_supply")) else { return false }; + let all: Vec<(PathBuf, String)> = rd.flatten().filter_map(|e| Some((e.path(), attr(&e.path(), "type")?))).collect(); + let mains = all.iter().any(|(d, t)| matches!(t.as_str(), "Mains" | "USB") && attr(d, "online").as_deref() == Some("1")); + let bats: Vec<&PathBuf> = all + .iter() + .filter(|(d, t)| t == "Battery" && attr(d, "scope").as_deref() != Some("Device") && attr(d, "present").is_none_or(|p| p == "1")) + .map(|(d, _)| d) + .collect(); + let Some(charge) = bats.iter().filter_map(|d| percent(d)).max() else { return false }; + let said: Vec = bats.iter().filter_map(|d| attr(d, "status")).collect(); + let draining = said.iter().any(|s| s == "Discharging") + || (!said.iter().any(|s| matches!(s.as_str(), "Charging" | "Full" | "Not charging")) && !mains); + draining && charge <= CRITICAL +} + +pub fn critical() -> bool { + critical_in(Path::new("/sys")) +} + +/// One look at the battery while the state partition is suspended, after `low` critical looks +/// in a row: the count now, and whether to power off. Twice running, as power.sh has it, so one +/// odd reading does not end the session. +pub fn sealed_look(low: u32, critical: bool) -> (u32, bool) { + let low = if critical { low + 1 } else { 0 }; + (low, low >= 2) +} + +/// Off at once, the battery running out with the state partition suspended. Everything was +/// written out before the seal, nothing can be written to the partition now, and a sync, or +/// the shutdown that would stop the zones first, would wait on it for good. Returns only with +/// why the kernel refused. +pub fn power_off() -> std::io::Error { + // SAFETY: reboot takes a command alone, and RB_POWER_OFF does not return when it works. + unsafe { libc::reboot(libc::RB_POWER_OFF) }; + std::io::Error::last_os_error() +} + #[derive(Debug)] pub enum ResumeError { /// cryptsetup exited 2: no keyslot took the passphrase. diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 803c72e9..facf08c8 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -87,3 +87,83 @@ fn a_seal_that_left_a_zone_waiting_counts_itself() { count_left_waiting(&dir.join("absent").join("left-waiting")); let _ = fs::remove_dir_all(&dir); } + +/// A power supply of the kernel's shape under `sys`: a directory with its type and attributes. +fn supply(sys: &Path, name: &str, kind: &str, attrs: &[(&str, &str)]) { + let d = sys.join("class/power_supply").join(name); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("type"), format!("{kind}\n")).unwrap(); + for (k, v) in attrs { + fs::write(d.join(k), format!("{v}\n")).unwrap(); + } +} + +fn fresh_sys(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("kryptikd-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +// The cases of tools/tests/power.sh that decide power.sh's critical=: the two must agree. +#[test] +fn the_battery_is_critical_as_power_sh_has_it() { + let s = fresh_sys("power-none"); + assert!(!critical_in(&s), "no supply at all, as in a VM"); + assert!(!critical_in(&s.join("absent")), "no power_supply class"); + + let s = fresh_sys("power-low"); + supply(&s, "AC", "Mains", &[("online", "0")]); + supply(&s, "BAT0", "Battery", &[("capacity", "3"), ("status", "Discharging")]); + assert!(critical_in(&s), "3% on battery"); + supply(&s, "BAT1", "Battery", &[("capacity", "60"), ("status", "Discharging")]); + assert!(!critical_in(&s), "but not while a second battery is full enough"); + + let s = fresh_sys("power-four"); + supply(&s, "BAT0", "Battery", &[("capacity", "4"), ("status", "Discharging")]); + assert!(!critical_in(&s), "4% is not yet"); + + let s = fresh_sys("power-weak"); + supply(&s, "AC", "Mains", &[("online", "1")]); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Discharging")]); + assert!(critical_in(&s), "plugged in to a charger that gives less than the machine uses, it runs down"); + supply(&s, "BAT0", "Battery", &[("status", "Charging")]); + assert!(!critical_in(&s), "2% charging on mains"); + + let s = fresh_sys("power-word"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Unknown")]); + assert!(critical_in(&s), "no word from the battery and no adapter: it runs down"); + supply(&s, "ucsi", "USB", &[("online", "1")]); + assert!(!critical_in(&s), "no word from the battery, a USB charger online"); + supply(&s, "BAT0", "Battery", &[("status", "Not charging")]); + supply(&s, "ucsi", "USB", &[("online", "0")]); + assert!(!critical_in(&s), "one that holds its charge"); + + let s = fresh_sys("power-other"); + supply(&s, "hidpp_battery_0", "Battery", &[("capacity", "1"), ("scope", "Device"), ("status", "Discharging")]); + assert!(!critical_in(&s), "a mouse's empty battery is not the machine's"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("present", "0"), ("status", "Discharging")]); + supply(&s, "BAT1", "Battery", &[("capacity", "fail"), ("status", "Discharging")]); + assert!(!critical_in(&s), "an absent battery and a bad reading count for nothing"); + + let s = fresh_sys("power-energy"); + supply(&s, "BAT0", "Battery", &[("capacity", "fail"), ("energy_now", "5130000"), ("energy_full", "57000000"), ("status", "Discharging")]); + assert!(!critical_in(&s), "with no capacity that reads, the energy against full: 9%"); + let s = fresh_sys("power-charge"); + supply(&s, "BAT0", "Battery", &[("charge_now", "120000"), ("charge_full", "4000000"), ("status", "Discharging")]); + assert!(critical_in(&s), "or the charge against full: 3%"); + supply(&s, "BAT0", "Battery", &[("charge_full", "0")]); + assert!(!critical_in(&s), "a battery full at nothing gives no charge"); + + for tag in ["none", "low", "four", "weak", "word", "other", "energy", "charge"] { + let _ = fs::remove_dir_all(std::env::temp_dir().join(format!("kryptikd-power-{tag}-{}", std::process::id()))); + } +} + +#[test] +fn critical_twice_running_powers_off() { + assert_eq!(sealed_look(0, true), (1, false), "one odd reading does not end the session"); + assert_eq!(sealed_look(1, true), (2, true), "the second, 30 s on, does"); + assert_eq!(sealed_look(1, false), (0, false), "a reading above starts the count again"); + assert_eq!(sealed_look(0, false), (0, false)); +} diff --git a/docs/design/laptop.md b/docs/design/laptop.md index bdc1b18d..6c03b6fb 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -465,6 +465,17 @@ and the power key, idle and inhibitors, suspend, and runtime directories since there is no hibernation. Each look is written to `/run/kryptik-power/state`, which the chrome reads, so the menu says what the service acts on. +- **The battery while the state partition waits.** From a wake until the + passphrase is given, the power service waits on the partition, as every + program started then does. A battery reaching its alarm level wakes an + s2idle laptop, so a laptop asleep in a bag would wake to the prompt, + lid still shut, and run flat there. kryptikd keeps the 3% rule + meanwhile, from `/sys` alone (`critical` in `suspend.rs`, held to + power.sh's cases): seen twice 30 s apart, it powers the machine off at + once (`reboot(2)`). The zones are frozen and their volumes and the + partition have no key, so they are not stopped first: everything was + written out before the seal, nothing can be written now, and a sync + would wait for good. - **A gauge that reads too low.** A worn battery whose gauge says 3% or less while it still holds charge powers the machine off a minute after every boot on battery. Requiring a reading above 3% earlier in the boot would From 87e4652e0345502cbf9aaf9feef69816a4c929c3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:19:15 -0700 Subject: [PATCH 2/2] The power service leaves a sealed machine's 3% power-off to kryptikd, and the count carries across a sleep The suspend suite's guest runs programs at the passphrase prompt, so the power service's loop is not sure to stall there. Where it does not, its shutdown would ask kryptikd to stop each zone and then run the system's shutdown, which syncs, stops kryptikd and unmounts: none of that can finish with the state partition suspended, and kryptikd's own power-off would die with it. sealed() reads /sys/block with builtins alone, and while it holds the service leaves the power-off to kryptikd. From 93's read: kryptikd's count of critical looks reset at each sleep's end, so a machine put back to sleep within 30 s of each wake would never see two in one wake. It now resets only once the partition is back. tools/tests/power.sh runs sealed() against stand-in device-mapper trees, and checks that the loop's power-off waits on it. --- build/service-scripts/power.sh | 14 +++++++++++++- compartments/kryptikd/src/serve.rs | 8 ++++---- docs/design/laptop.md | 11 +++++++---- tools/tests/power.sh | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/build/service-scripts/power.sh b/build/service-scripts/power.sh index 98c8fa39..dcfb9ffe 100755 --- a/build/service-scripts/power.sh +++ b/build/service-scripts/power.sh @@ -129,6 +129,18 @@ if [ "${1:-}" = --once ]; then exit 0 fi +# The state partition waits for its passphrase, read with builtins alone: a program started now +# can wait on it. Then kryptikd keeps the 3% rule (suspend.rs), as neither the zones' stop nor +# the shutdown's sync and unmounts could finish. +sealed() { + local d n s + for d in "$SYS"/block/dm-*/dm; do + read -r n 2>/dev/null < "$d/name" && [ "$n" = kryptik-state ] || continue + read -r s 2>/dev/null < "$d/suspended" && [ "$s" = 1 ] && return 0 + done + return 1 +} + # Each running zone stopped as `kryptikd stop` does, its launcher closing its volume, then off. shut_down() { local z @@ -158,7 +170,7 @@ while :; do fi # Twice running, so one odd reading does not end the session. if [ "$LOW" = yes ]; then low=$((low + 1)); else low=0; fi - if [ "$low" -ge 2 ]; then + if [ "$low" -ge 2 ] && ! sealed; then echo "power: the battery is at ${CHARGE}%: stopping the zones and powering off before it runs out" shut_down fi diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 5ba5d0a8..b6853f4f 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1987,10 +1987,11 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { loop { if sealed_due.is_some_and(|due| Instant::now() >= due) { sealed_due = None; - // A sleep under way ends with a wake, which looks afresh. - if jobs.iter().any(|j| j.what == JobKind::Sleep) || !crate::suspend::state_suspended() { + // A sleep under way looks again when it ends, the count carried across it: a machine + // put back to sleep within 30 s of each wake still powers off. + if !crate::suspend::state_suspended() { sealed_low = 0; - } else { + } else if !jobs.iter().any(|j| j.what == JobKind::Sleep) { let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical()); sealed_low = low; sealed_due = Some(Instant::now() + SEALED_EVERY); @@ -2029,7 +2030,6 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // Woken or refused, a sleep leaves the state partition suspended. if slept && !cfg.developer { sealed_due = Some(Instant::now()); - sealed_low = 0; } // fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline. diff --git a/docs/design/laptop.md b/docs/design/laptop.md index 6c03b6fb..7fca3771 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -472,10 +472,13 @@ and the power key, idle and inhibitors, suspend, and runtime directories lid still shut, and run flat there. kryptikd keeps the 3% rule meanwhile, from `/sys` alone (`critical` in `suspend.rs`, held to power.sh's cases): seen twice 30 s apart, it powers the machine off at - once (`reboot(2)`). The zones are frozen and their volumes and the - partition have no key, so they are not stopped first: everything was - written out before the seal, nothing can be written now, and a sync - would wait for good. + once (`reboot(2)`). The count carries across a sleep, so a machine put + back to sleep soon after each wake still powers off. The zones are frozen + and their volumes and the partition have no key, so they are not stopped + first: everything was written out before the seal, nothing can be + written now, and a sync would wait for good. The power service, should + its programs run then, leaves the power-off to kryptikd: its shutdown + would stop the zones and sync, and neither could finish. - **A gauge that reads too low.** A worn battery whose gauge says 3% or less while it still holds charge powers the machine off a minute after every boot on battery. Requiring a reading above 3% earlier in the boot would diff --git a/tools/tests/power.sh b/tools/tests/power.sh index 49f8bf71..bfbdb944 100755 --- a/tools/tests/power.sh +++ b/tools/tests/power.sh @@ -117,5 +117,22 @@ set_attr AC online 1; echo Discharging > "$T/sys/class/power_supply/BAT0/status" once > /dev/null check "plugged in, it follows the charger even while the battery runs down" "$(pref 0)" balance_performance +echo "-- the state partition waiting for its passphrase" +# sealed() alone, from the service: it decides whether the 3% power-off is the service's or kryptikd's. +eval "$(sed -n '/^sealed() {/,/^}/p' "$P")" +dm() { # dm N NAME SUSPENDED: a device-mapper device in the stand-in /sys/block + mkdir -p "$T/sys/block/dm-$1/dm" + echo "$2" > "$T/sys/block/dm-$1/dm/name"; echo "$3" > "$T/sys/block/dm-$1/dm/suspended" +} +is_sealed() { if SYS="$T/sys" sealed; then echo yes; else echo no; fi; } +fresh +check "no device-mapper device" "$(is_sealed)" no +dm 0 kryptik-zone-work 1; dm 1 kryptik-state 0 +check "the state partition up, a zone's volume waiting" "$(is_sealed)" no +dm 1 kryptik-state 1 +check "the state partition waiting for its passphrase" "$(is_sealed)" yes +if grep -qF '[ "$low" -ge 2 ] && ! sealed; then' "$P"; then r=yes; else r=no; fi +check "then the 3% power-off is kryptikd's, not the service's" "$r" yes + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]]