diff --git a/scripts/classroom-reboot-when-idle b/scripts/classroom-reboot-when-idle new file mode 100755 index 0000000..c7a81c4 --- /dev/null +++ b/scripts/classroom-reboot-when-idle @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Reboot after an update, but only once nobody is using the computer. + +The nightly update check (ekklesia-classroom-update.timer, 03:00, and +Persistent so that a computer switched off at 03:00 checks when it is next +switched on) used to reboot the moment an update was installed. At 03:00 +that is harmless. Switched on in the morning after a night off, it meant a +classroom computer restarting itself half an hour into the day, in front +of a teacher. + +So the updater installs, then starts this, and this reboots only when: + + nothing has touched any keyboard, mouse, touchpad or touchscreen for + IDLE_SECONDS — seen here as kernel input events, which is what every + one of them produces, whatever the desktop is doing; and + + no sound has played for as long — a video to the room has no input for + an hour and is not an idle computer, and the end of it is not the + moment to restart either. Seen as the kernel's own playback state + (/proc/asound/.../status says RUNNING while a stream plays). + +At 03:00 that is about 03:30. In the middle of a morning it is half an +hour after the teacher last touched it. If the computer is switched off +first, nothing is lost: the update is installed, and the next start is on +it. Until then the running session carries on as it was. + +The time starts when this starts: input from before cannot be seen, so a +computer is only called idle after this has watched it be idle. + + classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...] + +The options are for the lab; the service runs it with none. +""" + +import argparse +import glob +import os +import select +import subprocess +import sys +import time + +IDLE_SECONDS = 30 * 60 + +# How often it looks: input is noticed at once, the rest once a minute is +# plenty for a half-hour decision. +CHECK_SECONDS = 60 + +INPUT_GLOB = "/dev/input/event*" +ASOUND_ROOT = "/proc/asound" + + +def log(message): + print(f"[classroom-reboot-when-idle] {message}", flush=True) + + +def audio_playing(root=ASOUND_ROOT): + """Is any sound card playing right now?""" + for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")): + try: + with open(status, encoding="utf-8", errors="replace") as handle: + if handle.readline().strip() == "state: RUNNING": + return True + except OSError: + continue + + return False + + +def should_reboot(now, last_input, idle_seconds, playing): + """The decision: long enough without input, and nothing playing.""" + return not playing and now - last_input >= idle_seconds + + +class Inputs: + """Every input device the kernel has, reopened as they come and go.""" + + def __init__(self, pattern=INPUT_GLOB): + self.pattern = pattern + self.open = {} + + def rescan(self): + present = set(glob.glob(self.pattern)) + + for fd, path in list(self.open.items()): + if path not in present: + os.close(fd) + del self.open[fd] + + known = set(self.open.values()) + + for path in sorted(present - known): + try: + self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path + except OSError: + continue + + def wait(self, seconds): + """True if anything happened on any device within SECONDS.""" + if not self.open: + time.sleep(seconds) + return False + + ready, _, _ = select.select(list(self.open), [], [], seconds) + seen = False + + for fd in ready: + try: + while os.read(fd, 4096): + seen = True + except BlockingIOError: + pass + except OSError: + # A device that went away mid-read; the next rescan drops it. + pass + + return seen + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS) + parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS) + parser.add_argument("--input-glob", default=INPUT_GLOB) + parser.add_argument("--asound-root", default=ASOUND_ROOT) + parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"]) + args = parser.parse_args(argv) + + inputs = Inputs(args.input_glob) + inputs.rescan() + last_input = time.monotonic() + log(f"an update is installed; rebooting once nobody has used this computer for " + f"{round(args.idle_seconds / 60)} minutes and nothing is playing " + f"(watching {len(inputs.open)} input devices)") + + while True: + deadline = time.monotonic() + args.check_seconds + + while time.monotonic() < deadline: + if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))): + last_input = time.monotonic() + + inputs.rescan() + playing = audio_playing(args.asound_root) + + if playing: + # Sound counts as someone there: a 40-minute video ending is + # not the moment to restart, so the half hour starts after it. + last_input = time.monotonic() + + if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing): + log("nobody has used it, and nothing is playing: rebooting") + return subprocess.run(args.reboot_command).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ekklesia-classroom-update b/scripts/ekklesia-classroom-update index cb18a9b..711bdb8 100755 --- a/scripts/ekklesia-classroom-update +++ b/scripts/ekklesia-classroom-update @@ -14,6 +14,7 @@ from urllib.parse import urljoin CONFIG = "/etc/ekklesia-classroom/device.conf" LOCKFILE = "/run/ekklesia-classroom-update.lock" +REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service" CACHE_DIR = "/var/cache/ekklesia-classroom" PACKAGE = "ekklesia-classroom" @@ -291,8 +292,12 @@ def main(): if args.no_reboot: log("Update requires reboot, but --no-reboot was specified.") else: - log("Update requires reboot. Rebooting now.") - subprocess.run(["systemctl", "reboot"]) + # Not now. At 03:00 nobody is there; after a missed 03:00, + # checked when the computer is next switched on, somebody + # usually is. classroom-reboot-when-idle waits until nobody + # has touched it for half an hour and nothing is playing. + log("Update requires reboot. Rebooting once nobody is using this computer.") + subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE]) return 0 diff --git a/systemd/updater/ekklesia-classroom-reboot-when-idle.service b/systemd/updater/ekklesia-classroom-reboot-when-idle.service new file mode 100644 index 0000000..3c4a28b --- /dev/null +++ b/systemd/updater/ekklesia-classroom-reboot-when-idle.service @@ -0,0 +1,10 @@ +[Unit] +Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer +Documentation=file:///usr/local/bin/classroom-reboot-when-idle +# Started by ekklesia-classroom-update after it installs an update that +# needs a reboot; never at boot. A computer switched off before it +# reboots simply starts on the new version. + +[Service] +Type=simple +ExecStart=/usr/local/bin/classroom-reboot-when-idle diff --git a/tests/test-classroom-update-schedule.sh b/tests/test-classroom-update-schedule.sh new file mode 100755 index 0000000..f96e3c6 --- /dev/null +++ b/tests/test-classroom-update-schedule.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# When a classroom checks for updates, and when it reboots after one. +# +# The check: daily at 03:00, caught up once after a computer that was off +# at 03:00 is switched on. The reboot: only once nobody has used the +# computer for half an hour and nothing is playing. The timer itself is +# measured in the lab (tools/lab/update-schedule-check); here are its +# settings, and classroom-reboot-when-idle run for real with a pipe +# standing in for a keyboard and files standing in for sound cards. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +WORK="$WORK" python3 - "$ROOT" <<'PY' +import configparser +import importlib.machinery +import importlib.util +import os +import sys +import threading +import time +from pathlib import Path + +root = Path(sys.argv[1]) +work = Path(os.environ["WORK"]) +passed = failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + print(f" ok {label}") + passed += 1 + else: + print(f" FAIL {label}") + if detail: + print(f" {detail}") + failed += 1 + + +loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle")) +spec = importlib.util.spec_from_loader("idle", loader) +idle = importlib.util.module_from_spec(spec) +loader.exec_module(idle) + + +def unit(path): + parser = configparser.ConfigParser(strict=False, interpolation=None) + parser.optionxform = str + parser.read(path) + return parser + + +print("1. the check: daily at 03:00, caught up once after being off") +timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer") +check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"])) +check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true") +check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m") +check("and nothing else starts it: no boot or repeating trigger to loop on", + not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec"))) +check("enabled by the package on every install and upgrade", + "systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text()) +check("and installed as a package file, so an upgrade always brings the packaged schedule", + "systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text() + and not (root / "packaging/debian/conffiles").exists()) + +print() +print("2. the updater: the same checks, and no reboot of its own") +updater = (root / "scripts/ekklesia-classroom-update").read_text() +code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#")) +check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code) +check("only a newer version", "version_is_newer(candidate, current)" in code) +check("only a manifest that makes sense", "manifest_problem(manifest)" in code) +check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code) +check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code) +check("never reboots by itself any more", '["systemctl", "reboot"]' not in code) +check("but hands the reboot to classroom-reboot-when-idle, without waiting for it", + '"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code + and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code) +service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service") +check("which is never started at boot (no [Install])", "Install" not in service.sections()) +check("and is not enabled by the package", + "enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text()) +build = (root / "tools/build-release.sh").read_text() +check("both are packaged", "scripts/classroom-reboot-when-idle" in build + and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build) + +print() +print("3. is anything playing?") + + +def cards(states): + base = work / f"asound-{len(list(work.glob('asound-*')))}" + for n, state in enumerate(states): + status = base / f"card{n}" / "pcm0p" / "sub0" / "status" + status.parent.mkdir(parents=True) + status.write_text(state + "\n") + return base + + +check("nothing: no cards at all", not idle.audio_playing(work / "none")) +check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"]))) +check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"]))) +check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"]))) +check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"]))) +capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status" +capture.parent.mkdir(parents=True) +capture.write_text("state: RUNNING\n") +check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture")) + +print() +print("4. the decision") +check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False)) +check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False)) +check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True)) +check("half an hour by default", idle.IDLE_SECONDS == 30 * 60) + +print() +print("5. run for real: a keyboard that is used, then left") +keyboard = work / "input" / "event0" +keyboard.parent.mkdir() +os.mkfifo(keyboard) +os.mkfifo(work / "input" / "event1") # a second device, never used +marker = work / "rebooted" +asound = cards(["closed"]) +holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is +holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK) +result = {} + + +def run(): + result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2", + "--input-glob", str(work / "input" / "event*"), + "--asound-root", str(asound), + "--reboot-command", "touch", str(marker)]) + + +thread = threading.Thread(target=run, daemon=True) +started = time.monotonic() +thread.start() +for _ in range(10): # typing every 0.4 s for 4 s + os.write(holder, b"k" * 24) + time.sleep(0.4) +check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists()) +thread.join(8) +waited = time.monotonic() - started +check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)", + marker.exists() and 5.5 <= waited <= 8, result) + +marker.unlink(missing_ok=True) +playing = cards(["state: RUNNING"]) +result.clear() + + +def run_playing(): + result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2", + "--input-glob", str(work / "input" / "event*"), + "--asound-root", str(playing), + "--reboot-command", "touch", str(marker)]) + + +thread = threading.Thread(target=run_playing, daemon=True) +thread.start() +time.sleep(3) +check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists()) +((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n") +stopped = time.monotonic() +thread.join(6) +after = time.monotonic() - stopped +# Sound is seen at each check, so the limit runs from the last check that +# heard it: at most one check (0.2 s here, a minute in a classroom) early. +check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)", + marker.exists() and 0.75 <= after <= 3, result) +os.close(holder) +os.close(holder1) + +print() +print(f"{passed} passed, {failed} failed") +sys.exit(1 if failed else 0) +PY diff --git a/tests/test-release-versions.sh b/tests/test-release-versions.sh index 3fe9409..c751c29 100755 --- a/tests/test-release-versions.sh +++ b/tests/test-release-versions.sh @@ -441,8 +441,11 @@ publish("canary", "0.2.7") status, out = run() check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it", status == 0 and installed() == "0.2.7", out) -check("and reboots, because the manifest asks it to", - (work / "systemctl.log").read_text().strip() == "reboot") +# Since 0.2.6.5 not there and then: the reboot is handed to a service that +# waits until nobody is using the computer (classroom-reboot-when-idle). +check("and asks for the reboot the manifest wants, once nobody is using the computer", + (work / "systemctl.log").read_text().strip() + == "start --no-block ekklesia-classroom-reboot-when-idle.service") publish("canary", "0.2.6.1") device("canary", "0.2.7") diff --git a/tools/build-release.sh b/tools/build-release.sh index 418402f..90d32f0 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -193,6 +193,10 @@ install -Dm755 \ "$ROOT/scripts/ekklesia-classroom-update" \ "$STAGE/usr/local/bin/ekklesia-classroom-update" +install -Dm755 \ + "$ROOT/scripts/classroom-reboot-when-idle" \ + "$STAGE/usr/local/bin/classroom-reboot-when-idle" + # System configuration. install -Dm644 \ "$ROOT/config/sddm/ekklesia-classroom.conf" \ @@ -206,6 +210,11 @@ install -Dm644 \ "$ROOT/systemd/updater/ekklesia-classroom-update.timer" \ "$STAGE/etc/systemd/system/ekklesia-classroom-update.timer" +# After an update: the reboot, once nobody is using the computer. +install -Dm644 \ + "$ROOT/systemd/updater/ekklesia-classroom-reboot-when-idle.service" \ + "$STAGE/etc/systemd/system/ekklesia-classroom-reboot-when-idle.service" + install -Dm644 \ "$ROOT/systemd/provision/ekklesia-classroom-provision.service" \ "$STAGE/etc/systemd/system/ekklesia-classroom-provision.service" diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh index 20d405f..b1e1d7c 100755 --- a/tools/validate-trixie-release.sh +++ b/tools/validate-trixie-release.sh @@ -145,6 +145,20 @@ systemctl is-enabled NetworkManager.service >/dev/null systemctl is-enabled cups.service >/dev/null ! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1 +# The update check: daily at 03:00, caught up after a night switched off, +# and still so after this install or upgrade. The reboot after an update +# waits until nobody is using the computer, in a unit nothing enables. +systemctl is-enabled ekklesia-classroom-update.timer >/dev/null +timer=/etc/systemd/system/ekklesia-classroom-update.timer +grep -qx 'OnCalendar=\*-\*-\* 03:00:00' "$timer" +grep -qx 'Persistent=true' "$timer" +! grep -qE '^(OnBootSec|OnStartupSec|OnUnitActiveSec|OnUnitInactiveSec|OnActiveSec)=' "$timer" +! ls /etc/systemd/system/ekklesia-classroom-update.timer.d/*.conf >/dev/null 2>&1 +test -x /usr/local/bin/classroom-reboot-when-idle +[[ "$(systemctl is-enabled ekklesia-classroom-reboot-when-idle.service 2>/dev/null || true)" == "static" ]] +! grep -q '"systemctl", "reboot"' /usr/local/bin/ekklesia-classroom-update +grep -q 'ekklesia-classroom-reboot-when-idle.service' /usr/local/bin/ekklesia-classroom-update + # The audio policy reaches a session, and is ordered after the server it # actually talks to. Ordering it only after pipewire and wireplumber is # what let it race PipeWire's PulseAudio server at boot, fail its one