Cider Isn't Darwin Emulation, Really
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429#!/usr/bin/env bash# DRIVE A GUI APPLICATION UNDER CIDER AND CAPTURE WHAT IT DREW.## WHY THIS IS IN THE REPOSITORY. Every driver that verified an application used to live in the# session scratchpad under /tmp, and on 2026-09-01 systemd-tmpfiles deleted all of it: the drivers,# the status file and every analysis tool, because they were eleven days old and /tmp is swept by# age. The machine had not rebooted. Nothing was recoverable, because scratchpad/ in this repository# is a SYMLINK into that same directory and was never tracked. So the harness lives here now.## WHAT IT DOES, which is exactly the three criteria and nothing else:# RENDERS a capture at startup, to be LOOKED AT rather than counted# INTERACTIVE a real pointer click and a real key press, through a virtual input device# RESIZABLE the compositor output is resized and the window captured again## The application runs inside a NESTED compositor, not the user's own: the user's session is a# tiling manager, and a window there gets resized by the manager mid-run, which has silently# invalidated resize measurements before.## scripts/app-drive.sh --prefix /tmp/cider-sp-1000/prefix \# --app "/Applications/Swift Publisher 5.app/Contents/MacOS/Swift Publisher 5"## Captures land in captures/<name>/ in the repository, NOT in /tmp, for the reason above.set -u
REPO=$(cd "$(dirname "$0")/.." && pwd)PREFIX=""APPBIN=""NAME=""SETTLE=${SETTLE:-25} # seconds to let the application draw before the first captureLIMIT=${LIMIT:-120} # hard stop for the whole run# Arguments for the application itself. NSUserDefaults consults the ARGUMENT DOMAIN before the# plist, so APPARGS="-someSetting YES" sets a default that an externally written plist cannot.WIDTH=${WIDTH:-1256}HEIGHT=${HEIGHT:-684}# The resize target. Default is the old subtraction, but it must clear the window MINIMUM or the# capture shows a clipped title bar and reads as a broken resize. See the note at the resize step.RESIZE_W=${RESIZE_W:-$((WIDTH - 256))}RESIZE_H=${RESIZE_H:-$((HEIGHT - 84))}CLICK=${CLICK:-} # "x,y" to click after the first capture, empty to skip# Milliseconds between creating the virtual pointer and pressing, so the guest has seen the seat# capability and attached its listener. See the race described at the click step.CLICK_SETTLE=${CLICK_SETTLE:-1500}TYPE=${TYPE:-} # text to type after the click, empty to skipPOST_CLICK=${POST_CLICK:-} # "x,y" to click AFTER typing, when a keyboard exists (#210)# STEPS replaces the fixed CLICK, TYPE, POST_CLICK order with an arbitrary one, which is the only# way to fill two text fields. See the sequencer below for the verbs and the reason (#235).STEPS=${STEPS:-}
while [ $# -gt 0 ]; do case "$1" in --prefix) PREFIX="$2"; shift 2 ;; --app) APPBIN="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esacdone[ -n "$PREFIX" ] && [ -n "$APPBIN" ] || { echo "usage: $0 --prefix <dir> --app <guest binary> [--name <label>]" >&2; exit 2; }[ -n "$NAME" ] || NAME=$(basename "$(dirname "$(dirname "$APPBIN")")" .app | tr ' ' '-')
say() { echo "DRIVE $*" >&2; }
# TOOLS. sway, grim and wtype are nixpkgs; a store path that worked last month may have been# collected, so ask nix rather than hardcoding one. This is the slow part of a cold run and the# fast part of every other one.tool() { local attr=$1 bin=$2 path if command -v "$bin" >/dev/null 2>&1; then command -v "$bin"; return; fi path=$(nix build --no-link --print-out-paths "nixpkgs#$attr" 2>/dev/null | head -1) [ -n "$path" ] && [ -x "$path/bin/$bin" ] && { echo "$path/bin/$bin"; return; } echo ""}SWAY=$(tool sway sway); SWAYMSG=$(dirname "$SWAY" 2>/dev/null)/swaymsgGRIM=$(tool grim grim)WTYPE=$(tool wtype wtype)for t in SWAY GRIM WTYPE; do [ -n "${!t}" ] || { echo "missing tool: $t" >&2; exit 3; }done
# The virtual pointer holds its device open for a whole gesture. sway IPC makes a device per# command list and drops it at the end, so a press and a release arrive with the same timestamp and# every motion comes with no button held: that is why this exists rather than swaymsg seat commands.VPTR="$REPO/.cache/cider-vptr"if [ ! -x "$VPTR" ] || [ "$REPO/scripts/cider-vptr.c" -nt "$VPTR" ]; then mkdir -p "$REPO/.cache" say "building the virtual pointer" # The wlr virtual pointer protocol is XML, not a header: wayland-scanner generates both halves, # and the glue C has to be compiled in or every request is an undefined symbol. proto=$(nix build --no-link --print-out-paths nixpkgs#wlr-protocols 2>/dev/null | head -1) scanner=$(nix build --no-link --print-out-paths nixpkgs#wayland-scanner 2>/dev/null | grep -m1 bin) xml="$proto/share/wlr-protocols/unstable/wlr-virtual-pointer-unstable-v1.xml" [ -f "$xml" ] || { echo "wlr-protocols has no virtual pointer xml at $xml" >&2; exit 3; } "$scanner/bin/wayland-scanner" client-header "$xml" \ "$REPO/.cache/wlr-virtual-pointer-unstable-v1-client-protocol.h" || exit 3 "$scanner/bin/wayland-scanner" private-code "$xml" \ "$REPO/.cache/wlr-virtual-pointer-unstable-v1-protocol.c" || exit 3 cc -O2 -I"$REPO/.cache" -o "$VPTR" "$REPO/scripts/cider-vptr.c" \ "$REPO/.cache/wlr-virtual-pointer-unstable-v1-protocol.c" \ $(pkg-config --cflags --libs wayland-client 2>/dev/null || echo -lwayland-client) \ 2>"$REPO/.cache/cider-vptr.log" || { echo "vptr build failed, see .cache/cider-vptr.log" >&2; exit 3; }fi
# The artifact path carries the package path, which moved when first-party code went under src/, so# match both and take the newest: an old launcher still on disk is the wrong guest entirely.CIDER=${CIDER:-$(ls -t "$REPO"/buck-out/v2/art/root/*/src/linux/launcher/__cider__/cider \ "$REPO"/buck-out/v2/art/root/*/linux/launcher/__cider__/cider 2>/dev/null | head -1)}[ -x "$CIDER" ] || { echo "no cider launcher found, build //src/linux/launcher:cider or set CIDER" >&2; exit 3; }
SHOTS="$REPO/captures/$NAME"rm -rf "$SHOTS"; mkdir -p "$SHOTS"
# A NESTED COMPOSITOR OF OUR OWN, so the user's tiling manager cannot resize the window under us.XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/1000}PARENT_DISPLAY=${PARENT_DISPLAY:-wayland-1}cat > "$SHOTS/sway.conf" <<EOFdefault_border nonefocus_follows_mouse yesoutput * mode ${WIDTH}x${HEIGHT}EOF# Which socket is OURS is decided by difference: sway picks the next free wayland-N, so record the# set before starting it and take whatever is new. Guessing a name races the compositor.before=$(ls "$XDG_RUNTIME_DIR"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | sort)# HEADLESS, NOT NESTED IN A WINDOW. On the wayland backend the output is a window in the user's# tiling manager, and neither the mode in this config nor a later swaymsg output mode changes its# size: two captures taken either side of a resize came back identically 930x1028, the size the# parent had chosen. Headless owns its own output, so the size asked for is the size captured, and# the resize criterion becomes measurable.WAYLAND_DISPLAY=$PARENT_DISPLAY XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR \ WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_HEADLESS_INPUTS=1 "$SWAY" -c "$SHOTS/sway.conf" -d >"$SHOTS/sway.log" 2>&1 &SWAYPID=$!NEW=""for _ in $(seq 1 60); do after=$(ls "$XDG_RUNTIME_DIR"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | sort) fresh=$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -1) [ -n "$fresh" ] && { NEW=$(basename "$fresh"); break; } sleep 0.25done[ -n "$NEW" ] || { echo "nested compositor never came up, see $SHOTS/sway.log" >&2; kill $SWAYPID 2>/dev/null; exit 4; }say "nested compositor on $NEW"# swaymsg finds its IPC socket through SWAYSOCK, not through WAYLAND_DISPLAY: without it the resize# step failed with "Unable to retrieve socket path" and the output never changed size.SWAYSOCK=$(ls -t "$XDG_RUNTIME_DIR"/sway-ipc.*.sock 2>/dev/null | head -1)export SWAYSOCK
shoot() { WAYLAND_DISPLAY=$NEW "$GRIM" "$SHOTS/$1.png" 2>>"$SHOTS/driver.log" && say "shot $1"; }
# WHAT THE GUEST NEEDS TOLD, and every one of these was a silent hang until it was measured (#168).# The launcher bakes an install prefix and only finds its daemon as a SIBLING, which buck artifacts# never are; the daemon names its own missing paths only in <prefix>/ciderd.log; and launchd cannot# spawn a job here (#139), so the container is booted WITHOUT it, which is what every driver that# ever ran an application did. Set LAUNCHD=1 to get the launchd path back.CIDERD=${CIDERD:-$(ls -t "$REPO"/buck-out/v2/art/root/*/src/linux/server/__ciderd__/ciderd 2>/dev/null | head -1)}MLDR=${MLDR:-$(ls -t "$REPO"/buck-out/v2/art/root/*/src/darwin/loader/__mldr__/mldr 2>/dev/null | head -1)}RT=${RT:-$(ls -td "$REPO"/buck-out/v2/art/root/*/buck/prefix/__cider_prefix__/cider_prefix__prefix 2>/dev/null | head -1)}for t in CIDERD MLDR RT; do [ -e "${!t}" ] || { echo "missing $t: build //src/linux/server:ciderd, //src/darwin/loader:mldr and //buck/prefix:cider_prefix" >&2; exit 3; }done
# THE HOST LIBRARIES THE GUEST DLOPENS. mldr loads libGL and friends out of the nix store, and with# no search path it tries whichever store directory it saw last: the first run failed with# "libGL.so.1: /nix/store/...-alsa-lib-.../lib/libGL.so.1: cannot open shared object file", which# names alsa-lib because that was simply the last entry, not because anyone asked for it.# buck-setup.nu already computes the list; it is the same one the compiler links against.ELF_LIBS=$(grep '^elf_lib_dirs' "$REPO/.buckconfig.local" 2>/dev/null | sed 's/^elf_lib_dirs *= *//')
# THE ENTRY POINTS A NEWER SWIFT EXPECTS. Nothing links this library, so it has to be inserted, and# dyld's last-resort lookup matches the image by its exact path, which is why the same string is# both variables. Without it iTerm2 and iA Writer die in dyld before any window:# "Symbol not found: _$ss042_stdlib_isOSVersionAtLeastOrVariantVersion..., expected in libswiftCore".COMPAT=${COMPAT:-/usr/lib/swift/libswiftCompat.dylib}
# READ A VALUE OUT OF THE APPLICATION'S OWN IVARS, which no trace of ours can reach.# SPY='PTYSession.insertText:' scripts/app-drive.sh ...# src/darwin/spy takes a comma separated Class.selector list and logs each call with its argument# and return. It is inserted ALONGSIDE the compat library rather than instead of it, because# dropping compat kills any Swift application in dyld before it draws.SPY=${SPY:-}INSERT="$COMPAT"[ -n "$SPY" ] && INSERT="$COMPAT:/usr/lib/cider-spy.dylib"
# RECORD WHICH CONFIGURATION THIS WAS. An unrecorded setting cannot be proven after the fact: a# whole roster was driven with LAUNCHD_FORCE and afterwards nothing in the captures could say# whether the override had taken, because the guest syslog APPENDS across runs and its daemon names# were left over from earlier ones.say "launchd $([ "${LAUNCHD:-1}" = 0 ] && echo ON || echo off) (CIDER_NO_LAUNCHD=${LAUNCHD:-1})"say "launching $APPBIN"( # env, NOT an assignment prefix. An unquoted ${VAR:+NAME=value} is expanded AFTER the line is # parsed, so bash does not see an assignment and takes it as the command name: the whole run # died with "CIDER_WAYLAND_TRACE_INPUT=1: command not found" and an empty log. # SLIM THE ENVIRONMENT, and this is not tidiness. iTerm2 encodes a launch request for every # child it starts, the request CARRIES THE ENVIRONMENT, and the buffer is fixed: with the nix # devshell inherited (NIX_CFLAGS_COMPILE alone is 21 KB) it aborts at "encoded length 67951" and # no session ever starts. The failure never mentions size. PATH and LD_LIBRARY_PATH stay because # the runtime needs them. UNSET=$(env | awk -F= '/^[A-Za-z_][A-Za-z0-9_]*=/ && length($0)>400 && $1!="PATH" && $1!="LD_LIBRARY_PATH" {printf "-u %s ", $1}') # AND THE HOST TOOLKIT VARIABLES, which is the locale problem below one family further out. # This desktop exports QT_QPA_PLATFORM=wayland;xcb, a macOS Qt build ships only the cocoa # plugin, and CMake.app read the host value, looked for a plugin it does not have and exited # before drawing anything: # qt.qpa.plugin: Could not find the Qt platform plugin "wayland" in "" # The 400 character rule above could never catch these; they are short. WAYLAND_DISPLAY and # XDG_RUNTIME_DIR are deliberately NOT here, because cider's own backend needs them. UNSET="$UNSET $(env | grep -oE '^(QT_[A-Za-z0-9_]*|GDK_BACKEND|SDL_VIDEODRIVER|CLUTTER_BACKEND|GTK_IM_MODULE|XMODIFIERS)=' | tr -d '=' | sed 's/^/-u /' | tr '\n' ' ')" # PIN THE LOCALE, because the host's leaks in and decides whether an application starts. This # host is en_DK.UTF-8, and Darwin ships no en_DK: iTerm2 checks locale -a for its language and # country, finds nothing, and opens a MODAL prompt over the terminal before any session runs. # That is what a Mac set to English/Denmark does too, so it is not a bug to fix in the port, # but it does make a drive depend on whose machine it runs on. env $UNSET LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \ CIDERPREFIX="$PREFIX" WAYLAND_DISPLAY=$NEW XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/1000} \ CIDER_NO_LAUNCHD="${LAUNCHD:-1}" LD_LIBRARY_PATH="$ELF_LIBS${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ DYLD_INSERT_LIBRARIES="$INSERT" CIDER_COMPAT_LIBRARY="$COMPAT" ${SPY:+CIDER_SPY="$SPY"} \ ${TRACE_INPUT:+CIDER_WAYLAND_TRACE_INPUT=1} ${TRACE_ENV:-} \ DSERVER_PATH="$(realpath "$CIDERD")" DSERVER_MLDR_PATH="$(realpath "$MLDR")" \ DSERVER_LIBEXEC_PATH="$(realpath "$RT")/libexec/cider" \ ${WRAP:-} timeout "$LIMIT" "$CIDER" shell "$APPBIN" ${APPARGS:-} # A quiet exit and a process still running at the limit leave the same silence in the log, and # they want opposite work. 124 is the timeout's own code: still alive. echo "cider-app exit=$?") >"$SHOTS/app.log" 2>&1 &APPPID=$!
# A SEGFAULT AND A CLEAN EXIT BOTH REPORT exit=0, and that has now cost days. cider shell does not# propagate the guest signal, so the app.log line above says 0 for a process that died on SIGSEGV.# The fault IS recorded, in the container-wide ciderd.log, which APPENDS across runs, so remember# how far it had got before this launch and only read what comes after.CIDERD_LOG="$PREFIX/ciderd.log"
# EVERY SIGNAL GOES THROUGH sigexc, NOT ONLY FATAL ONES: the unfiltered line called iTerm2 a crash# on a sig 28, the SIGWINCH a terminal gets when the driver resizes it. 4/6/7/8/10/11 are ILL, ABRT,# EMT, FPE, BUS, SEGV.FATAL_SIG_RE="sigexc: have RIP .* sig (4|6|7|8|10|11)\$"
# grep -c prints 0 AND exits 1 on no match, so a plain "or echo 0" appends a SECOND zero and every# comparison below then fails on a two-line number.count_re() { grep -cE "$1" "$CIDERD_LOG" 2>/dev/null | head -1 | tr -dc '0-9' | grep . || echo 0; }
FAULTS_BEFORE=$(count_re "$FATAL_SIG_RE")SIGNALS_BEFORE=$(count_re "sigexc: have RIP")
sleep "$SETTLE"shoot d1-start
# A SEQUENCE, not a click. Showing that the keyboard works needs a text field, and a text field is# several clicks deep in most applications: Swift Publisher wants the welcome window closed, a# template picked and Choose pressed before anything will take a keystroke. Semicolons separate.# The vocabulary is abs/rel/press/release/scroll/sleep. "move" and "click" were ignored in# silence, which reads exactly like a click that landed and did nothing.## THE POINTER HAS THE SAME RACE THE KEYBOARD DOES, and the sleep is the same trick as wtype -s.# This tool creates the virtual pointer when it starts and destroys it when it exits, so the seat# gains and loses the capability in one breath; the guest attaches its wl_pointer listener only# after it SEES the capability, and a press sent before that is gone. Measured on mmex: a click on# one button opened its dialog 9 times in 12 at 200ms and never at all in three runs under load,# with the guest input trace showing the pointer attached and released and NO button event between.press_at() { printf 'abs %s %s\nsleep %s\npress left\nsleep 80\nrelease left\n' "$1" "$2" "$CLICK_SETTLE" \ | WAYLAND_DISPLAY=$NEW "$VPTR" "$WIDTH" "$HEIGHT" >>"$SHOTS/driver.log" 2>&1}
# -s SLEEPS BEFORE TYPING, and that is the whole trick. wtype creates its virtual keyboard when it# starts and destroys it when it exits, so the seat gains and loses the capability in one breath;# the guest attaches its wl_keyboard listener only after it SEES the capability, and every key was# gone before the listener existed. Sleeping first keeps the device alive long enough for the# application to attach, and -d spaces the keys so none is lost to the same race.send_keys_spec() { case "$1" in # Raw wtype arguments, for a shortcut: "raw:-M,logo,n,-m,logo" is Command and N, since the # backend maps Mod4 to NSCommandKeyMask. A menu item several clicks deep is not reachable # any other way from here. # # A NAMED KEY NEEDS -k, AND A BARE WORD IS TYPED AS TEXT. "raw:-M,logo,comma,-m,logo" does # NOT send Command and comma: wtype types the five letters c, o, m, m, a with Command held, # so the application receives Command C, Command O, Command M, Command M and Command A. # Command M minimised the window, which then stopped updating its frame on every later # resize, and I filed that as a two window resize defect before finding the cause was my own # shortcut. The correct form is "raw:-M,logo,-k,comma,-m,logo". A single letter is fine bare. # COMMAS BECOME SPACES, because STEPS splits on whitespace so a raw sequence cannot contain # one. The comment above the sequencer has promised this since the verb was added and the # code never did it: "raw:-M,logo,comma,-m,logo" reached wtype as ONE argument, wtype # rejected it, no virtual keyboard was ever created, and the run looked exactly like an # application ignoring a shortcut. A harness that cannot express the interaction # manufactures defects. raw:*) rawargs=$(printf '%s' "${1#raw:}" | tr ',' ' ') WAYLAND_DISPLAY=$NEW "$WTYPE" -s 1500 $rawargs >>"$SHOTS/driver.log" 2>&1 \ || say "WTYPE FAILED on raw sequence [$rawargs]: no keys were sent" ;; # key:<name> sends a named key rather than text. Proving the keyboard works needs something # whose effect is VISIBLE, and in an application whose text fields are several clicks deep # the cheapest such thing is Return on a selection. key:*) WAYLAND_DISPLAY=$NEW "$WTYPE" -s 1500 -k "${1#key:}" >>"$SHOTS/driver.log" 2>&1 \ || say "WTYPE FAILED on key [${1#key:}]: no keys were sent" ;; *) WAYLAND_DISPLAY=$NEW "$WTYPE" -s 1500 -d 120 "$1" >>"$SHOTS/driver.log" 2>&1 \ || say "WTYPE FAILED on text [$1]: no keys were sent" ;; esac}
# STEPS: AN ARBITRARY SEQUENCE, because CLICK then TYPE then POST_CLICK cannot fill two fields.## CMake wants a click, a path, another click, another path, then Configure, and the fixed order# below can express none of that. Tab was tried instead and does not move focus between those two# fields, which produced a run that LOOKED like a hung configure and was really a build directory# that was never set (#235). A harness that cannot express the interaction manufactures defects.## STEPS="click:300,73 type:/tmp/hello click:500,137 type:/tmp/hello/build click:57,567 wait:8"## Verbs: click:x,y type:<text|raw:...|key:...> wait:<seconds> shot:<name> size:WxH# SPACE SEPARATES STEPS, so no argument may contain a space; quote a raw: sequence with commas# instead. Every step is captured as sNN-<verb> so a sequence that goes wrong can be read back# frame by frame rather than guessed at.## size: RESIZES MORE THAN ONCE, which the single RESIZE_W/RESIZE_H step below cannot. A layout that# is one resize BEHIND and a layout that is dead look identical after a single resize: both show# the wrong geometry. Only a second resize separates them, because a one-behind layout then shows# what the FIRST resize should have produced. Task #247.if [ -n "${STEPS:-}" ]; then n=0 for STEP in $STEPS; do n=$((n+1)) verb=${STEP%%:*}; arg=${STEP#*:} case "$verb" in click) x=${arg%,*}; y=${arg#*,}; say "step $n click at $x,$y"; press_at "$x" "$y"; sleep 4 ;; type) say "step $n type $arg"; send_keys_spec "$arg"; sleep 3 ;; wait) say "step $n wait $arg"; sleep "$arg" ;; shot) say "step $n shot $arg" ;; # WIDTH AND HEIGHT MOVE WITH THE OUTPUT, because press_at maps a click against them. # Left stale, every click after a resize lands somewhere else: a click aimed at an # inspector field on a 1100x800 capture was delivered as if the output were still # 1256x684 and hit the page instead, which reads as a field that ignores clicks. size) say "step $n resize the output to $arg" WAYLAND_DISPLAY=$NEW "$SWAYMSG" output '*' mode "$arg" \ >>"$SHOTS/driver.log" 2>&1 WIDTH=${arg%x*}; HEIGHT=${arg#*x} sleep 5 ;; *) echo "unknown step verb: $STEP" >&2; exit 2 ;; esac shoot "$(printf 's%02d-%s' "$n" "$verb")" donefi
for STEP in ${CLICK//;/ }; do x=${STEP%,*}; y=${STEP#*,} say "click at $x,$y" press_at "$x" "$y" sleep 4done[ -n "$CLICK" ] && shoot d2-click
if [ -n "$TYPE" ]; then say "typing $TYPE" send_keys_spec "$TYPE" sleep 3 shoot d3-typedfi
# A CLICK AFTER THE KEYS, which is the only way to click while a keyboard EXISTS. wtype creates its# virtual keyboard when it starts, so before TYPE the seat advertises no keyboard at all, no window# can be given keyboard focus, and no window is key. A control that declines a click in a non-key# window then looks identical to a control that ignores clicks. CLICK cannot answer that because it# runs first, by design: it is what opens the field TYPE aims at. Task #210.if [ -n "${POST_CLICK:-}" ]; then for STEP in ${POST_CLICK//;/ }; do x=${STEP%,*}; y=${STEP#*,} say "post-click at $x,$y" press_at "$x" "$y" sleep 4 done shoot d3b-postclickfi
# A RESIZE BELOW THE APPLICATION MINIMUM IS NOT A FAILED RESIZE, and reading one as a failure cost# a whole investigation. Asked for 1000x600, Swift Publisher answered 1000x618 and LibreOffice# 900x739, because that is each window minimum height; the extra height then hangs off the output# and the capture shows the TITLE BAR CLIPPED, which looks exactly like a window that ignored the# configure. Above the minimum both follow the request exactly (900x650 and 900x800, frame equal to# surface). Measured minimums: Swift Publisher 618, LibreOffice 739, iTerm2 none reached.# So set RESIZE_W and RESIZE_H per application rather than trusting the default subtraction, and# read the verdict from CIDER_WAYLAND_TRACE_GEOMETRY (frame must equal surface), never from pixels.say "resizing the output to ${RESIZE_W}x${RESIZE_H}"WAYLAND_DISPLAY=$NEW "$SWAYMSG" output '*' mode "${RESIZE_W}x${RESIZE_H}" >>"$SHOTS/driver.log" 2>&1sleep 5shoot d4-resized
# A LAST FRAME, because a pass that clears and defers its content to the next one looks exactly like# a pass that drew nothing. POST_SETTLE seconds later, d5 says which it was.if [ -n "${POST_SETTLE:-}" ]; then sleep "$POST_SETTLE" shoot d5-settledfi
kill $APPPID 2>/dev/nullkill $SWAYPID 2>/dev/null# THE VERDICT THE EXIT CODE CANNOT GIVE. Printed whether or not it fired, because "no fault line"# is the answer to a real question and an absent line reads as an unasked one.FAULTS_AFTER=$(count_re "$FATAL_SIG_RE")SIGNALS_AFTER=$(count_re "sigexc: have RIP")if [ "$FAULTS_AFTER" -gt "$FAULTS_BEFORE" ]; then say "GUEST FAULTED during this drive, $(( FAULTS_AFTER - FAULTS_BEFORE )) time(s). exit=0 above is NOT a clean run:" grep -E "$FATAL_SIG_RE" "$CIDERD_LOG" 2>/dev/null | tail -n "$(( FAULTS_AFTER - FAULTS_BEFORE ))" \ | sed 's/^/DRIVE /' >&2 say "symbolicate with: scripts/core-guest-stack.py --root $PREFIX --root <rt>/libexec/cider <core> <RIP>"else say "no guest fault recorded in ciderd.log"fi
# Reported, not dropped: silence here is how a wrong FATAL_SIG_RE would hide a real crash.if [ "$(( SIGNALS_AFTER - FAULTS_AFTER ))" -gt "$(( SIGNALS_BEFORE - FAULTS_BEFORE ))" ]; then say "non fatal guest signals this drive: $(( (SIGNALS_AFTER - FAULTS_AFTER) - (SIGNALS_BEFORE - FAULTS_BEFORE) )) (SIGWINCH and the like, not a crash)"fi
# THE RUN THAT MEASURED NOTHING, said out loud. The launcher gives up waiting for the guest often# enough to hit any single comparison (2 of 5 attempts in one batch), and it exits 120 leaving a# fully black capture, which is byte for byte what a crash after a bad click leaves. On 2026-09-21# that pair was read as "the crash reproduced" and a two sided A/B was recorded on a run where the# application had never started. An instrument that cannot say it measured nothing will be believed# when it did. Task #248.# The criterion is the SIZE of the log, not one known message. The first version matched only the# timeout text and the very next void run said "Cannot open mnt namespace of pid" instead, exit=1,# and slipped through: a guard that lists the failures it knows about cannot report the one nobody# has seen yet. A guest that ran produces hundreds of lines whatever it then does.APPLOG_LINES=$(wc -l < "$SHOTS/app.log" 2>/dev/null || echo 0)if [ "$APPLOG_LINES" -lt 10 ]; then say "RUN VOID: the guest never started ($APPLOG_LINES lines of log), so every capture above is" say "RUN VOID: black for that reason alone, and the exit code is the LAUNCHER's, not the app's." sed 's/^/DRIVE /' "$SHOTS/app.log" >&2 2>/dev/nullfi
say "captures in $SHOTS"ls "$SHOTS"/*.png 2>/dev/null