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:
co-authored by
Claude Opus 5.5
parent
23194a085c
commit
3c40e849ea
Executable
+182
@@ -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
|
||||
Reference in New Issue
Block a user