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
+157
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user