All checks were successful
Check / eval (push) Successful in 3m10s
Round 7 FAILED: 5-6 consecutive unplugs on the dev box, the panel never came back. Round 6's retry could not have worked, because round 6's diagnosis was wrong — and so was the "phantom re-add" theory I brought to this session. With ZERO enabled outputs — panel disabled by the dock, external gone — Hyprland 0.55.4 accepts `hyprctl keyword monitor eDP-1,preferred,auto,1`, prints `ok`, exits 0, and never flushes it: the rule waits for a DRM event that is not coming. It is inert, not raced. Retrying it 25x/poll for 6 polls bought 30s of black screen and nothing else. What made this survive two rounds is worth more than the fix: every `transition=undock result=ok` in the round-6 journal was Bernardo plugging the cable back in because the screen was black. His hotplug flushed the queued rule and the next poll took the credit. Success was indistinguishable from the user working around the failure, so the logs confirmed whichever story we brought to them. Ten minutes of probing on hardware killed both. Probed live 2026-07-14 (dev box, cable out): keyword inert across 4s and 5s in two runs; `dispatch forcerendererreload` inert too; only `hyprctl reload` escapes — 99ms and 289ms. So: issue the keyword (enough whenever another output is still enabled, e.g. the menu's Dock mode), and escalate to reload only when `monitors` proves it inert. After a reload, re-assert the rule so a config that parks the panel off cannot undo the undock, and restore per-device keyboard layouts — a reload re-reads the config and drops the runtime `device[<name>]:kb_layout` an external board depends on. The menu's `enable` now proves itself against `monitors` too, instead of cheering for an exit code. Verified V3 (partial): the fixed transition driven through a real unplug — panel on, workspaces 1-3 home, 1.8s, `keyword=inert escalate=reload` -> `enable=via-reload` -> `result=ok`. V2: nix flake check --no-build; docking-ux + dock-audio; monitor-fallback.nix; shellcheck clean on display-transition. NOT proven, and queued as HARDWARE-QUEUE round 8: the watcher-driven path (exec-once + baked store path = relogin required, the round-5 trap) and repetition. The keyboard-restore path is untested — the dev box's keyboard hangs off the dock's hub, so it leaves with the cable; round 8 adds a check with a keyboard in the laptop. The VM harness still cannot reach any of this: QEMU aborts Hyprland if the last active output is deleted while the DRM output is disabled, which is the same zero-output degeneracy from the other side. New BACKLOG #114 (PROPOSED): tuigreet ignores per-device keyboard layouts — a VT has one keymap, so the greeter cannot honour `device[]:kb_layout`. Bernardo hit it logging out while docked; not a regression, a design gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
7.9 KiB
Nix
170 lines
7.9 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/theme-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")
|
|
|
|
# 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")
|
|
'';
|
|
}
|