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 721e1bfa..b6853f4f 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,31 @@ 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 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 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); + 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 +2018,19 @@ 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()); + } // 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..7fca3771 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -465,6 +465,20 @@ 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 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 ]]