feat(display): blackout rescue — re-enable a disabled panel on undock
Some checks failed
Check / eval (push) Failing after 1m40s

Bernardo's question: with "Laptop screen off" active, does the panel
come back when the dock is yanked? VM answer (new maintainer harness
tools/monitor-fallback.nix, softGL desktop): no — Hyprland 0.55.4
leaves the session with zero active outputs and never re-enables a
soft-disabled monitor. rescue_blackout in the display hotplug watcher
now re-enables every disabled output (immediate, retried, idempotent
keyword + toast) whenever a monitorremoved leaves nothing active;
independent of the profile auto-switch and its settings gate.

Sharp edge documented rather than hidden: the zero-output state itself
crashes Hyprland in the VM ~4/5 runs (aquamarine CBackend::dispatchIdle
ABRT within ~0.5s — faster than any userland rescue; a control run
removing the external with the panel ACTIVE survives 5/5, so the state
is the trigger, not headless removal; crash reproduced with the rescue
provably inert, so not caused by it). Rescue ships as defense-in-depth:
it recovers every surviving case, and the worst case on real DRM is
session-to-greeter on a re-lit panel instead of a black brick. Rider
fix: `grep -c .` exits 1 at count zero — the ||-guard on that pipeline
silently disabled the rescue exactly when it mattered.

Verified: hazard V2 (VM reproduces the dead state); control
discriminator 5/5; flake check green. V3 pending: real-dock
undock-while-off in HARDWARE-QUEUE (run with nothing important open;
crash on real DRM = rework the row to mirror or wait for a Hyprland
bump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:09:17 +01:00
parent 6439105d38
commit c840202018
6 changed files with 208 additions and 2 deletions

View File

@@ -48,6 +48,17 @@ the **T14s** (webcam case).
shows "Send output → <dock sink>" when one exists and isn't shows "Send output → <dock sink>" when one exists and isn't
default; picking it moves output + toasts. Pass = rows appear/act default; picking it moves output + toasts. Pass = rows appear/act
as described, and none of them show on a single-display session. as described, and none of them show on a single-display session.
Also — **run this one with nothing important open**: with the
laptop screen off via the menu, physically unplug the dock. The
panel should re-enable within ~2s (`rescue_blackout` in the
display watcher). Known risk from the VM harness
(tools/monitor-fallback.nix): on Hyprland 0.55.4 the zero-output
state itself can crash the compositor faster than any rescue
(softGL VM: 4/5 runs; control with the panel active survives
always) — a crash on real DRM ends the session to the greeter on
the re-lit panel. If that happens on hardware, the "Laptop screen
off" row needs a rework (mirror instead of disable, or drop until
a Hyprland bump) — file it as a NOW bug with the coredump.
- [ ] **#55 fingerprint enroll on real reader** — with - [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System `nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional Fingerprint Enroll a finger; List shows it; Verify succeeds; optional

View File

@@ -19,6 +19,26 @@ Template:
--- ---
## 2026-07-12 — undock blackout rescue (interactive follow-up)
- **Task:** Bernardo: "if I turn the laptop screen off and undock, does
the panel come back?" Answer had to be tested, not recalled.
- **Did:** Built tools/monitor-fallback.nix (softGL desktop VM, crib of
theme-shot): headless "external" + menu's exact disable + output
remove. Verdict: Hyprland 0.55.4 does NOT re-enable → shipped
`rescue_blackout` in the display hotplug watcher (immediate + retried
keyword re-enable of disabled outputs on zero-active, toast).
- **Verified:** V2 for the hazard (VM reproduced the dead state);
rescue itself is V1+components — the VM crashes before rescue can act
in 4/5 runs (aquamarine ABRT in dispatchIdle on the zero-output
state; control with panel active survives 5/5 — discriminator run).
Crash predates rescue (run with rescue provably inert also crashed).
Also fixed: `grep -c .` exit-1-at-zero disabling the rescue's own
trigger. flake check green.
- **Pending:** real-dock V3 (HARDWARE-QUEUE, "nothing important open"
warning); if real DRM crashes too → rework row (mirror) or wait for
Hyprland bump; consider reporting upstream with the coredump.
- **Next suggestion:** dock V3 results, then #89 slice 3.
## 2026-07-12 — dock audio auto-follow + docked menu rows (interactive) ## 2026-07-12 — dock audio auto-follow + docked menu rows (interactive)
- **Task:** Bernardo's dock test (= #87's V3): audio stayed on speakers, - **Task:** Bernardo's dock test (= #87's V3): audio stayed on speakers,
had to hand-tweak EasyEffects; wants docked Display/Audio menu options. had to hand-tweak EasyEffects; wants docked Display/Audio menu options.

View File

@@ -424,6 +424,25 @@ Design/decision records and a running log of shipped work (items marked
(`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated (`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row (the watcher's manual override). "Send output → <dock sink>" quick row (the watcher's manual override).
Real-dock V3 for both in HARDWARE-QUEUE. Real-dock V3 for both in HARDWARE-QUEUE.
✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the
panel comes back if you undock while "Laptop screen off" — VM answer
(tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland
0.55.4 leaves zero active outputs and never re-enables a soft-disabled
monitor. Fix: `rescue_blackout` in the display hotplug watcher
(hyprland.nix) — on monitorremoved with zero active outputs,
immediately (and with retries) `keyword monitor <name>,preferred,auto,
auto` every disabled output + toast; independent of the profile
auto-switch gate (safety invariant, not a preference). Sharp edge
found while testing: the zero-output state itself crashes Hyprland in
the VM ~4/5 runs (ABRT in aquamarine CBackend::dispatchIdle, ~0.5s —
faster than any userland rescue; control run with the panel active
survives 5/5, so it's the state, not headless removal; monitor-
disconnect crashes are a known upstream class). Posture: rescue ships
as defense-in-depth (recovers every surviving case; worst case on real
DRM is session-to-greeter on the re-lit panel, not a black brick);
V3 decides if the menu row needs a mirror-based rework. Also fixed in
the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
pipeline silently disabled the rescue exactly when it mattered.
✓ **neon-glass finish (#88, `VISION § D`):** rewrote whole-swap ✓ **neon-glass finish (#88, `VISION § D`):** rewrote whole-swap
`rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft `rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft
selection wash — same hex-alpha glass pattern as Boreal); btop title/ selection wash — same hex-alpha glass pattern as Boreal); btop title/

View File

@@ -105,6 +105,11 @@ unattended env it uses is in the script, and the same flow works
interactively from the live terminal. If the desktop comes up unthemed, interactively from the live terminal. If the desktop comes up unthemed,
read `/var/log/nomarchy-hm-preactivate.log` on the installed system. read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
Two full-desktop VM harnesses (softGL Hyprland, too heavy for `checks.*`)
live next to the scripts: `tools/theme-shot.nix` (themed-desktop
screenshots) and `tools/monitor-fallback.nix` (undock blackout rescue —
disabled panel must re-enable when the last active output disappears).
## 5. VM-specific gotchas ## 5. VM-specific gotchas
- **No KVM** (e.g. nested without acceleration): everything works but - **No KVM** (e.g. nested without acceleration): everything works but

View File

@@ -215,7 +215,43 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
case "$v" in true|True) return 0 ;; *) return 1 ;; esac case "$v" in true|True) return 0 ;; *) return 1 ;; esac
} }
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
# Blackout rescue the session must never end up with ZERO active
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
# the last active one disconnects (VM-verified, tools/
# monitor-fallback.nix): menu "Laptop screen off" + sudden undock
# left a dead session. On every monitorremoved, if nothing is
# active, re-enable whatever is disabled (preferred/auto the
# declared rule returns at the next reload/relogin). Independent of
# the profile auto-switch and its settings gate: this is a safety
# invariant, not a layout preference.
active_count() {
# NB: `grep -c .` exits 1 when the count is 0 the case this whole
# rescue exists for so never `||`-guard on the pipeline status.
hyprctl monitors -j 2>/dev/null | jq -r '.[].name' | grep -c . || true
}
rescue_blackout() {
# Act IMMEDIATELY and retry: the zero-output state is not just a
# black screen, it can kill the compositor outright in the softGL
# VM harness aquamarine ABRT'd (CBackend::dispatchIdle) within
# ~0.5s of the last output going while it persisted, so shrinking
# the window is the priority; the keyword is idempotent and the
# retries cover a first attempt racing the removal teardown.
tries=0
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \
| while read -r m; do
[ -n "$m" ] || continue
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1
done
tries=$((tries+1))
sleep 1
done
if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then
notify-send "Display" "Screen re-enabled the active output disappeared." 2>/dev/null || true
fi
}
check_monitors() { check_monitors() {
cur=$(outputs) cur=$(outputs)
[ "$cur" = "$1" ] && return [ "$cur" = "$1" ] && return
@@ -239,7 +275,12 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
# Listen to Hyprland's IPC socket for instant hotplug reaction # Listen to Hyprland's IPC socket for instant hotplug reaction
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do ${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
case "$line" in case "$line" in
monitoradded*|monitorremoved*) monitorremoved*)
rescue_blackout
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
monitoradded*)
res=$(check_monitors "$prev") res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res" [ -n "$res" ] && prev="$res"
;; ;;

110
tools/monitor-fallback.nix Normal file
View File

@@ -0,0 +1,110 @@
# Undock blackout-rescue harness — boots the full desktop headlessly
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the
# dangerous sequence: add a headless "external", soft-disable the
# internal panel (the Display menu's "Laptop screen off" command), then
# remove the external. Hyprland does NOT re-enable a disabled monitor on
# its own (verified 2026-07-12 — the session ends with zero active
# outputs), so the display-profile watcher's rescue_blackout
# (modules/home/hyprland.nix) must bring the panel back; this asserts it
# does. KNOWN FLAKE (2026-07-12, Hyprland 0.55.4): the zero-active-output
# state itself crashes Hyprland in this VM ~4/5 runs (ABRT in aquamarine
# CBackend::dispatchIdle, faster than any userland rescue) — a control
# run removing the external with the internal ACTIVE survives every
# time, so it's the zero-output state, not headless removal. A crashed
# run is that upstream bug, not a rescue regression; the assertion can
# only pass on a surviving run. Maintainer tool, NOT a checks.* gate
# (full-desktop VM — same weight call as theme-shot.nix). Run:
# nix build --impure -f tools/monitor-fallback.nix --no-link -L
let
flake = builtins.getFlake ("git+file://" + toString ../.);
inherit (flake.inputs) nixpkgs home-manager;
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ flake.overlays.default ];
config.allowUnfree = true;
};
in
pkgs.testers.runNixOSTest {
name = "monitor-fallback";
node.pkgsReadOnly = false;
node.specialArgs = { username = "nomarchy"; };
nodes.machine = { pkgs, lib, ... }: {
imports = [ flake.nixosModules.nomarchy home-manager.nixosModules.home-manager ];
virtualisation.memorySize = 4096;
virtualisation.cores = 4;
hardware.graphics.enable = true;
environment.variables.LIBGL_ALWAYS_SOFTWARE = "1";
boot.initrd.availableKernelModules = [ "virtio_gpu" ];
virtualisation.qemu.options = [ "-vga none" "-device virtio-gpu-pci,xres=1920,yres=1080" ];
users.users.nomarchy = {
isNormalUser = true;
password = "test";
extraGroups = [ "wheel" "video" ];
};
nomarchy.system.stateFile = flake + "/themes/boreal.json";
systemd.tmpfiles.rules = [
"d /home/nomarchy/.nomarchy 0755 nomarchy users -"
"C /home/nomarchy/.nomarchy/theme-state.json 0644 nomarchy users - ${flake + "/themes/boreal.json"}"
];
services.greetd.settings.initial_session = {
command = "start-hyprland";
user = "nomarchy";
};
home-manager.useGlobalPkgs = true;
home-manager.users.nomarchy = {
imports = [ flake.homeModules.nomarchy ];
nomarchy.stateFile = flake + "/themes/boreal.json";
nomarchy.idle.enable = false;
};
};
testScript = ''
import json, time
machine.wait_for_unit("multi-user.target")
machine.wait_for_file("/run/user/1000/hypr", 180)
machine.sleep(20) # softGL first paint settle
hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ")
def mons(all=False):
out = machine.succeed(hy + ("-j monitors all'" if all else "-j monitors'"))
return json.loads(out)
initial = mons()
assert len(initial) == 1, f"expected 1 initial monitor, got {initial}"
internal = initial[0]["name"]
print(f"internal panel: {internal}")
# "Plug the dock": add a headless output as the external monitor.
machine.succeed(hy + "output create headless'")
for _ in range(20):
if len(mons()) == 2: break
time.sleep(1)
names = [x["name"] for x in mons()]
ext = [n for n in names if n != internal][0]
print(f"external (headless): {ext}")
# Menu action: "Laptop screen off" the exact command the row runs.
machine.succeed(hy + f"keyword monitor {internal},disable'")
for _ in range(20):
if [x["name"] for x in mons()] == [ext]: break
time.sleep(1)
assert [x["name"] for x in mons()] == [ext], f"disable failed: {mons()}"
alln = {x["name"]: x.get("disabled", False) for x in mons(all=True)}
assert alln.get(internal) is True, f"{internal} not marked disabled: {alln}"
print("laptop panel disabled, only external active now yank it")
# Sudden undock: remove the external while the internal is disabled.
machine.succeed(hy + f"output remove {ext}'")
recovered = False
for i in range(30):
if [x["name"] for x in mons()] == [internal]:
recovered = True
print(f"recovered after {i+1}s: {internal} re-enabled")
break
time.sleep(1)
print(f"monitors now: {mons(all=True)}")
assert recovered, "internal panel did NOT come back after undock"
'';
}