From c8402020184516b77e60b4c072bb5a5136d8e70a Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sun, 12 Jul 2026 18:09:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(display):=20blackout=20rescue=20=E2=80=94?= =?UTF-8?q?=20re-enable=20a=20disabled=20panel=20on=20undock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agent/HARDWARE-QUEUE.md | 11 ++++ agent/JOURNAL.md | 20 +++++++ docs/ROADMAP.md | 19 +++++++ docs/TESTING.md | 5 ++ modules/home/hyprland.nix | 45 ++++++++++++++- tools/monitor-fallback.nix | 110 +++++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 tools/monitor-fallback.nix diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 665629f..9e7cccb 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -48,6 +48,17 @@ the **T14s** (webcam case). shows "Send output → " when one exists and isn't default; picking it moves output + toasts. Pass = rows appear/act 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 `nomarchy.hardware.fingerprint.enable` and a physical reader: System › Fingerprint › Enroll a finger; List shows it; Verify succeeds; optional diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 3dce68e..2cdffd7 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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) - **Task:** Bernardo's dock test (= #87's V3): audio stayed on speakers, had to hand-tweak EasyEffects; wants docked Display/Audio menu options. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8cfa947..85e0f18 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 "Send output → " quick row (the watcher's manual override). 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 ,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 `rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft selection wash — same hex-alpha glass pattern as Boreal); btop title/ diff --git a/docs/TESTING.md b/docs/TESTING.md index ea5a6f1..106f297 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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, 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 - **No KVM** (e.g. nested without acceleration): everything works but diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 216276d..fa0d15c 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -215,7 +215,43 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList case "$v" in true|True) return 0 ;; *) return 1 ;; esac } 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() { cur=$(outputs) [ "$cur" = "$1" ] && return @@ -239,7 +275,12 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList # 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 case "$line" in - monitoradded*|monitorremoved*) + monitorremoved*) + rescue_blackout + res=$(check_monitors "$prev") + [ -n "$res" ] && prev="$res" + ;; + monitoradded*) res=$(check_monitors "$prev") [ -n "$res" ] && prev="$res" ;; diff --git a/tools/monitor-fallback.nix b/tools/monitor-fallback.nix new file mode 100644 index 0000000..d799a8b --- /dev/null +++ b/tools/monitor-fallback.nix @@ -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" + ''; +}