diff --git a/docs/explanations/2026-07-18-explanation-night-lockdown-wake-fixes.html b/docs/explanations/2026-07-18-explanation-night-lockdown-wake-fixes.html new file mode 100644 index 00000000..190675de --- /dev/null +++ b/docs/explanations/2026-07-18-explanation-night-lockdown-wake-fixes.html @@ -0,0 +1,510 @@ + +
Two commits fixing three live bugs in the always-on screen lockdown, plus a follow-up that + makes the wake-alarm floor correct for any shutdown time — not just the ones the fixed schedule happened to cover.
+ + + +This machine never actually shuts down at night anymore. Instead of powering off, a script called + night-lockdown tears the GUI down and blanks the screen while every background server + (sync jobs, media services, etc.) keeps running. Two generated scripts do the work:
+ +night-lockdown-enter.sh — runs at the configured shutdown hour. Mutes
+ audio, turns RGB lighting off, masks the login prompt, stops the display manager (lightdm),
+ and forces the text console black.
night-lockdown-unlock.sh — runs on a morning timer. Restores audio and
+ RGB state, unmasks the login prompt, restarts lightdm, and — on days you've configured an
+ alarm — starts the morning wake-alarm routine.
Both scripts are idempotent against a single state file (LOCKED / UNLOCKED),
+ and the unlock side runs on a fixed staggered schedule: 05:00, 05:15, 05:30, 06:00, 07:00. The
+ idea was redundancy — if the 05:00 run fails for some reason, a later trigger catches it, but since the
+ action is idempotent, only the first one to actually run does anything.
Making the screen go black turned out to be more subtle than it sounds. This machine has exactly one
+ console framebuffer, nvidia-drmdrmfb, owned by the discrete NVIDIA GPU that drives both
+ connected monitors. The original code went to some trouble to not power the DisplayPort output
+ down (only FB_BLANK_NORMAL, not FB_BLANK_POWERDOWN) because powering it down had
+ previously dropped the DP link so hard the cable needed a physical replug:
# Force the console dark — black screen, but WITHOUT DPMS power management.
+# Powering the output down ... makes DisplayPort drop its link ...
+blank_console() {
+ local fb
+ for fb in /sys/class/graphics/fb*/blank; do
+ [[ -e "$fb" ]] || continue
+ echo "$CONSOLE_BLANK_MODE" >"$fb" 2>/dev/null || logg "WARN: could not blank $fb"
+ done
+ setterm --term linux --blank force --powersave off >"$CONSOLE_TTY" 2>/dev/null || ...
+}
+ And the lockdown-entry flow called it only after the display manager had fully stopped:
+# 7. Stop the GUI. This hands the VT back to fbcon, which unblanks it.
+run systemctl stop lightdm.service
+
+# 8. Now that fbcon owns the VT again, force it dark (DPMS off -> standby).
+blank_console
+
+ The unlock script also re-fires a "morning routine" service (wake alarm + workout lock) on configured + alarm days, but only as the very last step of the same idempotent run that restores the GUI:
+maybe_start_morning_alarm() {
+ [[ -n "$LOCK_USER" && -n "$LOCK_UID" && -n "$MORNING_ROUTINE_UNIT" ]] || return 0
+ ... # alarm-day check only
+ logg "alarm day (dow=$dow) — starting $MORNING_ROUTINE_UNIT"
+ run sudo -u "$LOCK_USER" ... systemctl --user start --no-block "$MORNING_ROUTINE_UNIT"
+}
+ Nothing in this function knew or cared what time lockdown had actually started the night before.
+The comment in the original code says stopping lightdm "hands the VT back to fbcon, which
+ unblanks it." That's the key fact: switching a virtual console active is not a passive handoff — the kernel
+ redraws it, showing whatever was already in that console's text buffer (old kernel/systemd log lines).
+ systemctl stop lightdm.service is one blocking call that can take a real one-to-a-few seconds
+ to fully finish (session cleanup, PAM, etc.), and the VT switch can happen at any point during that
+ call — not only at the very end. Blanking only after the whole call returns leaves that window
+ completely exposed:
The fix keeps a background loop calling blank_console every 100 ms for the whole
+ duration of the stop, so whichever moment the VT switch actually happens, something is re-darkening it within
+ a tenth of a second:
There was simply no step anywhere in the unlock flow that touched display configuration. If the
+ DisplayPort link didn't cleanly re-negotiate the instant lightdm restarted, nothing ever asked
+ it to. The fix adds a short retry loop calling autorandr --change — using a profile you save
+ once, in advance, while both monitors are connected correctly — right after lightdm restarts.
Lockdown entered at 23:00. The unlock schedule's first trigger, 05:00, ran the whole idempotent
+ unlock-and-alarm sequence in one go — 6 hours later, not the intended 8. The first fix added a floor: record
+ the real lockdown-entry timestamp, and don't start the alarm until entry + 8h — but the alarm
+ check still only ever got re-evaluated by whichever of the five fixed triggers happened to run
+ next.
That's fine as long as the floor lands inside 05:00–07:00. But the shutdown hour itself is
+ a config value (/etc/shutdown-schedule.conf) that already changes dynamically — a "sick day"
+ feature had pushed it to 23:00 the very night this bug was reported. Push it just a little later, say
+ 23:30, and the floor lands at 07:30 — past every remaining trigger. Nothing left in the fixed
+ schedule ever checks again, so the alarm silently never fires that morning at all:
Toy example — lockdown at 23:30, floor = entry + 8h = 07:30:
+Every fixed trigger says "too soon" and nothing runs again after 07:00 — unless something is
+ scheduled specifically for 07:30. The follow-up fix computes that exact moment when lockdown begins and
+ schedules a one-shot systemd-run --on-calendar timer for it, so the alarm gets a guaranteed,
+ correctly-timed chance no matter what the shutdown hour was that night. Play with the exact same rule
+ yourself below.
lightdm or calls autorandr, so it can prove the alarm-floor arithmetic works but
+ it structurally cannot prove bugs 1 or 2 are fixed. Both scripts now print an explicit
+ DRY_RUN NOTE saying exactly that, so a clean dry run is never mistaken for a real one.
+ Grouped by concern, not by file order.
+ +if [[ "$DRY_RUN" != "1" ]]; then
+ (
+ while :; do
+ blank_console
+ sleep 0.1
+ done
+ ) &
+ BLANK_POLLER_PID=$!
+fi
+run systemctl stop lightdm.service
+if [[ -n "${BLANK_POLLER_PID:-}" ]]; then
+ kill "$BLANK_POLLER_PID" 2>/dev/null || true
+ wait "$BLANK_POLLER_PID" 2>/dev/null || true
+ unset BLANK_POLLER_PID
+fi
+
+# Final guaranteed blank now that the GUI is fully down.
+blank_console
+ A background subshell keeps re-blanking every 100 ms while the (blocking, possibly slow)
+ systemctl stop lightdm.service runs, then gets killed and a final blank runs once more for
+ good measure. Cheap and idempotent — blank_console was already safe to call repeatedly.
# 2. Start the GUI — lightdm autologins back into i3/dwm.
+run systemctl start lightdm.service
+
+# 2a. Reconnect/reconfigure displays ...
+if [[ -n "$LOCK_USER" && -n "$LOCK_UID" ]] && command -v autorandr >/dev/null 2>&1; then
+ autorandr_ok=false
+ for ((i = 0; i < 15; i++)); do
+ if sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" DISPLAY=:0 \
+ autorandr --change --default "$AUTORANDR_PROFILE" >/dev/null 2>&1; then
+ autorandr_ok=true; break
+ fi
+ sleep 1
+ done
+ ...
+fi
+ Runs as the real desktop user (not root), retries for up to 15 seconds to give X time to re-enumerate
+ the link, and falls back to the saved default profile if nothing matches. Requires a one-time,
+ manual autorandr --save default while both monitors are connected correctly.
today="$(date +%F)"
+if [[ "$(cat "$STATE_DIR/alarm_started_date" 2>/dev/null || echo '')" == "$today" ]]; then
+ logg "morning alarm already started today — skipping"
+ return 0
+fi
+locked_at="$(cat "$STATE_DIR/locked_at_epoch" 2>/dev/null || echo 0)"
+now="$(date +%s)"
+min_wake=$((locked_at + MIN_WAKE_AFTER_LOCKDOWN_HOURS * 3600))
+if ((locked_at > 0 && now < min_wake)); then
+ logg "too soon since lockdown entry (...m remaining) — skipping alarm this trigger"
+ return 0
+fi
+...
+echo "$today" >"$STATE_DIR/alarm_started_date"
+ The dedup marker matters because the alarm check is no longer gated by the same LOCKED/UNLOCKED flag + that gates the GUI restore — it now runs from both branches of the unlock script, including the + already-unlocked early exit:
+if [[ "$(cat "$STATE_FILE" ...) " == "UNLOCKED" ]]; then
+ maybe_start_morning_alarm # still gets a chance, even though restore already happened
+ logg "already UNLOCKED — nothing else to do"
+ exit 0
+fi
+
+ if [[ "$DRY_RUN" != "1" ]]; then
+ echo LOCKED >"$STATE_FILE"
+ lockdown_epoch="$(date +%s)"
+ echo "$lockdown_epoch" >"$STATE_DIR/locked_at_epoch"
+
+ floor_epoch=$((lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS * 3600))
+ floor_cal="$(date -d "@$floor_epoch" '+%Y-%m-%d %H:%M:%S')"
+ run systemd-run --unit=night-lockdown-wake-floor --collect \
+ --on-calendar="$floor_cal" /usr/local/bin/night-lockdown-unlock.sh
+fi
+ At the exact moment lockdown begins, this schedules a one-off, self-cleaning
+ (--collect) systemd timer for precisely entry + 8h, whatever that turns out to
+ be, and simply re-runs the unlock script when it fires. Because the unlock script is already idempotent and
+ the alarm has its own dedup, re-running it is always safe — it either restores a GUI that's already up (no-op)
+ and/or fires the alarm exactly once if it hasn't already.
Move the slider to set the lockdown-entry time, then step through the same schedule the real script
+ checks: five fixed triggers, plus the one dynamic trigger scheduled precisely at entry + floor
+ hours.
Score: 0 / 5
+9 explanations · interactive walkthroughs of notable changes, newest first.
+10 explanations · interactive walkthroughs of notable changes, newest first.