# Undock blackout-rescue harness — boots the full desktop headlessly # (softGL Hyprland, same recipe as theme-shot.nix) and replays the # dangerous profile edge: add a headless "external", soft-disable the # internal panel (a named profile can still do this), then remove the # external. The interactive Display menu no longer disables the panel: its # safe Dock mode moves workspaces/focus but keeps eDP as the unplug fallback. # Hyprland does NOT re-enable a profile-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 machine.wait_until_succeeds( "pgrep -af nomarchy-display-profile-watch >/dev/null", timeout=30 ) 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}") # Named display-profile edge: eDP is disabled before an abrupt undock. 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" ''; }