From 176d17ba3aca810de2bb2860ab991384fab7aec6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 18 Jul 2026 07:03:11 +0200 Subject: [PATCH] Add change explanation: night-lockdown-wake-fixes Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S4YWZhDcEUMUi8cyw9AzTR --- ...explanation-night-lockdown-wake-fixes.html | 510 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-18-explanation-night-lockdown-wake-fixes.html 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 @@ + +Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits + + +
+

Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits

+

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.

+ + + +
+

Background

+

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.

+ +

Blanking the console

+

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 morning alarm

+

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.

+
+ +
+

Intuition

+ +

Bug 1 — a flash of old console text before black

+

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:

+ +
+
before +
systemctl stop lightdm.service — running…
+
+
← VT switches mid-way here; console redraws OLD text, nothing is blanking it →
+
before +
blank_console() — only now, once stop() has fully returned
+
+

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:

+
+
after +
background poller: blank_console() every 0.1s ⟷ systemctl stop lightdm.service
+
after +
blank_console() once more, guaranteed, after stop() returns
+
+ +

Bug 2 — the second monitor didn't come back

+

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.

+ +

Bug 3 — the alarm fired only 6 hours after a late lockdown

+

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:

+
+ + + + + +
07:30
dynamic one-shot: FIRES
+
+

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.

+ +
+ Also fixed DRY_RUN is a pure logger — it never really stops + 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. +
+
+ +
+

Code walkthrough

+

Grouped by concern, not by file order.

+ +

1. Console-blank timing race

+
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. DisplayPort recovery

+
# 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.

+ +

3. Wake-alarm floor + its own daily dedup

+
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
+ +

4. The dynamic wake-floor trigger

+
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.

+
+ +
+

Try it: when does the alarm actually fire?

+

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.

+
+ + + + + +
+
+
+ +
+

Quiz

+
+

Score: 0 / 5

+
+
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 94171a54..ffa0b5f7 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@
testsAndMisc

Change explanations

-

9 explanations · interactive walkthroughs of notable changes, newest first.

+

10 explanations · interactive walkthroughs of notable changes, newest first.