fix(docking): make workspace and lid transitions atomic
All checks were successful
Check / eval (push) Successful in 3m19s

Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary.

Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test.

V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
This commit is contained in:
2026-07-13 13:56:54 +01:00
parent cffe432912
commit 2a34c7398b
12 changed files with 654 additions and 292 deletions

View File

@@ -1,21 +1,16 @@
# 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:
# 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 ../.);
@@ -27,10 +22,10 @@ let
};
in
pkgs.testers.runNixOSTest {
name = "monitor-fallback";
name = "dock-transition";
node.pkgsReadOnly = false;
node.specialArgs = { username = "nomarchy"; };
nodes.machine = { pkgs, lib, ... }: {
nodes.machine = { pkgs, ... }: {
imports = [ flake.nixosModules.nomarchy home-manager.nixosModules.home-manager ];
virtualisation.memorySize = 4096;
virtualisation.cores = 4;
@@ -47,6 +42,7 @@ pkgs.testers.runNixOSTest {
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";
@@ -57,59 +53,106 @@ pkgs.testers.runNixOSTest {
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, time
import json
machine.wait_for_unit("multi-user.target")
machine.wait_for_file("/run/user/1000/hypr", 180)
machine.sleep(20) # softGL first paint settle
machine.sleep(15)
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 ")
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=False):
out = machine.succeed(hy + ("-j monitors all'" if all else "-j monitors'"))
return json.loads(out)
def mons(all_outputs=False):
suffix = "-j monitors all'" if all_outputs else "-j monitors'"
return json.loads(machine.succeed(hy + suffix))
initial = mons()
assert len(initial) == 1, f"expected 1 initial monitor, got {initial}"
internal = initial[0]["name"]
print(f"internal panel: {internal}")
def names(all_outputs=False):
return [m["name"] for m in mons(all_outputs)]
# "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}")
initial = names()[0]
print(f"bootstrap output: {initial}")
# 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")
assert initial == "Virtual-1", f"test override expects Virtual-1, got {initial}"
# 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"
# 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.
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")
'';
}