From 2b7b0bc3cdd3478f805ddf46649516955bd6c3f6 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 20 Oct 2026 17:24:32 -0700 Subject: [PATCH] Make the idle-lock watcher survive a clean exit, and outlive a session restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swayidle-lock.service` was `enabled`, in `default.target.wants/`, and **dead for eight days**: Active: inactive (dead) since Mon 2026-10-12 16:22:23 PDT; 1 week 1 day ago Duration: 2d 21h 57min Main PID: 3181 (code=exited, status=0/SUCCESS) Result=success NRestarts=0 `Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. So the watcher that owns both the 5-minute idle lock and the `before-sleep` lock stopped existing, and nothing on the box noticed. The consequence is not cosmetic: with `HandleLidSwitch=suspend` and `HandleLidSwitchExternalPower=""` (which falls back to it), **closing the lid suspended the machine unlocked**, because the hook that locks on sleep lived inside the unit that had died. No alternative was running: `pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock'` = 0, and neither `autostart.kdl` launches a locker. Three settings, each with a job: - `Restart=always` — a supervisor must not assume it can only die badly. - `StartLimitIntervalSec=0` — with `always`, a fast loop must never wedge the unit into `failed`; a lock that keeps trying beats a lock that stopped trying. - `PartOf=graphical-session.target` — it is wanted by `default.target`, so a session restart brought the target back without it; now the watcher is restarted with the session. What triggered the 16:22 exit is **unattributed**: the user journal has no entries in that window, so all that can be said is that swayidle exited with status 0 — precisely what the old policy was blind to. The fix does not depend on knowing the trigger, which is the point. Verification is behavioural, per the law recorded with it: start the unit, `kill -TERM` the main PID, and watch `NRestarts` increment with a new `MainPID` — the October event, survived. Not performed here: starting it arms a 5-minute idle lock on a machine that has gone a week without one, and that is Frank's call. `systemd-analyze --user verify` clean, `bats tests/` 71/71, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. Refs #47 --- .../systemd/user/swayidle-lock.service | 12 +++++++- omp-docs/tools/dotfiles-deploy.md | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/dotfiles/systemd-user/.config/systemd/user/swayidle-lock.service b/dotfiles/systemd-user/.config/systemd/user/swayidle-lock.service index ef5f63d..2bb987e 100644 --- a/dotfiles/systemd-user/.config/systemd/user/swayidle-lock.service +++ b/dotfiles/systemd-user/.config/systemd/user/swayidle-lock.service @@ -2,6 +2,13 @@ Description=swayidle idle lock (locks via noctalia-shell IPC, suspends on lid) Documentation=https://github.com/swaywm/swayidle After=graphical-session.target +# Restart together with the session, not merely at boot: this unit is wanted by +# default.target, so a compositor restart used to leave it orphaned while the target came +# back without it. Refs #47. +PartOf=graphical-session.target +# With Restart=always below, a fast failure loop must never wedge the watcher into `failed`: +# a lock that keeps trying beats a lock that stopped trying. Refs #47. +StartLimitIntervalSec=0 [Service] # Locks via the noctalia v5 shell; `noctalia msg` resolves the running instance @@ -11,7 +18,10 @@ ExecStart=/usr/bin/swayidle -w \ timeout 300 '/usr/bin/noctalia msg session lock' \ before-sleep '/usr/bin/noctalia msg session lock' \ after-resume '/usr/bin/noctalia msg session lock' -Restart=on-failure +# `swayidle -w` exits 0 on SIGTERM, and that is how this watcher died on 2026-10-12: a clean +# exit that `Restart=on-failure` cannot see, leaving the box unlocked for a week. A supervisor +# restarts always. Refs #47. +Restart=always RestartSec=5s [Install] diff --git a/omp-docs/tools/dotfiles-deploy.md b/omp-docs/tools/dotfiles-deploy.md index 94324af..4ff526d 100644 --- a/omp-docs/tools/dotfiles-deploy.md +++ b/omp-docs/tools/dotfiles-deploy.md @@ -205,3 +205,33 @@ it in the other. Re-dump on the box whose settings are canonical (`dconf dump / dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's *Never track: app state* class applies inside a tracked file, not just to whole directories. Refs #44. + +## Law: a watcher must restart on a clean exit too + +`swayidle-lock.service` was `enabled`, present in `default.target.wants/`, and **dead for eight +days**: `Result=success`, `NRestarts=0`, gone since 2026-10-12 16:22 after 2d 21h of uptime. +`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM — so the +watcher that owns the 5-minute idle lock *and* the `before-sleep` lock quietly stopped existing, +and nothing noticed. With it dead and logind at `HandleLidSwitch=suspend`, closing the lid +suspended the machine **unlocked**: the lock hook lived inside the thing that had died. No +alternative existed — `pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock'` = 0, and neither +`autostart.kdl` launches a locker. + +Three settings, each with a job: `Restart=always`, because a supervisor must not assume it can +only die badly; `StartLimitIntervalSec=0`, because a fast loop must never wedge it into `failed` — +a lock that keeps trying beats one that stopped trying; and `PartOf=graphical-session.target`, +because the unit is wanted by `default.target`, so the session coming back did not bring *it* +back. + +Verify by behaviour rather than by reading the file — the point is surviving the event that killed +it in October: + +```bash +systemctl --user start swayidle-lock.service +kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)" +sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState +``` + +The general shape: `deploy.sh` only ever *enables*. Nothing on any box re-checks that a unit the +system depends on is still running, so a security posture can lapse silently and stay lapsed for a +week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.