Add change explanation: night-lockdown-wake-fixes
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4YWZhDcEUMUi8cyw9AzTR
This commit is contained in:
Krzysztof Rudnicki 2026-07-18 07:03:11 +02:00
parent 88eda8dc51
commit 176d17ba3a
2 changed files with 515 additions and 1 deletions

View File

@ -0,0 +1,510 @@
<meta charset="utf-8">
<title>Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits</title>
<style>
:root {
--bg: #ffffff; --fg: #1c1f24; --muted: #5b6470; --card: #f4f6f8;
--border: #e0e4e9; --accent: #2f6fed; --accent-bg: #eaf1ff;
--good: #1a7f4e; --good-bg: #e8f7ef; --bad: #b8291f; --bad-bg: #fdecea;
--code-bg: #0f1420; --code-fg: #d9e0ea; --code-accent: #7ec3ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c; --fg: #e7ebf0; --muted: #9aa4b2; --card: #1c2129;
--border: #2b323d; --accent: #6ea8ff; --accent-bg: #1c2a44;
--good: #4fd88a; --good-bg: #142a1f; --bad: #ff8078; --bad-bg: #331615;
--code-bg: #0b0e14; --code-fg: #d9e0ea; --code-accent: #7ec3ff;
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.25rem 6rem; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.65; font-size: 16.5px;
}
.page { max-width: min(48rem, 92vw); margin: 0 auto; }
h1 { font-size: 1.7rem; line-height: 1.3; margin-bottom: .3rem; }
.subtitle { color: var(--muted); margin-top: 0; margin-bottom: 2rem; font-size: 1.02rem; }
h2 { font-size: 1.35rem; margin-top: 3rem; padding-top: .5rem; border-top: 1px solid var(--border); scroll-margin-top: 5rem; }
h3 { font-size: 1.08rem; margin-top: 1.8rem; }
p { margin: 0.9rem 0; }
a { color: var(--accent); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; background: var(--card); padding: .1em .35em; border-radius: 4px; }
pre {
background: var(--code-bg); color: var(--code-fg); padding: 1rem 1.1rem; border-radius: 10px;
overflow-x: auto; white-space: pre-wrap; word-break: break-word; font-size: .86rem; line-height: 1.55;
}
pre code { background: none; padding: 0; color: inherit; }
.toc {
position: sticky; top: 0; z-index: 20; background: var(--bg);
border-bottom: 1px solid var(--border); padding: .8rem 0 .9rem; margin: 0 0 1rem;
}
.toc ul { list-style: none; display: flex; flex-wrap: wrap; gap: .5rem 1rem; padding: 0; margin: 0; }
.toc a {
text-decoration: none; font-weight: 600; font-size: .88rem; color: var(--fg);
background: var(--card); border: 1px solid var(--border); padding: .35rem .7rem; border-radius: 999px;
}
.toc a:hover { border-color: var(--accent); color: var(--accent); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.25rem; margin: 1.2rem 0; }
.badge { display: inline-block; font-size: .74rem; font-weight: 700; letter-spacing: .02em; text-transform: uppercase;
padding: .15rem .55rem; border-radius: 999px; margin-right: .4rem; }
.b-bug { background: var(--bad-bg); color: var(--bad); }
.b-fix { background: var(--good-bg); color: var(--good); }
.b-note { background: var(--accent-bg); color: var(--accent); }
/* diagrams */
.timeline { display: flex; flex-wrap: wrap; align-items: stretch; gap: .35rem; margin: 1rem 0; }
.tl-node { flex: 1 1 5.5rem; min-width: 5.2rem; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg); padding: .55rem .5rem; text-align: center; position: relative; }
.tl-node .time { font-weight: 700; font-size: .92rem; }
.tl-node .label { font-size: .72rem; color: var(--muted); margin-top: .2rem; }
.tl-node.skip { opacity: .55; }
.tl-node.fire { border-color: var(--good); background: var(--good-bg); }
.tl-node.dyn { border-style: dashed; border-color: var(--accent); }
.tl-node.dyn.fire { border-color: var(--good); background: var(--good-bg); }
.arrowrow { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .8rem; }
.stack { display: flex; flex-direction: column; gap: .5rem; margin: 1rem 0; }
.row { display: grid; grid-template-columns: 8.5rem 1fr; gap: .75rem; align-items: center; }
.row .tag { font-size: .78rem; font-weight: 700; color: var(--muted); text-align: right; }
.bar { height: 2.1rem; border-radius: 8px; display: flex; align-items: center; padding: 0 .6rem;
font-size: .82rem; font-weight: 600; border: 1px solid var(--border); }
.bar.gap { background: var(--bad-bg); color: var(--bad); }
.bar.blank { background: var(--good-bg); color: var(--good); }
.bar.stop { background: var(--card); color: var(--fg); }
.micro { border: 1px solid var(--border); border-radius: 14px; padding: 1.2rem; background: var(--card); }
.micro label { display: block; font-size: .85rem; font-weight: 600; margin: .6rem 0 .3rem; }
.micro input[type=range] { width: 100%; }
.micro .readout { font-size: .95rem; margin: .5rem 0 1rem; }
.micro button {
background: var(--accent); color: white; border: none; padding: .55rem 1.1rem; border-radius: 8px;
font-weight: 700; cursor: pointer; font-size: .9rem;
}
.micro button:hover { filter: brightness(1.08); }
.micro .result { margin-top: 1rem; font-size: .92rem; }
.quiz-q { border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.2rem; margin: 1.2rem 0; background: var(--card); }
.quiz-q h4 { margin: 0 0 .8rem; font-size: 1rem; }
.opt { display: block; width: 100%; text-align: left; background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: .6rem .8rem; margin: .4rem 0; cursor: pointer; font-size: .9rem; color: var(--fg); }
.opt:hover { border-color: var(--accent); }
.opt.correct { border-color: var(--good); background: var(--good-bg); }
.opt.wrong { border-color: var(--bad); background: var(--bad-bg); }
.rationale { font-size: .85rem; color: var(--muted); margin-top: .6rem; display: none; }
.rationale.show { display: block; }
#score { font-weight: 700; }
.fnote { color: var(--muted); font-size: .85rem; }
</style>
<div class="page">
<h1>Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits</h1>
<p class="subtitle">Two commits fixing three live bugs in the always-on screen lockdown, plus a follow-up that
makes the wake-alarm floor correct for <em>any</em> shutdown time — not just the ones the fixed schedule happened to cover.</p>
<nav class="toc">
<ul>
<li><a href="#background">Background</a></li>
<li><a href="#intuition">Intuition</a></li>
<li><a href="#code">Code</a></li>
<li><a href="#quiz">Quiz</a></li>
</ul>
</nav>
<section id="background">
<h2>Background</h2>
<p>This machine never actually shuts down at night anymore. Instead of powering off, a script called
<strong>night-lockdown</strong> 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:</p>
<div class="card">
<p><strong><code>night-lockdown-enter.sh</code></strong> — runs at the configured shutdown hour. Mutes
audio, turns RGB lighting off, masks the login prompt, stops the display manager (<code>lightdm</code>),
and forces the text console black.</p>
<p><strong><code>night-lockdown-unlock.sh</code></strong> — runs on a morning timer. Restores audio and
RGB state, unmasks the login prompt, restarts <code>lightdm</code>, and — on days you've configured an
alarm — starts the morning wake-alarm routine.</p>
</div>
<p>Both scripts are idempotent against a single state file (<code>LOCKED</code> / <code>UNLOCKED</code>),
and the unlock side runs on a fixed staggered schedule: <code>05:00, 05:15, 05:30, 06:00, 07:00</code>. 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.</p>
<h3>Blanking the console</h3>
<p>Making the screen go black turned out to be more subtle than it sounds. This machine has exactly one
console framebuffer, <code>nvidia-drmdrmfb</code>, owned by the discrete NVIDIA GPU that drives both
connected monitors. The original code went to some trouble to <em>not</em> power the DisplayPort output
down (only <code>FB_BLANK_NORMAL</code>, not <code>FB_BLANK_POWERDOWN</code>) because powering it down had
previously dropped the DP link so hard the cable needed a physical replug:</p>
<pre><code># 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 || ...
}</code></pre>
<p>And the lockdown-entry flow called it only <em>after</em> the display manager had fully stopped:</p>
<pre><code># 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</code></pre>
<h3>The morning alarm</h3>
<p>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:</p>
<pre><code>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"
}</code></pre>
<p>Nothing in this function knew or cared what time lockdown had actually started the night before.</p>
</section>
<section id="intuition">
<h2>Intuition</h2>
<h3 id="bug1">Bug 1 — a flash of old console text before black</h3>
<p>The comment in the original code says stopping <code>lightdm</code> "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).
<code>systemctl stop lightdm.service</code> 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 <em>during</em> that
call — not only at the very end. Blanking only <em>after</em> the whole call returns leaves that window
completely exposed:</p>
<div class="stack">
<div class="row"><span class="tag">before</span>
<div class="bar stop">systemctl stop lightdm.service — running…</div></div>
<div class="row"><span class="tag"></span>
<div class="bar gap">← VT switches mid-way here; console redraws OLD text, nothing is blanking it →</div></div>
<div class="row"><span class="tag">before</span>
<div class="bar blank">blank_console() — only now, once stop() has fully returned</div></div>
</div>
<p>The fix keeps a background loop calling <code>blank_console</code> every 100&nbsp;ms for the <em>whole</em>
duration of the stop, so whichever moment the VT switch actually happens, something is re-darkening it within
a tenth of a second:</p>
<div class="stack">
<div class="row"><span class="tag">after</span>
<div class="bar blank">background poller: blank_console() every 0.1s ⟷ systemctl stop lightdm.service</div></div>
<div class="row"><span class="tag">after</span>
<div class="bar blank">blank_console() once more, guaranteed, after stop() returns</div></div>
</div>
<h3 id="bug2">Bug 2 — the second monitor didn't come back</h3>
<p>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 <code>lightdm</code> restarted, nothing ever asked
it to. The fix adds a short retry loop calling <code>autorandr --change</code> — using a profile you save
once, in advance, while both monitors are connected correctly — right after <code>lightdm</code> restarts.</p>
<h3 id="bug3">Bug 3 — the alarm fired only 6 hours after a late lockdown</h3>
<p>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 <code>entry + 8h</code> — but the alarm
check still only ever got <em>re-evaluated</em> by whichever of the five fixed triggers happened to run
next.</p>
<p>That's fine as long as the floor lands inside <code>05:00–07:00</code>. But the shutdown hour itself is
a config value (<code>/etc/shutdown-schedule.conf</code>) 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 — <em>past every remaining trigger</em>. Nothing left in the fixed
schedule ever checks again, so the alarm silently never fires that morning at all:</p>
<p><strong>Toy example — lockdown at 23:30, floor = entry + 8h = 07:30:</strong></p>
<div class="timeline">
<div class="tl-node skip"><div class="time">05:00</div><div class="label">too soon</div></div>
<div class="tl-node skip"><div class="time">05:15</div><div class="label">too soon</div></div>
<div class="tl-node skip"><div class="time">05:30</div><div class="label">too soon</div></div>
<div class="tl-node skip"><div class="time">06:00</div><div class="label">too soon</div></div>
<div class="tl-node skip"><div class="time">07:00</div><div class="label">too soon</div></div>
<div class="tl-node dyn fire"><div class="time">07:30</div><div class="label">dynamic one-shot: FIRES</div></div>
</div>
<p>Every fixed trigger says "too soon" and nothing runs again after 07:00 — <em>unless</em> something is
scheduled specifically for 07:30. The follow-up fix computes that exact moment when lockdown begins and
schedules a one-shot <code>systemd-run --on-calendar</code> 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.</p>
<div class="card b-note" style="margin-top:1.5rem;">
<span class="badge b-note">Also fixed</span> DRY_RUN is a pure logger — it never really stops
<code>lightdm</code> or calls <code>autorandr</code>, so it can prove the alarm-floor arithmetic works but
it structurally <em>cannot</em> prove bugs 1 or 2 are fixed. Both scripts now print an explicit
<code>DRY_RUN NOTE</code> saying exactly that, so a clean dry run is never mistaken for a real one.
</div>
</section>
<section id="code">
<h2>Code walkthrough</h2>
<p>Grouped by concern, not by file order.</p>
<h3>1. Console-blank timing race</h3>
<pre><code>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</code></pre>
<p>A background subshell keeps re-blanking every 100&nbsp;ms while the (blocking, possibly slow)
<code>systemctl stop lightdm.service</code> runs, then gets killed and a final blank runs once more for
good measure. Cheap and idempotent — <code>blank_console</code> was already safe to call repeatedly.</p>
<h3>2. DisplayPort recovery</h3>
<pre><code># 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</code></pre>
<p>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 <code>default</code> profile if nothing matches. Requires a one-time,
manual <code>autorandr --save default</code> while both monitors are connected correctly.</p>
<h3>3. Wake-alarm floor + its own daily dedup</h3>
<pre><code>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"</code></pre>
<p>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 <em>both</em> branches of the unlock script, including the
already-unlocked early exit:</p>
<pre><code>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</code></pre>
<h3>4. The dynamic wake-floor trigger</h3>
<pre><code>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</code></pre>
<p>At the exact moment lockdown begins, this schedules a one-off, self-cleaning
(<code>--collect</code>) systemd timer for precisely <code>entry + 8h</code>, 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.</p>
</section>
<section id="intuition-play">
<h2 id="micro-heading">Try it: when does the alarm actually fire?</h2>
<p>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 <code>entry + floor
hours</code>.</p>
<div class="micro">
<label for="entryTime">Lockdown entry time: <strong id="entryReadout">23:00</strong></label>
<input type="range" id="entryTime" min="1200" max="1440" step="15" value="1380">
<label for="floorHours">Minimum hours before waking: <strong id="floorReadout">8</strong>h</label>
<input type="range" id="floorHours" min="6" max="10" step="1" value="8">
<button id="simBtn">Step through tonight's triggers</button>
<div class="result" id="simResult"></div>
</div>
</section>
<section id="quiz">
<h2>Quiz</h2>
<div id="quizRoot"></div>
<p class="fnote">Score: <span id="score">0</span> / 5</p>
</section>
</div>
<script>
(function () {
"use strict";
// ---------- micro-world: trigger scheduler simulator ----------
var entryEl = document.getElementById("entryTime");
var entryReadout = document.getElementById("entryReadout");
var floorEl = document.getElementById("floorHours");
var floorReadout = document.getElementById("floorReadout");
var simBtn = document.getElementById("simBtn");
var simResult = document.getElementById("simResult");
function fmt(mins) {
mins = ((mins % 1440) + 1440) % 1440;
var h = Math.floor(mins / 60), m = mins % 60;
return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m;
}
function updateReadouts() {
entryReadout.textContent = fmt(parseInt(entryEl.value, 10));
floorReadout.textContent = floorEl.value;
}
entryEl.addEventListener("input", updateReadouts);
floorEl.addEventListener("input", updateReadouts);
updateReadouts();
var STATIC_TRIGGERS = [300, 315, 330, 360, 420]; // minutes-of-day: 05:00,05:15,05:30,06:00,07:00
function simulate() {
var entry = parseInt(entryEl.value, 10); // minutes of day, e.g. 1380 = 23:00
var floorHours = parseInt(floorEl.value, 10);
var floorAbs = entry + floorHours * 60; // may exceed 1440 (past midnight)
var floorNextDayMinutes = floorAbs % 1440; // where it lands the following morning
var html = '<div class="timeline">';
var firedAt = null;
for (var i = 0; i < STATIC_TRIGGERS.length; i++) {
var t = STATIC_TRIGGERS[i]; // these are always "next morning" times
var tooSoon = t < floorNextDayMinutes;
var cls = tooSoon ? "tl-node skip" : "tl-node fire";
html += '<div class="' + cls + '"><div class="time">' + fmt(t) + '</div><div class="label">' +
(tooSoon ? "too soon" : "FIRES here") + '</div></div>';
if (!tooSoon && firedAt === null) firedAt = t;
}
// Dynamic one-shot, always exactly at the floor moment.
var dynCls = "tl-node dyn" + (firedAt === null ? " fire" : "");
html += '<div class="' + dynCls + '"><div class="time">' + fmt(floorNextDayMinutes) +
'</div><div class="label">dynamic one-shot' + (firedAt === null ? ": FIRES (rescue)" : " (redundant)") +
'</div></div>';
html += '</div>';
var verdict;
if (firedAt !== null) {
verdict = 'A fixed trigger at <strong>' + fmt(firedAt) +
'</strong> already clears the floor, so the alarm fires there. The dynamic one-shot at ' +
fmt(floorNextDayMinutes) + ' would just re-confirm it (dedup makes that a no-op).';
} else {
verdict = '<strong>Every</strong> fixed trigger is still too soon — without the dynamic one-shot, the ' +
'alarm would silently never fire this morning. The one-shot scheduled for exactly ' +
fmt(floorNextDayMinutes) + ' is the only thing that catches it.';
}
simResult.innerHTML = html + "<p>" + verdict + "</p>";
}
simBtn.addEventListener("click", simulate);
simulate();
// ---------- quiz ----------
var QUESTIONS = [
{
q: "Why did the console briefly show old kernel/systemd text before going black?",
opts: [
"blank_console() silently failed to write to the framebuffer blank file used by this exact GPU model",
"The VT redraws its old buffer the moment it activates, before lightdm's stop call even returns",
"CONSOLE_BLANK_MODE was left at the DPMS power-down value instead of the safe normal value",
"The RTMIN+21 signal reached systemd before kernel.printk had actually been lowered first"
],
correct: 1,
rationale: "The write to the framebuffer's blank file always succeeded; the bug was purely about timing — nothing re-blanked during the VT-switch window mid-stop, not that blanking itself was broken."
},
{
q: "Why can't a clean DRY_RUN=1 run prove the console-blank or autorandr fixes actually work?",
opts: [
"DRY_RUN mode exists only in the unlock script, and was somehow never added to the enter script",
"DRY_RUN requires elevated system privileges that this particular test environment currently lacks",
"run(), blank_console(), and autorandr only log \"would do X\" under DRY_RUN, nothing real happens",
"DRY_RUN skips the alarm-floor arithmetic entirely, so no timestamp ever actually gets read"
],
correct: 2,
rationale: "DRY_RUN is genuinely log-only across both scripts, including the alarm-floor math (which it does exercise faithfully) — what it never exercises is any command with a real side effect, like stopping lightdm or calling autorandr."
},
{
q: "Why does maybe_start_morning_alarm() now need its own alarm_started_date marker, separate from the LOCKED/UNLOCKED state file?",
opts: [
"It now runs from both unlock branches, including the already-unlocked exit, so it needs its own dedup",
"The LOCKED/UNLOCKED state file gets silently deleted by the RGB-off step before the alarm routine ever runs",
"systemd timers configured on this host don't guarantee only one instance ever runs at a time",
"ALARM_DAYS_DOW somehow stopped reliably identifying which day of the week it currently is"
],
correct: 0,
rationale: "The console-restore idempotency check only protects against redoing the GUI restore; once the alarm function is reachable from multiple triggers per morning, it needs its own daily marker or it would re-fire the alarm unit on every later staggered trigger."
},
{
q: "Why wasn't the entry-time-plus-floor calculation enough on its own, without also scheduling a dynamic one-shot timer?",
opts: [
"date +%s returns unreliable timestamps right after a VT switch on this specific hardware",
"The fixed trigger list is a static ceiling a late shutdown hour can push the floor past",
"MIN_WAKE_AFTER_LOCKDOWN_HOURS can't be read correctly from the config file under systemd",
"The alarm_started_date marker would otherwise get written under entirely the wrong day"
],
correct: 1,
rationale: "The floor math was already correct; the gap was that nothing besides the fixed trigger list ever re-evaluated it, and that list has a hard ceiling (07:00) that a late shutdown hour can exceed."
},
{
q: "What one-time manual step does the DisplayPort recovery fix depend on?",
opts: [
"Running xrandr --output DP-0 --auto once, then rebooting so the layout is remembered",
"Setting AUTORANDR_PROFILE to the exact xrandr output name rather than a profile name",
"Disabling nvidia-drm modeset entirely so fbcon stops competing with X for the framebuffer",
"Saving the layout once via autorandr --save default while both monitors are connected"
],
correct: 3,
rationale: "autorandr --change matches the currently detected outputs against saved profiles; without a saved 'default' profile there's nothing for the unlock-time retry loop to reapply."
}
];
var root = document.getElementById("quizRoot");
var scoreEl = document.getElementById("score");
var score = 0;
QUESTIONS.forEach(function (item, qi) {
var card = document.createElement("div");
card.className = "quiz-q";
var h4 = document.createElement("h4");
h4.textContent = (qi + 1) + ". " + item.q;
card.appendChild(h4);
var answered = false;
item.opts.forEach(function (optText, oi) {
var btn = document.createElement("button");
btn.className = "opt";
btn.textContent = optText;
btn.addEventListener("click", function () {
if (answered) return;
answered = true;
var buttons = card.querySelectorAll(".opt");
buttons.forEach(function (b, bi) {
if (bi === item.correct) b.classList.add("correct");
else if (bi === oi) b.classList.add("wrong");
});
if (oi === item.correct) score++;
scoreEl.textContent = score;
var rat = document.createElement("div");
rat.className = "rationale show";
rat.textContent = (oi === item.correct ? "Correct. " : "Not quite. ") + item.rationale;
card.appendChild(rat);
});
card.appendChild(btn);
});
root.appendChild(card);
});
})();
</script>

View File

@ -30,8 +30,12 @@
<div class="wrap">
<div class="kicker">testsAndMisc</div>
<h1>Change explanations</h1>
<p class="lead">9 explanations · interactive walkthroughs of notable changes, newest first.</p>
<p class="lead">10 explanations · interactive walkthroughs of notable changes, newest first.</p>
<div class="grid">
<a class="card" href="2026-07-18-explanation-night-lockdown-wake-fixes.html">
<span class="date">2026-07-18</span>
<span class="title">Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits</span>
</a>
<a class="card" href="2026-07-18-explanation-claude-tty-fix-wrapper.html">
<span class="date">2026-07-18</span>
<span class="title">Surviving early keystrokes — the claude() tty-fix wrapper</span>