All checks were successful
Check / eval (push) Successful in 4m16s
tools/monitor-fallback.nix flaked at the #148 reload-mid-dock step: after a lid-closed `hyprctl reload` re-lights the disabled Virtual-1 panel, the watcher's enforce_dock_intent re-disables it — but only on the loop's 1s idle tick, because re-enabling an existing connector emits no `monitoradded` and the chatty IPC events `continue` past reconcile. Under this softGL VM's load (swaync crash-loops against the absent Vulkan device) that idle tick is starved, so the re-dock lands ~15-20s out — right on the old timeout=15. An instrumented run confirmed the mechanism is correct: dockMode=true is read, `action=re-dock` fires, the panel is re-disabled (it just missed the bound). Fix is the bound, not the watcher: 15s → 45s, in line with the test's own 20-30s waits. Harness now green end-to-end (~61s). Not a product bug and independent of the #150 extraction (the built transition/keyboard tools have byte-identical store paths across it). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
9.8 KiB
Nix
199 lines
9.8 KiB
Nix
# 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")
|
|
'';
|
|
}
|