Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion build/service-scripts/power.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion compartments/kryptikd/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,8 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec<Job>, 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
Expand Down Expand Up @@ -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.
Expand All @@ -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<libc::pollfd> = vec![libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }];
Expand All @@ -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) };
}
Expand Down
64 changes: 64 additions & 0 deletions compartments/kryptikd/src/suspend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<u64> {
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::<u64>().ok()?;
let full = attr(bat, &format!("{k}_full"))?.parse::<u64>().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<String> = 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.
Expand Down
80 changes: 80 additions & 0 deletions compartments/kryptikd/src/suspend/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
14 changes: 14 additions & 0 deletions docs/design/laptop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tools/tests/power.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
Loading