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