Files
Nomarchy/tools/monitor-fallback.nix
Bernardo Magri 7ebfab4bd4
All checks were successful
Check / eval (push) Successful in 3m32s
fix(docking): make clamshell hotplug safe and discoverable
Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs.

Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions.

Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md.
2026-07-13 11:53:01 +01:00

116 lines
4.9 KiB
Nix

# 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"
'';
}