diff options
| -rwxr-xr-x | .ai/scripts/screenshot.py | 57 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_screenshot.py | 38 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/screenshot.py | 57 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_screenshot.py | 38 | ||||
| -rw-r--r-- | docs/design/2026-06-10-screenshot-launch-crash-analysis.org (renamed from inbox/2026-06-11-0045-from-archsetup-screenshot-py-launch-crash-on-ratio.org) | 0 |
5 files changed, 176 insertions, 14 deletions
diff --git a/.ai/scripts/screenshot.py b/.ai/scripts/screenshot.py index bdff419..e7c3046 100755 --- a/.ai/scripts/screenshot.py +++ b/.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/.ai/scripts/tests/test_screenshot.py b/.ai/scripts/tests/test_screenshot.py index 357caec..2a01eb1 100644 --- a/.ai/scripts/tests/test_screenshot.py +++ b/.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) == [] 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) == [] diff --git a/inbox/2026-06-11-0045-from-archsetup-screenshot-py-launch-crash-on-ratio.org b/docs/design/2026-06-10-screenshot-launch-crash-analysis.org index dfad9f4..dfad9f4 100644 --- a/inbox/2026-06-11-0045-from-archsetup-screenshot-py-launch-crash-on-ratio.org +++ b/docs/design/2026-06-10-screenshot-launch-crash-analysis.org |
