diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-11 05:07:42 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-11 05:07:42 -0500 |
| commit | 7095d622ab6e295143d1306bdb5c8ecd85cf0745 (patch) | |
| tree | 006df0449c9deded341904e00d2ff315742f0f8d /claude-templates | |
| parent | 2ffb01c62b154bac73542da63825a8ab1a17a49c (diff) | |
| download | rulesets-7095d622ab6e295143d1306bdb5c8ecd85cf0745.tar.gz rulesets-7095d622ab6e295143d1306bdb5c8ecd85cf0745.zip | |
fix(scripts): keep screenshot --launch from crashing the compositor
An XWayland client launched by --launch could send a configure request while the script tore down the headless output. Hyprland's damage path then dereferenced the removed monitor and the compositor aborted (Hyprland 0.55.2, coredump analysis in docs/design/).
The fix has two layers. --launch now forces the Wayland backend (DISPLAY unset, GDK and Qt steered to wayland) so no XWayland surface exists to race. Teardown also polls until the launched clients actually unmap before removing the output.
X11-only apps fail to map under the default, and some emacs builds are X11-only. The new --x11 flag allows XWayland for them, protected by the unmap wait. The no-window error hints at the flag.
Diffstat (limited to 'claude-templates')
| -rwxr-xr-x | claude-templates/.ai/scripts/screenshot.py | 57 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_screenshot.py | 38 |
2 files changed, 88 insertions, 7 deletions
diff --git a/claude-templates/.ai/scripts/screenshot.py b/claude-templates/.ai/scripts/screenshot.py index bdff419..e7c3046 100755 --- a/claude-templates/.ai/scripts/screenshot.py +++ b/claude-templates/.ai/scripts/screenshot.py @@ -30,6 +30,13 @@ Requires grim and hyprctl. For window/region modes the window must be visible on the active workspace — grim reads rendered outputs, so a hidden window captures whatever is drawn at its region. The --launch mode sidesteps that by rendering on a headless output that is real to the compositor but not displayed. + +--launch forces the Wayland backend (DISPLAY unset, GDK/Qt steered to +wayland): an XWayland surface can race the headless-output teardown and +crash the compositor. X11-only apps (e.g. a GTK3/X11 emacs build) fail to +map under that default — pass --x11 to allow XWayland for them; teardown +then waits for the clients to unmap before removing the output, which +narrows the race but cannot provably eliminate it. """ import argparse @@ -88,6 +95,25 @@ def center_offset(ow, oh, w, h): return max(0, (ow - w) // 2), max(0, (oh - h) // 2) +def wayland_cmd(cmd): + """Wrap CMD so the launched app maps as a native Wayland surface. + + An XWayland client's configure request can race the headless-output + teardown — Hyprland's damage path then dereferences the removed monitor + and the compositor aborts (observed Hyprland 0.55.2, 2026-06-10). + Unsetting DISPLAY makes an XWayland surface impossible; the backend vars + steer GTK/Qt to Wayland explicitly. An X11-only app fails to map and the + script dies with its normal no-window error instead of crashing the + session. + """ + return f"env -u DISPLAY GDK_BACKEND=wayland QT_QPA_PLATFORM=wayland {cmd}" + + +def clients_on_workspace(clients, wsid): + """Return the CLIENTS whose workspace id is WSID.""" + return [c for c in clients if c.get("workspace", {}).get("id") == wsid] + + # ------------------------------- I/O wrappers -------------------------------- def require_tools(*tools): @@ -144,7 +170,7 @@ def find_window(regex): return matches[0] -def capture_launched(cmd, out, settle, timeout, layout, size): +def capture_launched(cmd, out, settle, timeout, layout, size, allow_x11=False): """Run CMD on a transient headless output, capture it, then tear down. Creates a virtual (headless) Hyprland output that the compositor renders but @@ -153,6 +179,7 @@ def capture_launched(cmd, out, settle, timeout, layout, size): LAYOUT: 'tiled'/'monocle' (window fills the output, sized by SIZE) or 'floating' (window floats at SIZE, centered). SIZE is (w, h) or None. + ALLOW_X11 skips the Wayland-backend forcing for X11-only apps. """ before_mons = {m["name"] for m in hypr("monitors")} hypr_run("output", "create", "headless") @@ -168,7 +195,8 @@ def capture_launched(cmd, out, settle, timeout, layout, size): time.sleep(0.2) before_wins = {c["address"] for c in hypr("clients")} - hypr_run("dispatch", "exec", f"[{launch_rule(wsid, layout)}] {cmd}") + run_cmd = cmd if allow_x11 else wayland_cmd(cmd) + hypr_run("dispatch", "exec", f"[{launch_rule(wsid, layout)}] {run_cmd}") deadline = time.time() + timeout new_wins = [] @@ -178,7 +206,9 @@ def capture_launched(cmd, out, settle, timeout, layout, size): break time.sleep(0.3) if not new_wins: - die(f"no window appeared within {timeout}s of launching: {cmd}") + hint = "" if allow_x11 else \ + " (the Wayland backend is forced by default — if the app is X11-only, retry with --x11)" + die(f"no window appeared within {timeout}s of launching: {cmd}{hint}") # Daemon-spawned frames (emacsclient) ignore the exec rule, so apply # placement and layout by address — robust for both spawn paths. @@ -199,9 +229,17 @@ def capture_launched(cmd, out, settle, timeout, layout, size): time.sleep(settle) grim(out, output_name=name) finally: - for c in hypr("clients"): - if c.get("workspace", {}).get("id") == wsid: - hypr_run("dispatch", "closewindow", f"address:{c['address']}") + for c in clients_on_workspace(hypr("clients"), wsid): + hypr_run("dispatch", "closewindow", f"address:{c['address']}") + # Wait for the clients to actually unmap before removing the output: + # a configure request still in flight against a just-removed monitor + # crashes the compositor. + unmap_deadline = time.time() + 5 + while time.time() < unmap_deadline: + if not clients_on_workspace(hypr("clients"), wsid): + break + time.sleep(0.2) + time.sleep(0.2) hypr_run("output", "remove", name) @@ -225,6 +263,10 @@ def main(): p.add_argument("--size", help="WxH: output res (tiled/monocle) or window size (floating)") p.add_argument("--timeout", type=float, default=10.0, help="seconds to wait for the --launch window (default 10)") + p.add_argument("--x11", action="store_true", + help="allow the --launch app to map via XWayland (X11-only " + "apps); default forces the Wayland backend to avoid a " + "compositor race on teardown") p.add_argument("--output", "-o", help="PNG output path") p.add_argument("--delay", type=float, default=0.0, help="seconds to wait before capture") args = p.parse_args() @@ -240,7 +282,8 @@ def main(): if args.launch: out = resolve_output(args.output) settle = args.delay if args.delay else 1.5 - capture_launched(args.launch, out, settle, args.timeout, args.layout, size) + capture_launched(args.launch, out, settle, args.timeout, args.layout, size, + allow_x11=args.x11) print(out) return diff --git a/claude-templates/.ai/scripts/tests/test_screenshot.py b/claude-templates/.ai/scripts/tests/test_screenshot.py index 357caec..2a01eb1 100644 --- a/claude-templates/.ai/scripts/tests/test_screenshot.py +++ b/claude-templates/.ai/scripts/tests/test_screenshot.py @@ -95,3 +95,41 @@ def test_center_offset_boundary_exact_fit(): def test_center_offset_error_window_larger_than_output_clamps(): assert screenshot.center_offset(800, 600, 1000, 700) == (0, 0) + + +# ------------------------------- wayland_cmd --------------------------------- +# --launch must not let the app map via XWayland: an XWayland configure request +# can race the headless-output teardown and crash the compositor. + +def test_wayland_cmd_unsets_display_and_forces_wayland(): + wrapped = screenshot.wayland_cmd("emacs -Q") + assert wrapped.startswith("env -u DISPLAY ") + assert "GDK_BACKEND=wayland" in wrapped + assert "QT_QPA_PLATFORM=wayland" in wrapped + assert wrapped.endswith(" emacs -Q") + +def test_wayland_cmd_boundary_preserves_quoted_args(): + wrapped = screenshot.wayland_cmd("foot -e sh -c 'echo hi'") + assert wrapped.endswith(" foot -e sh -c 'echo hi'") + + +# --------------------------- clients_on_workspace ---------------------------- + +WS_CLIENTS = [ + {"address": "0x1", "workspace": {"id": 3}}, + {"address": "0x2", "workspace": {"id": 5}}, + {"address": "0x3", "workspace": {"id": 3}}, +] + +def test_clients_on_workspace_normal(): + got = screenshot.clients_on_workspace(WS_CLIENTS, 3) + assert [c["address"] for c in got] == ["0x1", "0x3"] + +def test_clients_on_workspace_boundary_empty_list(): + assert screenshot.clients_on_workspace([], 3) == [] + +def test_clients_on_workspace_boundary_missing_workspace_key(): + assert screenshot.clients_on_workspace([{"address": "0x9"}], 3) == [] + +def test_clients_on_workspace_error_no_match(): + assert screenshot.clients_on_workspace(WS_CLIENTS, 99) == [] |
