Compare commits

..
Author SHA1 Message Date
Frank 2b7b0bc3cd Make the idle-lock watcher survive a clean exit, and outlive a session restart
`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
2026-10-20 17:24:32 -07:00
dank 3809e1ed22 Merge pull request 'deploy.sh declared this repo's own heartbeat unit foreign, on every box' (#46) from fix/44-unit-specifier-resolution into main 2026-10-20 17:22:52 -07:00
Frank e278e4b863 Expand systemd specifiers before calling a unit foreign
The first real `deploy.sh` run on blankpad printed:

    SKIP   live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh' absent on this host)

while `/home/blank/.omp/scripts/live-sync.sh` sits right there. `%h` is expanded by systemd,
never by the shell, so the literal string was handed to `command -v`, failed, and the unit was
declared to belong to another machine — through the same code path whose whole purpose is to
keep a *foreign* unit inert, and silently. The victim was this repo's own heartbeat, and the
verdict was the same on every box that ever deployed it, because `%h` is exactly where the
checkout lives.

Nothing broke tonight only by luck of install state: `live-sync.service` is `linked`, driven
by its timer, so the heartbeat kept running while the deploy lied about it. A unit whose only
install route was this loop would simply never have been enabled anywhere.

`%h`, `%u` and `%U` are now expanded before resolution; an unrecognised specifier is left
alone so it fails loudly rather than being guessed at. Pinned by a test that writes a
`%h/.local/bin/helper` unit into the scratch home — red before (`SKIP`), green after
(`already linked; not started`), which also keeps the #45 rule intact: resolving a unit is not
licence to start it. Refs #44.

Verification: `bats tests/` 71/71, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, `bash -n` clean.
2026-10-20 17:19:49 -07:00
dank c05f5ad451 Merge pull request 'deploy.sh is unsafe beside a live session: it starts a screen lock and imports another box settings' (#45) from fix/44-deploy-live-safe into main 2026-10-20 17:16:47 -07:00
4 changed files with 86 additions and 1 deletions
@@ -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]
+51
View File
@@ -158,6 +158,27 @@ fresh box — where everything reports `disabled` — still gets its units up. B
are pinned in `tests/deploy.bats` over a stub `systemctl`, because the real one on this box
is exactly what must not be invoked from a test. Refs #44.
## Law: expand systemd specifiers before calling a unit foreign
`ExecStart=%h/.omp/scripts/live-sync.sh` is valid systemd and meaningless to a shell. The
resolution check handed that literal string to `command -v`, which fails everywhere, so the
deployment concluded the unit belonged to another machine and skipped it — silently, through
the same code path that is supposed to keep a *foreign* unit inert. The unit it kept skipping
was this repo's own heartbeat, and `%h` is precisely where this checkout lives, so the false
verdict was the same on every box that deployed it. Measured on blankpad: the first real
`deploy.sh` run printed `SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh'
absent on this host)` while `/home/blank/.omp/scripts/live-sync.sh` sits right there.
Expand the specifiers a user unit can carry — `%h`, `%u`, `%U` — before resolving, and let an
unknown specifier fail rather than guess at it. The probe needs no privileges and writes
nothing:
```bash
bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \
~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1)
command -v "${bin//%h/$HOME}" # resolves → the unit is this box's; the SKIP was the lie
```
## Law: the dconf dump holds preferences, not per-box state
`dconf load /` writes every key in `dotfiles/dconf/dump.txt`, so the dump carries whatever
@@ -184,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.
+8
View File
@@ -123,6 +123,14 @@ if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user"
for unit in "$PKGS_DIR"/systemd-user/.config/systemd/user/*.service; do
[ -e "$unit" ] || continue
bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$unit" | head -1)"
# systemd expands specifiers inside ExecStart; this shell does not. Matching the literal
# `%h/…` makes a unit of THIS box look foreign and skips it in silence — which is exactly
# how live-sync.service, the repo's own heartbeat, has been reported "absent on this
# host" on every machine that deployed it. Expand what a user unit can carry; leave any
# other specifier unmatched rather than guess at it. Refs #44.
bin="${bin//%h/$HOME}"
bin="${bin//%u/$(id -un)}"
bin="${bin//%U/$(id -u)}"
if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then
name="$(basename "$unit")"
# Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly
+16
View File
@@ -136,3 +136,19 @@ systemd_unit() {
[[ "$output" == *"SKIP"*otherbox.service* ]]
! grep -q enable "$STUB_LOG"
}
@test "a unit whose ExecStart uses a systemd specifier still resolves here" {
# `%h` is expanded by systemd, not by the shell, so matching the literal string makes a
# unit belonging to THIS box look foreign and skip it in silence. The victim is this repo's
# own heartbeat: `ExecStart=%h/.omp/scripts/live-sync.sh`, skipped on every box. Refs #44.
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\ntrue\n' > "$HOME/.local/bin/helper"
chmod +x "$HOME/.local/bin/helper"
systemd_unit heartbeat.service '%h/.local/bin/helper'
printf 'linked\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"heartbeat.service (already linked"* ]]
grep -q 'systemctl --user enable heartbeat.service' "$STUB_LOG"
! grep -q -- '--now' "$STUB_LOG"
}