feat(display): blackout rescue — re-enable a disabled panel on undock
Some checks failed
Check / eval (push) Failing after 1m40s
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:
@@ -48,6 +48,17 @@ the **T14s** (webcam case).
|
||||
shows "Send output → <dock sink>" 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 → <dock sink>" 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 <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
|
||||
`rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft
|
||||
selection wash — same hex-alpha glass pattern as Boreal); btop title/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -216,6 +216,42 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
}
|
||||
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"
|
||||
;;
|
||||
|
||||
110
tools/monitor-fallback.nix
Normal file
110
tools/monitor-fallback.nix
Normal 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"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user