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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user