fix: an update reboots the computer only once nobody is using it

The update check has been daily at 03:00, Persistent, since 0.1.5 — the
lab's installed stable machine logged it at 03:01 and 03:04 on successive
days — and it stays so. What changes is the reboot after an update. It
happened the moment the update was installed: harmless at 03:00, and a
room restarting itself mid-morning when a computer switched off at 03:00
caught up after being switched on.

Now the updater installs, with every check it had (the device's own
channel, a newer version, the manifest's SHA-256, name and version), and
starts classroom-reboot-when-idle, which reboots once no input device has
produced an event for 30 minutes and no sound card has played for as
long. At 03:00 that is about 03:30; switched off first, the next start is
on the new version. The service has no [Install] and nothing enables it.

The release gate checks after every install and upgrade that the timer is
enabled, daily at 03:00, Persistent, with no boot or repeat trigger and
no drop-in, and that the updater no longer reboots by itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 22:56:08 -05:00
co-authored by Claude Opus 5.5
parent 23194a085c
commit 3c40e849ea
7 changed files with 384 additions and 4 deletions
+182
View File
@@ -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