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>
111 lines
4.7 KiB
Nix
111 lines
4.7 KiB
Nix
# 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"
|
|
'';
|
|
}
|