# Dock-transition hotplug harness — full softGL Hyprland VM with real KVM # acceleration when available. It uses QEMU's Virtual-1 DRM output as the # laptop panel and creates a named DP-1 headless output as the dock monitor, # exercises the generated transition helper and the live IPC watcher, and # proves the safety sequence that regressed on hardware: # # dock: all internal workspaces -> external, focus external, eDP disabled, # low-level logind lid-switch inhibitor held # undock: eDP enabled before workspace restoration; inhibitor remains while # the simulated lid is closed, then releases when it opens # # Every wait is bounded. Maintainer V2 harness (full desktop, so intentionally # not part of the cheap default checks): # 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 = "dock-transition"; node.pkgsReadOnly = false; node.specialArgs = { username = "nomarchy"; }; nodes.machine = { pkgs, ... }: { 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/state.json 0644 nomarchy users - ${flake + "/themes/boreal.json"}" "f /run/nomarchy-test-lid-state 0644 nomarchy users - state:_closed" ]; 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; # Audio has its own generated-artifact guard; avoid restarting the VM's # graph during display-only hotplug assertions. nomarchy.dockAudio.enable = false; home.packages = [ pkgs.foot ]; # QEMU's DRM output is Virtual-1 rather than eDP-*; this test-only # override exercises the same production lifecycle with that connector. wayland.windowManager.hyprland.settings.env = [ "NOMARCHY_LID_STATE_FILE,/run/nomarchy-test-lid-state" "NOMARCHY_INTERNAL_OUTPUT,Virtual-1" ]; }; }; testScript = '' import json machine.wait_for_unit("multi-user.target") machine.wait_for_file("/run/user/1000/hypr", 180) machine.sleep(15) machine.wait_until_succeeds( "pgrep -af nomarchy-display-profile-watch >/dev/null", timeout=30 ) env = ("XDG_RUNTIME_DIR=/run/user/1000 " "HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) " "WAYLAND_DISPLAY=wayland-1 ") hy = f"su - nomarchy -c '{env}hyprctl " user = lambda command: machine.succeed(f"su - nomarchy -c '{env}{command}'") def mons(all_outputs=False): suffix = "-j monitors all'" if all_outputs else "-j monitors'" return json.loads(machine.succeed(hy + suffix)) def names(all_outputs=False): return [m["name"] for m in mons(all_outputs)] initial = names()[0] print(f"bootstrap output: {initial}") assert initial == "Virtual-1", f"test override expects Virtual-1, got {initial}" # Two real mapped clients keep workspaces 1 and 2 alive for handoff. machine.succeed(hy + "dispatch workspace 1'") user("foot --app-id dock-test-1 >/dev/null 2>&1 &") machine.wait_until_succeeds(hy + "-j clients' | jq -e 'length >= 1'", timeout=30) machine.succeed(hy + "dispatch workspace 2'") user("foot --app-id dock-test-2 >/dev/null 2>&1 &") machine.wait_until_succeeds(hy + "-j clients' | jq -e 'length >= 2'", timeout=30) machine.succeed(hy + "output create headless DP-1'") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'any(.[]; .name == \"DP-1\")'", timeout=20 ) machine.wait_until_succeeds( "systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'", timeout=20, ) print("external added: low-level lid inhibitor held") transition = "$(command -v nomarchy-display-transition)" user(f"{transition} dock Virtual-1 DP-1") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'length == 1 and .[0].name == \"DP-1\"'", timeout=20 ) ws = json.loads(machine.succeed(hy + "-j workspaces'")) assert {w["id"] for w in ws} >= {1, 2}, ws assert all(w["monitor"] == "DP-1" for w in ws if w["id"] in (1, 2)), ws all_state = {m["name"]: m.get("disabled", False) for m in mons(True)} assert all_state.get("Virtual-1") is True, all_state assert mons()[0].get("focused") is True, mons() print("dock transition: both workspaces on DP-1, internal disabled") # #148: every home-manager activation fires `hyprctl reload`, which # re-reads the generated config's catch-all `monitor = ,preferred,auto,1` # and would re-light Virtual-1 even though the session is still docked # with the (simulated) lid shut. `enforce_dock_intent` — the tick-driven # invariant in the watcher — is what is supposed to put it back off. # Before the #148 fix it also required `settings.displayProfileAuto` # (the *named-profile* auto-switch toggle, unrelated to dock intent), and # this harness never sets that — it stays at its schema default `false` # — which is exactly the condition the bug shipped under: the invariant # silently never fired for any session that had not separately opted # into auto profiles. Re-assert the lid is shut (it already is, from the # tmpfiles seed, but do so explicitly since this scenario depends on it), # reload, and prove the watcher re-disables the panel and logs the # re-dock action. machine.succeed("printf 'state: closed\\n' > /run/nomarchy-test-lid-state") machine.succeed(hy + "reload'") # A reload re-lights the *already-present* (disabled) Virtual-1 connector, # which emits no `monitoradded` — so the watcher only re-disables it on its # 1s idle tick (chatty IPC events `continue` past reconcile). Under this # softGL VM's load (swaync crash-loops on the absent Vulkan device, see the # journal) that idle tick is badly starved and the re-dock lands ~15-20s # out — a real, prompt re-dock on hardware, but right on the old 15s bound, # so this flaked. Bounded generously, in line with the 20-30s waits above. machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'all(.[]; .name != \"Virtual-1\")'", timeout=45 ) machine.succeed("journalctl -t nomarchy-display-watch | grep -q 'action=re-dock'") print("reload-mid-dock: panel re-disabled by enforce_dock_intent; re-dock logged (#148)") # QEMU's synthetic headless backend aborts Hyprland if its last active # output is deleted while the DRM output is disabled. Exercise the exact # production recovery primitive first, then remove DP-1: the real # monitorremoved event still proves that the watcher retains the inhibitor # until lid-open, without mistaking a backend limitation for product # behavior. Physical cable-yank timing remains the bounded V3 check. # # Undocking a quiescent output is therefore all this harness can assert — # and that gap is exactly what shipped a black panel in rounds 6 and 7. # The real fault needs ZERO enabled outputs: with the panel disabled and # the external gone, Hyprland 0.55.4 accepts `keyword monitor` and never # flushes it, so the panel stays dark until some DRM event arrives (only # `hyprctl reload` escapes it — probed on hardware 2026-07-14). Note the # QEMU abort above is that same degeneracy seen from the other side, so # this harness cannot reach the state without dying in it: the undock here # always runs while DP-1 is still enabled, i.e. the one case that never # needed the fix. A pass below is NOT proof of the reload escalation. user(f"{transition} undock Virtual-1") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20 ) machine.wait_until_succeeds( hy + "-j workspaces' | jq -e 'all(.[]; (.id != 1 and .id != 2) or .monitor == \"Virtual-1\")'", timeout=20, ) machine.succeed(hy + "output remove DP-1'") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'length == 1 and .[0].name == \"Virtual-1\"'", timeout=20 ) machine.succeed( "systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'" ) print("undock transition: eDP/workspaces restored; closed-lid inhibitor retained") machine.succeed("printf 'state: open\\n' > /run/nomarchy-test-lid-state") machine.wait_until_fails( "systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'", timeout=20, ) machine.fail("test -e /run/user/1000/nomarchy-dock-lid-inhibitor.pid") print("lid-open transition: inhibitor released; normal future lid policy restored") ''; }