From 5e23c8b0c0c0be4ce71e861dcf3b308c309cffc9 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 17:47:27 -0400 Subject: [PATCH] Add a headless runner for iris examples This VM has no display but does have a real GPU -- Vulkan 1.4 through Venus and GL 4.6 through virgl, onto the host's card -- so the only thing missing for a winit window is a compositor. Same trick `emu` uses for the Android emulator: a headless sway, with grim for the picture. It starts its own compositor rather than joining `emu`'s. sway tiles, so adding a window to the one an emulator sits in resizes that emulator, and a peer session's `emu up` could join at any moment. Xwayland is off here because winit speaks Wayland; `emu` forces it on only because the Android emulator's renderer speaks GLX. It waits for the window to be mapped rather than sleeping a fixed time: the first version's fixed sleep captured an all-black screen when sway had started in the same invocation, which is indistinguishable from an app that draws nothing. Co-Authored-By: Claude Opus 5 --- iris/headless.conf | 14 ++++++ iris/run-headless.sh | 108 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 iris/headless.conf create mode 100755 iris/run-headless.sh diff --git a/iris/headless.conf b/iris/headless.conf new file mode 100644 index 0000000..1f755ca --- /dev/null +++ b/iris/headless.conf @@ -0,0 +1,14 @@ +# The compositor `run-headless.sh` starts, because this machine has no +# display. Nothing here is meant to be looked at directly; `grim` is. +# +# No Xwayland: winit talks Wayland natively, and starting an X server is a +# second thing to go wrong for no gain. (`emu`'s config forces it because the +# Android emulator's renderer speaks GLX.) +xwayland disable + +# A desktop-shaped output, since this is the desktop half of the port. Larger +# than the window an example opens, so nothing is scaled or clipped. +output HEADLESS-1 mode 1920x1200@60Hz + +default_border none +focus_follows_mouse no diff --git a/iris/run-headless.sh b/iris/run-headless.sh new file mode 100755 index 0000000..e253b88 --- /dev/null +++ b/iris/run-headless.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# Run an iris example on this machine, which has no display. +# +# ./run-headless.sh tabs [-- cargo args] +# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 +# +# The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6 +# through virgl), so wgpu runs on the host's real GPU -- what is missing is +# only a compositor to give winit a surface. So: a headless sway, the same +# trick `emu` uses for the Android emulator, and `grim` to see the result. +# +# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window +# to the one an emulator is sitting in resizes that emulator's window, and a +# peer session's `emu up` could join at any moment. This one has its own +# socket and its own runtime directory and goes away with the machine. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" +seconds=3 +shot="" +example="" + +while [ $# -gt 0 ]; do + case "$1" in + --shot) shot=$2; shift 2 ;; + --seconds) seconds=$2; shift 2 ;; + --) shift; break ;; + *) example=$1; shift ;; + esac +done +[ -n "$example" ] || { echo "usage: $0 EXAMPLE [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } + +mkdir -p "$run" +export SWAYSOCK="$run/sway.sock" + +# Named rather than left to sway's pid-based default, so a second run reuses +# this compositor instead of starting another beside it. +if ! swaymsg -t get_version >/dev/null 2>&1; then + rm -f "$SWAYSOCK" + WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \ + setsid sway -c "$here/headless.conf" >"$run/sway.log" 2>&1 & + i=0 + while [ $i -lt 20 ]; do + swaymsg -t get_version >/dev/null 2>&1 && break + i=$((i + 1)); sleep 0.5 + done + swaymsg -t get_version >/dev/null 2>&1 || { + echo "run-headless: compositor did not start; see $run/sway.log" >&2 + exit 1 + } +fi + +# Asked of the compositor rather than guessed: sway takes the first free +# wayland-N, and this machine may already have one. +rm -f "$run/display" +swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null +i=0 +while [ $i -lt 20 ]; do + [ -s "$run/display" ] && break + i=$((i + 1)); sleep 0.5 +done +[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; } +WAYLAND_DISPLAY=$(cat "$run/display") +export WAYLAND_DISPLAY + +echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 + +cd "$here" +cargo build --example "$example" "$@" >&2 +bin="$here/target/debug/examples/$example" + +"$bin" >"$run/$example.log" 2>&1 & +pid=$! +trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM + +# Wait for the window to be mapped rather than for a number of seconds. A +# fixed sleep took an all-black screenshot the first time this ran, when sway +# had started in the same invocation and had not composited its output yet -- +# which is indistinguishable from an app that draws nothing. +i=0 +while [ $i -lt 40 ]; do + kill -0 "$pid" 2>/dev/null || break + swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break + i=$((i + 1)); sleep 0.25 +done + +# Then settle, for whatever the example does after its first frame. +i=0 +while [ $i -lt "$((seconds * 2))" ]; do + kill -0 "$pid" 2>/dev/null || break + i=$((i + 1)); sleep 0.5 +done + +if kill -0 "$pid" 2>/dev/null; then + [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + status=0 +else + wait "$pid" 2>/dev/null || status=$? + echo "run-headless: $example exited early (status ${status:-0})" >&2 + status=${status:-1} +fi + +echo "--- $example output ---" >&2 +cat "$run/$example.log" >&2 +exit "$status"