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
+157
View File
@@ -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())
+7 -2
View File
@@ -14,6 +14,7 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf" CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock" LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom" CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom" PACKAGE = "ekklesia-classroom"
@@ -291,8 +292,12 @@ def main():
if args.no_reboot: if args.no_reboot:
log("Update requires reboot, but --no-reboot was specified.") log("Update requires reboot, but --no-reboot was specified.")
else: else:
log("Update requires reboot. Rebooting now.") # Not now. At 03:00 nobody is there; after a missed 03:00,
subprocess.run(["systemctl", "reboot"]) # 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 return 0
@@ -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
+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
+5 -2
View File
@@ -441,8 +441,11 @@ publish("canary", "0.2.7")
status, out = run() status, out = run()
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it", 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) status == 0 and installed() == "0.2.7", out)
check("and reboots, because the manifest asks it to", # Since 0.2.6.5 not there and then: the reboot is handed to a service that
(work / "systemctl.log").read_text().strip() == "reboot") # 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") publish("canary", "0.2.6.1")
device("canary", "0.2.7") device("canary", "0.2.7")
+9
View File
@@ -193,6 +193,10 @@ install -Dm755 \
"$ROOT/scripts/ekklesia-classroom-update" \ "$ROOT/scripts/ekklesia-classroom-update" \
"$STAGE/usr/local/bin/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. # System configuration.
install -Dm644 \ install -Dm644 \
"$ROOT/config/sddm/ekklesia-classroom.conf" \ "$ROOT/config/sddm/ekklesia-classroom.conf" \
@@ -206,6 +210,11 @@ install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-update.timer" \ "$ROOT/systemd/updater/ekklesia-classroom-update.timer" \
"$STAGE/etc/systemd/system/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 \ install -Dm644 \
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \ "$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service" "$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
+14
View File
@@ -145,6 +145,20 @@ systemctl is-enabled NetworkManager.service >/dev/null
systemctl is-enabled cups.service >/dev/null systemctl is-enabled cups.service >/dev/null
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1 ! 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 # The audio policy reaches a session, and is ordered after the server it
# actually talks to. Ordering it only after pipewire and wireplumber is # actually talks to. Ordering it only after pipewire and wireplumber is
# what let it race PipeWire's PulseAudio server at boot, fail its one # what let it race PipeWire's PulseAudio server at boot, fail its one