aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-x.ai/scripts/screenshot.py57
-rw-r--r--.ai/scripts/tests/test_screenshot.py38
-rwxr-xr-xclaude-templates/.ai/scripts/screenshot.py57
-rw-r--r--claude-templates/.ai/scripts/tests/test_screenshot.py38
-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