v4.e killed the worker mid-session and then required "the encode worker died
mid-session" in the spike's log. That line, and the respawn that follows it, are
emitted by `RemotePyroWave::reset` — and the only caller of `Encoder::reset` is
the real session's `reset_stalled_encoder` loop in native/stream.rs. `spike` is
a dev tool with no recovery loop at all: it does
encoder.submit(&frame).context("encoder submit")?
and exits. So a worker killed under the spike can never reach reset, the line
can never appear, and the leg reported
FAILED — a red leg here is a shipping blocker, not a flake.
for a ladder rung the product implements correctly. A false negative in the one
place that must not have one: this kit exists to refuse false PASSes, and a
false FAIL spends exactly the same credibility.
Verified on glass first, so the rung is not being excused on a reading of the
source. home-nobara-1 (KDE, RTX 5070 Ti), real client session, worker pid 44249
killed with -9: `video_streaming` stayed true across the kill, and the host
logged
pyrowave: respawned the encode worker after a mid-session death
worker=/usr/bin/punktfunk-encode-worker priority=Granted(Realtime)
encoder submit failed — encoder rebuilt in place, forcing an IDR
error=... Broken pipe (os error 32) reset=1 max=5
v4.e now asserts the half the spike can actually observe — the death surfaces as
an ATTRIBUTABLE worker-IPC error naming the worker, after real encode windows,
and the host process does not die with it. A hang, an unexplained failure, or a
dead host still fails. The respawn half is printed as the human follow-up, in
the same idiom v1 already uses for its on-glass half, and written into `recipe`
with the two commands that close it.
1467 lines
75 KiB
Bash
Executable File
1467 lines
75 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# WP3 — the on-glass validation kit for `punktfunk-encode-worker`.
|
||
#
|
||
# Plan: punktfunk-planning/design/gpu-priority-capability-worker-implementation-plan.md §2/WP3.
|
||
# This script is the mechanical half of that table. It runs ON a real box (Deck desktop mode, .21,
|
||
# .25, .41, a nobara VM); it needs a GPU, and for the KDE legs a live KDE session. Nothing here
|
||
# talks to another machine.
|
||
#
|
||
# WHAT IS BEING VALIDATED. `punktfunk-encode-worker` is a separate binary carrying
|
||
# `cap_sys_nice=ep`; it encodes PyroWave at an elevated VK_KHR_global_priority class so the encode
|
||
# dispatch can preempt a GPU-bound game. `punktfunk-host` carries NO capability, ever: KWin
|
||
# identifies a Wayland client by resolving its /proc/<pid>/exe, the kernel refuses that readlink to
|
||
# any reader whose effective set is not a superset of the target's PERMITTED set
|
||
# (cap_ptrace_access_check), KWin holds no capabilities — so a capability-carrying host is
|
||
# unidentifiable, `zkde_screencast_unstable_v1` is never advertised, and every KDE desktop session
|
||
# loses streaming. That is the 0.26.0-1 field incident, and V1 below is its regression test.
|
||
#
|
||
# SAFE BY DEFAULT. With no arguments this only READS: /proc, getcap, the installed .desktop. It
|
||
# starts no session, kills nothing, and never calls setcap. Every leg that runs a capture+encode
|
||
# session must be named explicitly; the one leg that kills a process needs --allow-mutate on top.
|
||
# This script NEVER setcaps anything, least of all the host — it asserts the host is uncapped and
|
||
# hard-fails if it is not, which is the entire point of V1.
|
||
#
|
||
# SKIPPED IS NOT A PASS. A leg that cannot run prints SKIP with the reason and the exit code says
|
||
# so (2). Only PASS counts, and anything this script cannot determine is reported, never assumed.
|
||
#
|
||
# Usage: scripts/validate-encode-worker.sh [LEG ...] [OPTIONS]
|
||
# scripts/validate-encode-worker.sh recipe # the per-box human recipe, incl. sudo
|
||
# scripts/validate-encode-worker.sh --help
|
||
#
|
||
# Legs:
|
||
# inspect (default) pure read-only inspection: the capability matrix as it exists on THIS box,
|
||
# plus every running host's runtime capability state. No session, no spawn.
|
||
# v1 the 0.26.0-1 regression test — KDE only.
|
||
# v2 grant: the worker reports REALTIME.
|
||
# v3a IPC hop cost: in-process vs an UNCAPPED worker, both at default GPU priority.
|
||
# R1's pre-registered abandonment gate. Needs no capability and no KDE.
|
||
# v3b lever benefit: in-process at default priority vs the CAPPED worker.
|
||
# v4 the fallback ladder (chaos). Read-only rungs by default; --allow-mutate adds kill -9.
|
||
# v5 fd hygiene over a long session (default 10 min).
|
||
# auto inspect + v1 + v2 + v3a + v4 — everything that needs no extra permission.
|
||
# all auto + v3b + v5.
|
||
# recipe the per-box human recipe, including the privileged steps this script will not run.
|
||
# selftest red-team this kit's own readers against synthetic logs. No box, no GPU, no Linux —
|
||
# run it before trusting a green run, because an assertion that cannot fail is not one.
|
||
#
|
||
# ⚠ USE INSTALLED PATHS ON THE KDE LEGS. KWin caches the grant per EXECUTABLE PATH, matched against
|
||
# an installed .desktop's `Exec=`. A binary run from a scratch build directory is a different
|
||
# path, so KWin refuses it and `zkde_screencast_unstable_v1` never appears. That is identification
|
||
# WORKING, not a bug — do not go debugging KWin. This script resolves the host binary from the
|
||
# installed .desktop precisely so the legs run against the path KWin knows.
|
||
#
|
||
# ⚠ THE WORKER'S PERF SUMMARY GOES TO INHERITED STDERR. The p50/p99 lines are emitted by the worker
|
||
# process, whose stderr is the host's. They reach `journalctl`; they do NOT reach the web
|
||
# console's Logs tab (that ring is a tracing layer INSIDE the host process). This kit runs the
|
||
# spike itself and captures stderr directly, so it never depends on either.
|
||
|
||
# No `set -e`. This harness deliberately runs commands that are EXPECTED to fail (a readlink that
|
||
# must be refused, a worker that must not spawn); under -e a normal negative result aborts the run
|
||
# and looks like a crash. Every fallible call below checks its own status instead.
|
||
set -uo pipefail
|
||
|
||
VERSION='WP3 kit 1'
|
||
SELF="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
# --- the capability vocabulary, reused rather than re-derived ------------------------------------
|
||
# scripts/ci/assert-cap-matrix.sh (WP4) is sourceable by design and owns `caps_norm` — the
|
||
# canonicalizer for getcap's two output forms ("path cap_sys_nice=ep" since libcap ~2.36,
|
||
# "path = cap_sys_nice+ep" before) and rpm's "(none)". Reuse it: two normalizers that disagree is
|
||
# exactly how a wrong capability string gets waved through.
|
||
#
|
||
# It carries `set -euo pipefail` at the top, which sourcing would import into THIS shell, so the
|
||
# option set is saved and restored around the source.
|
||
CAP_MATRIX_SH="$SELF/ci/assert-cap-matrix.sh"
|
||
if [ ! -r "$CAP_MATRIX_SH" ]; then
|
||
echo "FATAL: cannot read $CAP_MATRIX_SH — run this from a Punktfunk checkout (it owns the" >&2
|
||
echo " capability-string canonicalizer this kit reuses)." >&2
|
||
exit 3
|
||
fi
|
||
_saved_opts="$(set +o)"
|
||
# shellcheck source=scripts/ci/assert-cap-matrix.sh
|
||
. "$CAP_MATRIX_SH"
|
||
eval "$_saved_opts"
|
||
unset _saved_opts
|
||
set +e # `eval` above restored -e from the sourced file's perspective; this is our posture.
|
||
|
||
WORKER_BIN_NAME='punktfunk-encode-worker'
|
||
HOST_BIN_NAME='punktfunk-host'
|
||
# CAP_SYS_NICE is capability bit 23, so the kernel prints it as CapPrm: 0000000000800000.
|
||
CAP_SYS_NICE_BIT=23
|
||
|
||
# --- the log lines this kit asserts on, verbatim -------------------------------------------------
|
||
# Every one of these is a fixed substring of a real `tracing` message (grep -F, never a regex — the
|
||
# messages contain em dashes and parentheses). If a message is reworded, this block is the single
|
||
# place to fix, and a leg that stops matching FAILS rather than silently passing.
|
||
L_FALLBACK='encoding in-process at default GPU priority' # the clause EVERY fallback ends with
|
||
L_OFF='pyrowave: PUNKTFUNK_ENCODE_WORKER=off'
|
||
L_NOTFOUND='the encode worker was not found beside the host binary or on PATH'
|
||
L_NOTUP='the encode worker did not come up'
|
||
L_GRANTED='encoding in the capability-carrying worker at an elevated global queue priority'
|
||
L_NOPRIO='encoding in the capability-carrying worker, no queue priority requested'
|
||
L_INERT='every global queue priority class was refused'
|
||
L_INERT_WORKER='CAP_SYS_NICE on the encode WORKER binary' # the worker-side wording (host logs it)
|
||
L_INERT_HOST='CAP_SYS_NICE on the host binary' # the in-process wording
|
||
L_LEAVING='pyrowave: leaving the encode worker'
|
||
L_DIED='the encode worker died mid-session'
|
||
L_RESPAWNED='respawned the encode worker after a mid-session death'
|
||
L_NORESPAWN='the encode worker would not respawn'
|
||
# The proxy's attributable "my peer went away" errors. `spike` has NO encoder-recovery loop — it does
|
||
# `encoder.submit(..)?` and exits — so under the spike a killed worker surfaces as one of these and
|
||
# never reaches `Encoder::reset`, which is where L_DIED/L_RESPAWNED are emitted. See v4.e.
|
||
L_PEERGONE='no reply from the encode worker'
|
||
L_PEERPIPE='send to the encode worker'
|
||
L_WORKER_READY='punktfunk-encode-worker ready'
|
||
L_PERF='pyrowave encode, submit->AU'
|
||
L_CAPTURE='capture pipeline resolved:'
|
||
L_KWIN_DENIED='does not expose zkde_screencast_unstable_v1'
|
||
|
||
# --- options -------------------------------------------------------------------------------------
|
||
OPT_HOST_BIN=''
|
||
OPT_WORKER_BIN=''
|
||
OPT_HOST_PID=''
|
||
OPT_SOURCE=''
|
||
OPT_SECONDS=45
|
||
OPT_MINUTES=10
|
||
OPT_WIDTH=1920
|
||
OPT_HEIGHT=1080
|
||
OPT_FPS=60
|
||
OPT_BITRATE=20
|
||
OPT_GATE_MS='1.0'
|
||
OPT_FD_TOLERANCE=0
|
||
OPT_ALLOW_MUTATE=0
|
||
OPT_KEEP_LOGS=0
|
||
OPT_LOG_DIR=''
|
||
OPT_LOOPBACK=0
|
||
LEGS=()
|
||
|
||
usage() {
|
||
sed -n '2,56p' "${BASH_SOURCE[0]}" | sed -e 's/^# \{0,1\}//'
|
||
cat <<'EOF'
|
||
|
||
Options:
|
||
--host-bin PATH the INSTALLED punktfunk-host (default: from a running host, then the
|
||
installed .desktop's Exec=, then PATH)
|
||
--worker-bin PATH the INSTALLED punktfunk-encode-worker (default: $PUNKTFUNK_ENCODE_WORKER,
|
||
then beside the host binary, then PATH — mirrors the host's own resolution)
|
||
--host-pid PID inspect this host process only (default: every running punktfunk-host)
|
||
--source SRC spike capture source: kwin-virtual | portal (default: kwin-virtual under
|
||
KWin, portal elsewhere). ⚠ synthetic is NOT usable — see the note in v3a.
|
||
--seconds N wall clock per measured arm (default 45)
|
||
--minutes N v5 session length (default 10)
|
||
--width/--height/--fps N spike geometry (default 1920x1080@60)
|
||
--bitrate MBPS spike bitrate (default 20)
|
||
--gate-ms X v3a abandonment gate, p99 headroom in ms (default 1.0)
|
||
--fd-tolerance N v5 allowed fd growth in the steady-state window (default 0)
|
||
--with-loopback keep the spike's punktfunk-core loopback on (default: --no-loopback, less
|
||
CPU noise in the latency arms)
|
||
--allow-mutate permit the legs that change the box: v4's `kill -9` of a live worker
|
||
--log-dir DIR where the captured spike logs go (default: a mktemp dir)
|
||
--keep-logs do not delete the captured logs on exit
|
||
-h, --help this text
|
||
|
||
Exit: 0 every attempted leg PASSed and none was skipped · 1 at least one FAIL · 2 no FAIL but at
|
||
least one SKIP (the run is incomplete — a skip is never a pass) · 3 the kit could not start.
|
||
EOF
|
||
}
|
||
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
inspect|v1|v2|v3a|v3b|v4|v5|auto|all) LEGS+=("$1") ;;
|
||
recipe|selftest) LEGS+=("$1") ;;
|
||
--host-bin) OPT_HOST_BIN="${2:-}"; shift ;;
|
||
--worker-bin) OPT_WORKER_BIN="${2:-}"; shift ;;
|
||
--host-pid) OPT_HOST_PID="${2:-}"; shift ;;
|
||
--source) OPT_SOURCE="${2:-}"; shift ;;
|
||
--seconds) OPT_SECONDS="${2:-}"; shift ;;
|
||
--minutes) OPT_MINUTES="${2:-}"; shift ;;
|
||
--width) OPT_WIDTH="${2:-}"; shift ;;
|
||
--height) OPT_HEIGHT="${2:-}"; shift ;;
|
||
--fps) OPT_FPS="${2:-}"; shift ;;
|
||
--bitrate) OPT_BITRATE="${2:-}"; shift ;;
|
||
--gate-ms) OPT_GATE_MS="${2:-}"; shift ;;
|
||
--fd-tolerance) OPT_FD_TOLERANCE="${2:-}"; shift ;;
|
||
--with-loopback) OPT_LOOPBACK=1 ;;
|
||
--allow-mutate) OPT_ALLOW_MUTATE=1 ;;
|
||
--log-dir) OPT_LOG_DIR="${2:-}"; shift ;;
|
||
--keep-logs) OPT_KEEP_LOGS=1 ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) echo "unknown argument '$1' (try --help)" >&2; exit 3 ;;
|
||
esac
|
||
shift
|
||
done
|
||
[ ${#LEGS[@]} -gt 0 ] || LEGS=(inspect)
|
||
|
||
# Numeric options are used in arithmetic; a typo must be a refusal, not a bash error mid-leg.
|
||
for _n in OPT_SECONDS OPT_MINUTES OPT_WIDTH OPT_HEIGHT OPT_FPS OPT_BITRATE OPT_FD_TOLERANCE; do
|
||
case "${!_n}" in
|
||
''|*[!0-9]*) echo "FATAL: --${_n#OPT_} must be a whole number, got '${!_n}'" >&2; exit 3 ;;
|
||
esac
|
||
done
|
||
case "$OPT_GATE_MS" in ''|*[!0-9.]*) echo "FATAL: --gate-ms must be numeric, got '$OPT_GATE_MS'" >&2; exit 3 ;; esac
|
||
unset _n
|
||
|
||
# --- result bookkeeping ---------------------------------------------------------------------------
|
||
N_PASS=0; N_FAIL=0; N_SKIP=0
|
||
RESULTS=()
|
||
C_OK=''; C_BAD=''; C_WARN=''; C_OFF=''
|
||
if [ -t 1 ]; then C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_WARN=$'\033[33m'; C_OFF=$'\033[0m'; fi
|
||
|
||
pass() { N_PASS=$((N_PASS+1)); RESULTS+=("PASS|$1|$2"); printf ' %sPASS%s %-6s %s\n' "$C_OK" "$C_OFF" "$1" "$2"; }
|
||
fail() { N_FAIL=$((N_FAIL+1)); RESULTS+=("FAIL|$1|$2"); printf ' %sFAIL%s %-6s %s\n' "$C_BAD" "$C_OFF" "$1" "$2"; }
|
||
skip() { N_SKIP=$((N_SKIP+1)); RESULTS+=("SKIP|$1|$2"); printf ' %sSKIP%s %-6s %s\n' "$C_WARN" "$C_OFF" "$1" "$2"; }
|
||
info() { printf ' %s\n' "$*"; }
|
||
head2() { printf '\n%s\n' "$*"; }
|
||
|
||
# --- capability readers ---------------------------------------------------------------------------
|
||
|
||
# The canonical capability string of a FILE, or the marker `?` when it genuinely could not be read.
|
||
# `?` is never treated as "none" — a blind read that reports "no capability" would wave through the
|
||
# exact state V1 exists to catch.
|
||
file_caps() {
|
||
local f="$1" raw
|
||
[ -e "$f" ] || { printf '?'; return 1; }
|
||
if command -v getcap >/dev/null 2>&1; then
|
||
raw="$(getcap "$f" 2>/dev/null)"
|
||
# getcap prints nothing at all for a file with no capability, and exits 0 — so an empty read
|
||
# here is a real "none", not a failed read.
|
||
if [ -z "$raw" ]; then printf ''; return 0; fi
|
||
caps_norm "$(printf '%s' "$raw" | sed -e 's/^[^ ]* //' )"
|
||
return 0
|
||
fi
|
||
if command -v getfattr >/dev/null 2>&1; then
|
||
# Fallback that at least distinguishes present-vs-absent, even though it cannot render flags.
|
||
if getfattr -n security.capability --only-values -- "$f" >/dev/null 2>&1; then
|
||
printf 'cap_present_unreadable'; return 0
|
||
fi
|
||
printf ''; return 0
|
||
fi
|
||
printf '?'; return 1
|
||
}
|
||
|
||
# The PERMITTED capability mask of a live process, as the kernel's 16-hex-digit word, or '' if the
|
||
# field could not be read. /proc/<pid>/status is world-readable and carries CapPrm unconditionally,
|
||
# so this works across a capability boundary where a readlink of /proc/<pid>/exe does not.
|
||
proc_capprm() {
|
||
local pid="$1" line
|
||
line="$(grep -m1 '^CapPrm:' "/proc/$pid/status" 2>/dev/null)"
|
||
[ -n "$line" ] || { printf ''; return 1; }
|
||
printf '%s' "$line" | awk '{print $2}'
|
||
}
|
||
|
||
capprm_is_zero() { case "${1:-}" in ''|*[!0]*) return 1 ;; *) return 0 ;; esac; }
|
||
|
||
# Bit test against a 16-hex-digit mask. Only the low 32 bits are converted, which is both enough
|
||
# (CAP_SYS_NICE is bit 23) and immune to the signed-64-bit overflow a full-width $((16#...)) invites.
|
||
capprm_has_bit() {
|
||
local hex="${1:-}" bit="${2:-0}" low
|
||
[ -n "$hex" ] || return 2
|
||
case "$hex" in *[!0-9a-fA-F]*) return 2 ;; esac
|
||
low="${hex: -8}"
|
||
(( (16#$low >> bit) & 1 ))
|
||
}
|
||
|
||
# --- process discovery ----------------------------------------------------------------------------
|
||
|
||
host_pids() {
|
||
if [ -n "$OPT_HOST_PID" ]; then printf '%s\n' "$OPT_HOST_PID"; return 0; fi
|
||
# -x matches `comm`, which for a normally-exec'd host IS punktfunk-host.
|
||
pgrep -x "$HOST_BIN_NAME" 2>/dev/null
|
||
}
|
||
|
||
# The worker processes spawned by a given host pid.
|
||
#
|
||
# ⚠ `pgrep -x punktfunk-encode-worker` does NOT find them. The host execs the worker through the
|
||
# pinned `/proc/self/fd/<n>` path (so a replaced binary cannot be swapped under a spawn), and the
|
||
# kernel sets `comm` from the basename of the path passed to execve — i.e. a bare number. argv[0]
|
||
# IS `punktfunk-encode-worker`, so a full-cmdline match works — but matching by PARENT is exact and
|
||
# cannot pick up somebody else's worker, which matters a great deal before a `kill -9`.
|
||
worker_pids_of() {
|
||
local parent="$1" p cmd
|
||
for p in $(pgrep -P "$parent" 2>/dev/null); do
|
||
cmd="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null)"
|
||
case "$cmd" in *"$WORKER_BIN_NAME"*) printf '%s\n' "$p" ;; esac
|
||
done
|
||
}
|
||
|
||
fd_count() {
|
||
local pid="$1" n
|
||
n="$(ls "/proc/$pid/fd" 2>/dev/null | wc -l | tr -d ' ')"
|
||
[ -n "$n" ] || { printf ''; return 1; }
|
||
printf '%s' "$n"
|
||
}
|
||
|
||
# --- binary resolution ----------------------------------------------------------------------------
|
||
|
||
DESKTOP_FILE=''
|
||
DESKTOP_EXEC=''
|
||
find_desktop() {
|
||
local d f
|
||
for d in "${XDG_DATA_HOME:-$HOME/.local/share}/applications" \
|
||
/usr/share/applications /usr/local/share/applications \
|
||
/var/lib/flatpak/exports/share/applications; do
|
||
f="$d/io.unom.Punktfunk.Host.desktop"
|
||
if [ -r "$f" ]; then
|
||
DESKTOP_FILE="$f"
|
||
DESKTOP_EXEC="$(sed -n 's/^Exec=//p' "$f" | head -1 | awk '{print $1}')"
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
HOST_BIN=''; HOST_BIN_FROM=''
|
||
WORKER_BIN=''; WORKER_BIN_FROM=''
|
||
resolve_binaries() {
|
||
local pid
|
||
if [ -n "$OPT_HOST_BIN" ]; then
|
||
HOST_BIN="$OPT_HOST_BIN"; HOST_BIN_FROM='--host-bin'
|
||
else
|
||
for pid in $(host_pids); do
|
||
HOST_BIN="$(readlink -f "/proc/$pid/exe" 2>/dev/null)"
|
||
[ -n "$HOST_BIN" ] && { HOST_BIN_FROM="the running host, pid $pid"; break; }
|
||
done
|
||
if [ -z "$HOST_BIN" ] && find_desktop && [ -n "$DESKTOP_EXEC" ]; then
|
||
HOST_BIN="$DESKTOP_EXEC"; HOST_BIN_FROM="$DESKTOP_FILE (Exec=)"
|
||
fi
|
||
if [ -z "$HOST_BIN" ]; then
|
||
HOST_BIN="$(command -v "$HOST_BIN_NAME" 2>/dev/null)"
|
||
[ -n "$HOST_BIN" ] && HOST_BIN_FROM='PATH'
|
||
fi
|
||
fi
|
||
# Absolute, always: these strings become argv[0] of a spawned process, and `env ./relative` is a
|
||
# PATH lookup that fails rather than the file the operator meant.
|
||
[ -n "$HOST_BIN" ] && HOST_BIN="$(readlink -f "$HOST_BIN" 2>/dev/null || printf '%s' "$HOST_BIN")"
|
||
find_desktop >/dev/null 2>&1
|
||
|
||
# Mirror the host's own resolution order (pyrowave_remote::resolve_worker_path).
|
||
if [ -n "$OPT_WORKER_BIN" ]; then
|
||
WORKER_BIN="$OPT_WORKER_BIN"; WORKER_BIN_FROM='--worker-bin'
|
||
elif [ -n "${PUNKTFUNK_ENCODE_WORKER:-}" ] && \
|
||
[ "$(printf '%s' "${PUNKTFUNK_ENCODE_WORKER}" | tr 'A-Z' 'a-z')" != off ]; then
|
||
WORKER_BIN="$PUNKTFUNK_ENCODE_WORKER"; WORKER_BIN_FROM='$PUNKTFUNK_ENCODE_WORKER'
|
||
elif [ -n "$HOST_BIN" ] && [ -f "$(dirname "$HOST_BIN")/$WORKER_BIN_NAME" ]; then
|
||
WORKER_BIN="$(dirname "$HOST_BIN")/$WORKER_BIN_NAME"; WORKER_BIN_FROM='beside the host binary'
|
||
else
|
||
WORKER_BIN="$(command -v "$WORKER_BIN_NAME" 2>/dev/null)"
|
||
[ -n "$WORKER_BIN" ] && WORKER_BIN_FROM='PATH'
|
||
fi
|
||
[ -n "$WORKER_BIN" ] && WORKER_BIN="$(readlink -f "$WORKER_BIN" 2>/dev/null || printf '%s' "$WORKER_BIN")"
|
||
return 0
|
||
}
|
||
|
||
# --- log assertions --------------------------------------------------------------------------------
|
||
|
||
# EVERY log read goes through this. `tracing`'s fmt layer emits SGR escapes around field NAMES, so a
|
||
# real line reads `p99_us\e[0m\e[2m=\e[0m4601` — the message text is plain but `key=value` is not.
|
||
# That is invisible in a hand-written fixture and fatal in the field: it cost the first real V3a run
|
||
# on .25, where both arms encoded 2700 frames perfectly and the kit reported "fewer than 3 usable
|
||
# perf windows" because `p99_us=\([0-9]*\)` could not match. Anything matching a FIELD (priority=,
|
||
# p99_us=, reason=) breaks without this; anything matching prose only got lucky.
|
||
# The spike is also launched with NO_COLOR=1 so fresh logs are plain — this strips the rest
|
||
# (journalctl, an operator's own capture, a log from an older build).
|
||
strip_ansi() { LC_ALL=C sed -E $'s/\x1b\\[[0-9;]*[a-zA-Z]//g'; }
|
||
log_cat() { [ -r "$1" ] && LC_ALL=C strip_ansi <"$1"; }
|
||
|
||
logs_have() { log_cat "$1" | LC_ALL=C grep -qF -- "$2"; }
|
||
logs_first() { log_cat "$1" | LC_ALL=C grep -m1 -F -- "$2"; }
|
||
# Always a number, even for a log that does not exist — a bare `grep -c` prints nothing on a missing
|
||
# file and `[ "" -ge 1 ]` is a bash syntax error, not a failed assertion. (The default has to be
|
||
# applied in the SHELL: `sed 's/^$/0/'` sees no line at all on empty input and emits nothing.)
|
||
logs_count() {
|
||
local n; n="$(log_cat "$1" | LC_ALL=C grep -cF -- "$2" | tr -dc '0-9')"
|
||
printf '%s' "${n:-0}"
|
||
}
|
||
perf_windows() { logs_count "$1" "$L_PERF"; }
|
||
# Perf windows that appear AFTER a marker line. This is what "the stream survived" actually means
|
||
# for the chaos leg: a warn followed by silence is a dead session that logged politely.
|
||
perf_windows_after() {
|
||
log_cat "$1" | LC_ALL=C awk -v m="$2" -v p="$L_PERF" \
|
||
'index($0,m){seen=1; next} seen && index($0,p){n++} END{print n+0}'
|
||
}
|
||
|
||
# The single most important assertion in the measured legs: prove the arm is the arm it claims.
|
||
#
|
||
# A PyroWave session that receives a CPU frame pins itself in-process for the rest of the session
|
||
# (a socket copy of 1080p BGRA is ~480 MB/s), so a `cpu → pyrowave` capture arm silently turns the
|
||
# "worker" arm into a second in-process arm — an A/B of inline against inline that PASSES the gate
|
||
# for entirely the wrong reason. This refuses that.
|
||
assert_worker_arm() {
|
||
local log="$1" tag="$2" rc=0
|
||
if ! logs_have "$log" "$L_GRANTED" && ! logs_have "$log" "$L_NOPRIO" && ! logs_have "$log" "$L_INERT_WORKER"; then
|
||
info "$tag: no 'capability-carrying worker' line — the worker never took the session"; rc=1
|
||
fi
|
||
if logs_have "$log" "$L_FALLBACK"; then
|
||
info "$tag: a fallback rung fired: $(logs_first "$log" "$L_FALLBACK" | sed 's/^.*pyrowave: /pyrowave: /')"; rc=1
|
||
fi
|
||
if logs_have "$log" "$L_LEAVING"; then
|
||
info "$tag: the session LEFT the worker mid-run: $(logs_first "$log" "$L_LEAVING" | sed 's/^.*pyrowave: /pyrowave: /')"; rc=1
|
||
fi
|
||
if logs_have "$log" "$L_CAPTURE" && ! log_cat "$log" | LC_ALL=C grep -F -- "$L_CAPTURE" | grep -q 'dmabuf-passthrough'; then
|
||
info "$tag: capture arm is not dmabuf-passthrough — $(logs_first "$log" "$L_CAPTURE" | sed 's/^.*capture pipeline/capture pipeline/')"
|
||
info "$tag: a non-dmabuf frame pins the session in-process, so this arm is NOT the worker."
|
||
rc=1
|
||
fi
|
||
return $rc
|
||
}
|
||
|
||
# Why an arm produced nothing — checked BEFORE any arm-identity assert.
|
||
#
|
||
# "this arm is not the in-process arm" is a LIE when the truth is "capture never came up", and it
|
||
# sends the reader looking at the wrong thing entirely. That cost a real V3b run on .21: a GNOME
|
||
# consent dialog went unanswered, the spike died before opening an encoder, and the kit blamed the
|
||
# arm construction — which was correct all along. Prints a human reason and returns 0 when the arm
|
||
# never got far enough to be judged; returns 1 when the run is judgeable.
|
||
spike_failure_reason() {
|
||
local log="$1" line
|
||
if logs_have "$log" 'timed out waiting for the ScreenCast portal'; then
|
||
printf '%s' "the xdg ScreenCast portal was never answered. GNOME and KDE raise a CONSENT DIALOG on the host's own screen, and it cannot be answered from inside a stream — approve it there, or run a leg that needs no portal"
|
||
return 0
|
||
fi
|
||
line="$(log_cat "$log" | LC_ALL=C grep -E 'ERROR' | head -1 | sed 's/^.*ERROR[[:space:]]*//')"
|
||
if [ -n "$line" ]; then printf '%s' "$line"; return 0; fi
|
||
if [ "$(perf_windows "$log")" = 0 ]; then
|
||
printf '%s' "the spike never encoded a frame — no PUNKTFUNK_PERF window in the log at all"
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
assert_inline_arm() {
|
||
local log="$1" tag="$2" rc=0
|
||
if ! logs_have "$log" "$L_OFF"; then
|
||
info "$tag: no 'PUNKTFUNK_ENCODE_WORKER=off' line — this arm did not force the in-process path"; rc=1
|
||
fi
|
||
if logs_have "$log" "$L_GRANTED" || logs_have "$log" "$L_NOPRIO"; then
|
||
info "$tag: a 'capability-carrying worker' line is present — this arm is not in-process"; rc=1
|
||
fi
|
||
return $rc
|
||
}
|
||
|
||
# --- the perf instrument ----------------------------------------------------------------------------
|
||
#
|
||
# `PUNKTFUNK_PERF` makes the PyroWave encoder emit one summary line every ~2 s over >=30 frames:
|
||
# frames=30 mean_us=… p50_us=… p99_us=… max_us=… depth=… pyrowave encode, submit->AU (…)
|
||
# The measured quantity is submit→AU: CSC + encode + fence wait + packetize. Per PW1 the FIRST
|
||
# window is warm-up and is dropped, and `encode_fps` is a VACUOUS metric here (a starved capture
|
||
# makes both arms report the capture rate) — latency is the whole point.
|
||
#
|
||
# In worker mode the line comes from the WORKER process on inherited stderr; in-process it comes
|
||
# from the host. Either way it lands in the log this kit captures.
|
||
perf_p99_series() { log_cat "$1" | LC_ALL=C grep -F -- "$L_PERF" | sed -n 's/.*p99_us=\([0-9][0-9]*\).*/\1/p'; }
|
||
perf_p50_series() { log_cat "$1" | LC_ALL=C grep -F -- "$L_PERF" | sed -n 's/.*p50_us=\([0-9][0-9]*\).*/\1/p'; }
|
||
|
||
# LC_ALL=C on every awk here is load-bearing, not decoration: under a comma-decimal locale awk
|
||
# PRINTS "6,40" for a millisecond figure and, worse, READS "1.0" as 1 — which would silently turn
|
||
# the --gate-ms 1.0 abandonment gate into a 1 µs gate. Numbers in this kit are C-locale throughout.
|
||
median_us() {
|
||
LC_ALL=C sort -n | LC_ALL=C awk 'NF{a[++n]=$1} END{ if(!n) exit 1; if(n%2) print a[(n+1)/2]; else print int((a[n/2]+a[n/2+1])/2) }'
|
||
}
|
||
ms() { LC_ALL=C awk -v u="${1:-0}" 'BEGIN{ printf "%.2f", u/1000 }'; }
|
||
|
||
# Median p99 (and p50) across all windows after the warm-up one. Prints "p50_us p99_us windows",
|
||
# or nothing when there is not enough data to mean anything.
|
||
perf_summary() {
|
||
local log="$1" min_windows="${2:-3}" p99s p50s n m99 m50
|
||
p99s="$(perf_p99_series "$log" | tail -n +2)"
|
||
p50s="$(perf_p50_series "$log" | tail -n +2)"
|
||
n="$(printf '%s\n' "$p99s" | grep -c '[0-9]')"
|
||
[ "$n" -ge "$min_windows" ] 2>/dev/null || return 1
|
||
m99="$(printf '%s\n' "$p99s" | median_us)" || return 1
|
||
m50="$(printf '%s\n' "$p50s" | median_us)" || return 1
|
||
printf '%s %s %s' "$m50" "$m99" "$n"
|
||
}
|
||
|
||
# --- the spike vehicle -------------------------------------------------------------------------------
|
||
|
||
LOG_DIR=''
|
||
setup_logdir() {
|
||
[ -n "$LOG_DIR" ] && return 0
|
||
if [ -n "$OPT_LOG_DIR" ]; then LOG_DIR="$OPT_LOG_DIR"; mkdir -p "$LOG_DIR"
|
||
else LOG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pf-wp3.XXXXXX")"; fi
|
||
return 0
|
||
}
|
||
cleanup() {
|
||
# Never leave a spike (and through PR_SET_PDEATHSIG, its worker) running because the kit was
|
||
# interrupted — a stray capture session holds a virtual output and a GPU context.
|
||
stop_spike
|
||
[ -n "$LOG_DIR" ] || return 0
|
||
if [ "$OPT_KEEP_LOGS" = 1 ] || [ -n "$OPT_LOG_DIR" ]; then
|
||
printf '\nlogs kept in %s\n' "$LOG_DIR"
|
||
else
|
||
rm -rf "$LOG_DIR"
|
||
fi
|
||
}
|
||
trap cleanup EXIT
|
||
trap 'printf "\ninterrupted\n" >&2; exit 130' INT TERM
|
||
|
||
SPIKE_SOURCE=''
|
||
resolve_source() {
|
||
[ -n "$SPIKE_SOURCE" ] && return 0
|
||
if [ -n "$OPT_SOURCE" ]; then SPIKE_SOURCE="$OPT_SOURCE"; return 0; fi
|
||
case "$(compositor_guess)" in
|
||
kwin) SPIKE_SOURCE='kwin-virtual' ;;
|
||
*) SPIKE_SOURCE='portal' ;;
|
||
esac
|
||
}
|
||
|
||
compositor_guess() {
|
||
# Cheap and honest: a name, never a claim of readiness (probe-compositor is the readiness check).
|
||
if pgrep -x kwin_wayland >/dev/null 2>&1; then printf 'kwin'; return 0; fi
|
||
case "${XDG_CURRENT_DESKTOP:-}" in *KDE*|*kde*) printf 'kwin'; return 0 ;; esac
|
||
if pgrep -x gnome-shell >/dev/null 2>&1; then printf 'mutter'; return 0; fi
|
||
if pgrep -x sway >/dev/null 2>&1; then printf 'sway'; return 0; fi
|
||
if pgrep -x gamescope >/dev/null 2>&1; then printf 'gamescope'; return 0; fi
|
||
printf 'unknown'
|
||
}
|
||
|
||
# run_spike <arm-name> <wall-seconds> <env-assignment>...
|
||
#
|
||
# Launches `punktfunk-host spike --codec pyrowave` with PUNKTFUNK_PERF armed, captures the combined
|
||
# stderr of the host AND its worker (one inherited stream), waits at most `wall + grace`, then
|
||
# terminates it. Echoes the log path. Returns non-zero only if the spike could not be started.
|
||
#
|
||
# Output goes to /dev/null: a 10-minute 1080p PyroWave dump is ~1.5 GB and nothing here reads the
|
||
# bitstream. The loopback is off by default — less CPU noise in a latency A/B.
|
||
SPIKE_LOG=''
|
||
SPIKE_PID=''
|
||
run_spike() {
|
||
local arm="$1" wall="$2"; shift 2
|
||
local log="$LOG_DIR/$arm.log" kv
|
||
SPIKE_LOG="$log"; SPIKE_PID=''
|
||
# NO_COLOR: keep the captured log plain at the source. `log_cat` strips SGR escapes anyway, but a
|
||
# plain log is also the one a human greps by hand when a leg goes red, and `p99_us\e[0m\e[2m=…`
|
||
# defeats the obvious grep just as thoroughly as it defeated this kit's parser.
|
||
#
|
||
# PUNKTFUNK_ENCODER=pyrowave is NOT redundant with `--codec pyrowave`, and leaving it out cost a
|
||
# real V3b run on .21. `--codec` selects the ENCODER; the capture pipeline picks its consumer from
|
||
# `ZeroCopyPolicy::pyrowave_session`, which on the spike path is fed only by this env (see
|
||
# punktfunk-host/src/capture.rs — "the global PUNKTFUNK_ENCODER=pyrowave lab lever"). Without it an
|
||
# NVIDIA host resolves `cuda-import -> nvenc`, imports the dmabuf to CUDA as NV12, and hands the
|
||
# wavelet encoder a payload it rejects: "unsupported FramePayload (need Dmabuf or Cpu RGB)". AMD
|
||
# boxes hide this — they have no CUDA arm to pick — so it reproduces only where the A/B lives.
|
||
local -a envs=(PUNKTFUNK_PERF=1 NO_COLOR=1 PUNKTFUNK_ENCODER=pyrowave)
|
||
for kv in "$@"; do envs+=("$kv"); done
|
||
local -a cmd=("$HOST_BIN" spike --codec pyrowave --source "$SPIKE_SOURCE"
|
||
--width "$OPT_WIDTH" --height "$OPT_HEIGHT" --fps "$OPT_FPS"
|
||
--bitrate "$OPT_BITRATE" --seconds "$wall" --out /dev/null)
|
||
[ "$OPT_LOOPBACK" = 1 ] || cmd+=(--no-loopback)
|
||
{
|
||
printf '### %s\n### env: %s\n### cmd: %s\n' "$arm" "${envs[*]}" "${cmd[*]}"
|
||
} > "$log"
|
||
env "${envs[@]}" "${cmd[@]}" >>"$log" 2>&1 &
|
||
SPIKE_PID=$!
|
||
sleep 1
|
||
kill -0 "$SPIKE_PID" 2>/dev/null || {
|
||
# It may have finished legitimately (unlikely in 1 s) or died on open. Let the caller's
|
||
# assertions decide; report the start as OK either way.
|
||
wait "$SPIKE_PID" 2>/dev/null
|
||
SPIKE_PID=''
|
||
return 0
|
||
}
|
||
return 0
|
||
}
|
||
|
||
# Wait out an arm: the spike's own `--seconds` budget plus a grace, then stop it regardless. The
|
||
# grace matters because a frame-starved capture (PW1 saw ~2.5 fps under load) will never reach its
|
||
# frame target — the wall clock, not the frame count, is what bounds a leg.
|
||
wait_spike() {
|
||
local wall=$(( $1 + 15 )) waited=0
|
||
[ -n "$SPIKE_PID" ] || return 0
|
||
while [ "$waited" -lt "$wall" ]; do
|
||
kill -0 "$SPIKE_PID" 2>/dev/null || break
|
||
sleep 1; waited=$((waited+1))
|
||
done
|
||
stop_spike
|
||
}
|
||
|
||
stop_spike() {
|
||
[ -n "$SPIKE_PID" ] || return 0
|
||
if kill -0 "$SPIKE_PID" 2>/dev/null; then
|
||
kill -TERM "$SPIKE_PID" 2>/dev/null
|
||
local t=0
|
||
while [ "$t" -lt 5 ] && kill -0 "$SPIKE_PID" 2>/dev/null; do sleep 1; t=$((t+1)); done
|
||
kill -KILL "$SPIKE_PID" 2>/dev/null
|
||
fi
|
||
wait "$SPIKE_PID" 2>/dev/null
|
||
SPIKE_PID=''
|
||
}
|
||
|
||
# An UNCAPPED copy of the worker binary, in a temp dir. This is how the kit gets a default-priority
|
||
# worker without touching the box: a plain `cp` does NOT carry the `security.capability` xattr, so
|
||
# the copy is uncapped by construction — no setcap, no sudo, nothing restored afterwards. The copy
|
||
# is VERIFIED uncapped before use; an unverifiable copy is refused, never assumed.
|
||
#
|
||
# ⚠ It is also the only arm immune to R4: the real capped worker is AT_SECURE, so the dynamic loader
|
||
# ignores LD_LIBRARY_PATH/LD_PRELOAD. On a box propped up by a loader shim (the .21 ffmpeg-9
|
||
# workaround) the copy will start and the capped original will not — a v3a PASS beside a v3b
|
||
# spawn failure is that, not a regression.
|
||
UNCAPPED_COPY=''
|
||
make_uncapped_copy() {
|
||
[ -n "$UNCAPPED_COPY" ] && return 0
|
||
[ -n "$WORKER_BIN" ] && [ -x "$WORKER_BIN" ] || return 1
|
||
setup_logdir
|
||
local dst="$LOG_DIR/uncapped-$WORKER_BIN_NAME"
|
||
cp -- "$WORKER_BIN" "$dst" 2>/dev/null || return 1
|
||
chmod 0755 "$dst" 2>/dev/null
|
||
local c; c="$(file_caps "$dst")"
|
||
case "$c" in
|
||
'') : ;; # verified uncapped — what we want
|
||
'?') return 2 ;; # could not read: refuse rather than assume
|
||
*) return 3 ;; # the copy carried the capability (cp --preserve=xattr?): refuse
|
||
esac
|
||
# A noexec /tmp would make this arm fail for a reason that has nothing to do with the worker.
|
||
"$dst" >/dev/null 2>&1
|
||
case $? in 126|127) return 4 ;; esac
|
||
UNCAPPED_COPY="$dst"
|
||
return 0
|
||
}
|
||
|
||
# ==================================================================================================
|
||
# LEGS
|
||
# ==================================================================================================
|
||
|
||
leg_inspect() {
|
||
head2 "INSPECT — the capability matrix on this box (read-only)"
|
||
|
||
# --- I1 the worker binary --------------------------------------------------------------------
|
||
if [ -z "$WORKER_BIN" ]; then
|
||
skip I1 "no $WORKER_BIN_NAME found (env, beside the host, PATH) — the host encodes in-process"
|
||
info "at default GPU priority. That is a legitimate best-effort state, not a failure; it is"
|
||
info "also why every grant leg below will skip."
|
||
elif [ ! -x "$WORKER_BIN" ]; then
|
||
fail I1 "$WORKER_BIN exists but is not executable"
|
||
else
|
||
pass I1 "worker binary: $WORKER_BIN ($WORKER_BIN_FROM)"
|
||
fi
|
||
|
||
# --- I2 the host binary carries NO capability — the whole point ------------------------------
|
||
if [ -z "$HOST_BIN" ]; then
|
||
fail I2 "could not determine the host binary — pass --host-bin. Refusing to report a PASS from"
|
||
info "a check that read nothing."
|
||
elif [ ! -e "$HOST_BIN" ]; then
|
||
fail I2 "host binary $HOST_BIN does not exist"
|
||
else
|
||
local hc; hc="$(file_caps "$HOST_BIN")"
|
||
case "$hc" in
|
||
'') pass I2 "host binary carries NO capability: $HOST_BIN" ;;
|
||
'?') fail I2 "could not read capabilities of $HOST_BIN (no getcap/getfattr) — a blind check"
|
||
info "cannot clear the host, and this is the assertion the leg exists for." ;;
|
||
*) fail I2 "THE 0.26.0-1 REGRESSION: $HOST_BIN carries '$hc'."
|
||
info "KWin cannot readlink /proc/<pid>/exe of a capability-carrying process, so it"
|
||
info "cannot identify the host, never advertises zkde_screencast_unstable_v1, and every"
|
||
info "KDE desktop session loses streaming. Repair: sudo setcap -r '$HOST_BIN'"
|
||
info "The GPU-priority grant belongs on $WORKER_BIN_NAME and nowhere else." ;;
|
||
esac
|
||
fi
|
||
|
||
# --- I3 the worker's capability ----------------------------------------------------------------
|
||
if [ -n "$WORKER_BIN" ] && [ -e "$WORKER_BIN" ]; then
|
||
local wc; wc="$(file_caps "$WORKER_BIN")"
|
||
case "$wc" in
|
||
"$WANT_WORKER_CAPS") pass I3 "worker carries exactly $WANT_WORKER_CAPS — the lever is live" ;;
|
||
'') skip I3 "worker carries NO capability — the GPU-preemption lever is INERT here."
|
||
info "Legitimate for a source build; the packaging channels grant it at install time."
|
||
info "By hand: sudo setcap 'cap_sys_nice=ep' '$WORKER_BIN' (a REBUILD is a new inode"
|
||
info "and drops it again). v2/v3b need this; v3a deliberately does not." ;;
|
||
'?') fail I3 "could not read capabilities of $WORKER_BIN — refusing to guess" ;;
|
||
*) fail I3 "worker carries '$wc', expected exactly '$WANT_WORKER_CAPS' — over-granting a"
|
||
info "capability-carrying binary is its own hazard; fix the packaging channel." ;;
|
||
esac
|
||
else
|
||
skip I3 "no worker binary to inspect"
|
||
fi
|
||
|
||
# --- I4 host and worker are DIFFERENT files ------------------------------------------------------
|
||
# The plan's non-negotiable: never a hardlink, never a subcommand. A shared inode shares the file
|
||
# capability and silently re-creates 0.26.0-1.
|
||
if [ -n "$HOST_BIN" ] && [ -n "$WORKER_BIN" ] && [ -e "$HOST_BIN" ] && [ -e "$WORKER_BIN" ]; then
|
||
local hi wi
|
||
hi="$(stat -Lc '%d:%i' "$HOST_BIN" 2>/dev/null)"
|
||
wi="$(stat -Lc '%d:%i' "$WORKER_BIN" 2>/dev/null)"
|
||
if [ -z "$hi" ] || [ -z "$wi" ]; then
|
||
skip I4 "no stat(1) that reports device:inode — cannot prove the two are separate files"
|
||
elif [ "$hi" = "$wi" ]; then
|
||
fail I4 "host and worker are THE SAME INODE ($hi) — a hardlink shares the file capability,"
|
||
info "which puts the capability on the host and re-creates 0.26.0-1."
|
||
else
|
||
pass I4 "host and worker are separate files (inodes $hi / $wi)"
|
||
fi
|
||
else
|
||
skip I4 "need both binaries to compare inodes"
|
||
fi
|
||
|
||
# --- I5 every RUNNING host is uncapped and identifiable ------------------------------------------
|
||
local pids; pids="$(host_pids)"
|
||
if [ -z "$pids" ]; then
|
||
skip I5 "no running $HOST_BIN_NAME — start one (or a session) to check the runtime state"
|
||
else
|
||
local pid ok=1 checked=0
|
||
for pid in $pids; do
|
||
checked=$((checked+1))
|
||
local prm; prm="$(proc_capprm "$pid")"
|
||
if [ -z "$prm" ]; then
|
||
info "pid $pid: no CapPrm in /proc/$pid/status — cannot determine, treating as a failure"; ok=0; continue
|
||
fi
|
||
if ! capprm_is_zero "$prm"; then
|
||
info "pid $pid: CapPrm=$prm — this host process HOLDS capabilities. KWin cannot identify it."; ok=0; continue
|
||
fi
|
||
local owner; owner="$(stat -c '%u' "/proc/$pid" 2>/dev/null)"
|
||
if [ -n "$owner" ] && [ "$owner" != "$(id -u)" ]; then
|
||
info "pid $pid: runs as uid $owner, this shell is uid $(id -u) — the /proc/<pid>/exe read"
|
||
info "pid $pid: is only meaningful from the session user KWin runs as. CapPrm=$prm is clean."
|
||
continue
|
||
fi
|
||
if ! readlink "/proc/$pid/exe" >/dev/null 2>&1; then
|
||
info "pid $pid: readlink /proc/$pid/exe REFUSED although CapPrm is zero — something else"
|
||
info "pid $pid: (Yama ptrace_scope, an LSM) is blocking the read KWin identifies clients by."; ok=0; continue
|
||
fi
|
||
done
|
||
if [ "$ok" = 1 ]; then
|
||
pass I5 "$checked running host process(es): CapPrm all zero, /proc/<pid>/exe readable"
|
||
else
|
||
fail I5 "a running host process is not identifiable — see above"
|
||
fi
|
||
fi
|
||
|
||
# --- I6 the KWin .desktop grant -------------------------------------------------------------------
|
||
if [ -z "$DESKTOP_FILE" ]; then
|
||
skip I6 "no io.unom.Punktfunk.Host.desktop installed — KWin has nothing to match the host"
|
||
info "against, so zkde_screencast_unstable_v1 is never advertised however clean the capability"
|
||
info "state is. On the Deck this file is written by scripts/steamdeck/install.sh; packaged"
|
||
info "installs ship it. This is a MISSING PRECONDITION, not the capability regression."
|
||
else
|
||
local exec_ok=1 iface_ok=1
|
||
[ -n "$HOST_BIN" ] && [ "$DESKTOP_EXEC" != "$HOST_BIN" ] && exec_ok=0
|
||
grep -q '^X-KDE-Wayland-Interfaces=.*zkde_screencast_unstable_v1' "$DESKTOP_FILE" 2>/dev/null || iface_ok=0
|
||
if [ "$exec_ok" = 1 ] && [ "$iface_ok" = 1 ]; then
|
||
pass I6 ".desktop grants the screencast interface to $DESKTOP_EXEC"
|
||
else
|
||
fail I6 "$DESKTOP_FILE does not authorize this host binary"
|
||
[ "$exec_ok" = 0 ] && info "Exec=$DESKTOP_EXEC but the host binary is $HOST_BIN — KWin caches the"
|
||
[ "$exec_ok" = 0 ] && info "grant per EXECUTABLE PATH, so a build run from anywhere else is refused."
|
||
[ "$iface_ok" = 0 ] && info "X-KDE-Wayland-Interfaces does not list zkde_screencast_unstable_v1."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
leg_v1() {
|
||
head2 "V1 — the 0.26.0-1 regression test (KDE desktop session)"
|
||
local comp; comp="$(compositor_guess)"
|
||
if [ "$comp" != kwin ]; then
|
||
skip V1 "compositor is '$comp', not KWin — V1 is the KWin identification gate and cannot run here"
|
||
return 0
|
||
fi
|
||
if [ -z "${WAYLAND_DISPLAY:-}" ]; then
|
||
skip V1 "WAYLAND_DISPLAY is unset — run this from inside the KDE session, on glass"
|
||
return 0
|
||
fi
|
||
if [ -z "$DESKTOP_FILE" ]; then
|
||
skip V1 "precondition missing: no io.unom.Punktfunk.Host.desktop (run scripts/steamdeck/install.sh"
|
||
info "on the Deck, or install a package). Without it KWin cannot advertise the screencast"
|
||
info "global for any reason, which would mask the capability question this leg exists to ask."
|
||
return 0
|
||
fi
|
||
if [ -z "$HOST_BIN" ] || [ ! -x "$HOST_BIN" ]; then
|
||
skip V1 "no runnable host binary"; return 0
|
||
fi
|
||
# The scratch-path trap, stated before it can be mis-debugged: KWin caches its grant per
|
||
# EXECUTABLE PATH against the .desktop's Exec=. Probing a different build is a refusal by design.
|
||
if [ -n "$DESKTOP_EXEC" ] && [ "$DESKTOP_EXEC" != "$HOST_BIN" ]; then
|
||
skip V1 "the installed .desktop authorizes '$DESKTOP_EXEC', but this leg would probe '$HOST_BIN'."
|
||
info "KWin would refuse the screencast global — correctly, because it identifies clients by"
|
||
info "executable path. Run the INSTALLED binary (or pass --host-bin '$DESKTOP_EXEC'); do not"
|
||
info "go debugging KWin."
|
||
return 0
|
||
fi
|
||
|
||
# The static half must already be clean; V1 restates it because a PASS here has to mean the
|
||
# capability state is right, not just that a probe happened to answer.
|
||
local hc; hc="$(file_caps "$HOST_BIN")"
|
||
if [ "$hc" != '' ]; then
|
||
fail V1 "host binary carries '${hc:-?}' — hard fail. See I2."
|
||
return 0
|
||
fi
|
||
|
||
# The instrument: probe-compositor connects as a KWin client from THIS binary and exits 0 only if
|
||
# zkde_screencast_unstable_v1 was actually advertised to it. That is the identification path
|
||
# end-to-end — the readlink, the .desktop match and the global — in one exit code.
|
||
setup_logdir
|
||
local plog="$LOG_DIR/v1-probe.log"
|
||
"$HOST_BIN" probe-compositor >"$plog" 2>&1
|
||
local prc=$?
|
||
if [ "$prc" != 0 ]; then
|
||
fail V1 "probe-compositor exited $prc — KWin did not advertise the screencast global"
|
||
if LC_ALL=C grep -qF -- "$L_KWIN_DENIED" "$plog"; then
|
||
info "$(LC_ALL=C grep -m1 -F -- "$L_KWIN_DENIED" "$plog" | cut -c1-200)"
|
||
fi
|
||
info "If the message mentions CapPrm, this IS the 0.26.0-1 regression. If it does not, the"
|
||
info ".desktop is missing/stale or KWin has cached a grant for a different path (re-login)."
|
||
return 0
|
||
fi
|
||
|
||
# …and a real capture+encode pass over that same grant, which is the "stream up" half.
|
||
resolve_source
|
||
run_spike v1-stream "$OPT_SECONDS"
|
||
wait_spike "$OPT_SECONDS"
|
||
local frames; frames="$(perf_windows "$SPIKE_LOG")"
|
||
if [ "${frames:-0}" -lt 1 ]; then
|
||
fail V1 "probe passed but the spike produced no encode windows — see $SPIKE_LOG"
|
||
return 0
|
||
fi
|
||
pass V1 "host uncapped, /proc/<pid>/exe readable, zkde_screencast_unstable_v1 advertised, stream up"
|
||
info "source=$SPIKE_SOURCE, $frames perf window(s). A client session on glass is the human half."
|
||
}
|
||
|
||
leg_v2() {
|
||
head2 "V2 — grant: the worker reports an elevated global queue priority"
|
||
if [ -z "$WORKER_BIN" ] || [ ! -x "$WORKER_BIN" ]; then
|
||
skip V2 "no worker binary"; return 0
|
||
fi
|
||
local wc; wc="$(file_caps "$WORKER_BIN")"
|
||
if [ "$wc" != "$WANT_WORKER_CAPS" ]; then
|
||
skip V2 "the worker carries '${wc:-<none>}', not $WANT_WORKER_CAPS — every driver refuses every"
|
||
info "elevated class without it, so a REFUSED result here would say nothing about the driver."
|
||
return 0
|
||
fi
|
||
[ -n "$HOST_BIN" ] && [ -x "$HOST_BIN" ] || { skip V2 "no runnable host binary"; return 0; }
|
||
resolve_source
|
||
setup_logdir
|
||
# The intent is pinned rather than inherited: a host.env or shell with PYROWAVE_QUEUE_PRIORITY=off
|
||
# in it would produce "no queue priority requested" and read as a failed grant.
|
||
run_spike v2-grant "$OPT_SECONDS" "PUNKTFUNK_ENCODE_WORKER=$WORKER_BIN" "PYROWAVE_QUEUE_PRIORITY=realtime"
|
||
wait_spike "$OPT_SECONDS"
|
||
local log="$SPIKE_LOG"
|
||
|
||
if why="$(spike_failure_reason "$log")"; then fail V2 "the run never got as far as an encoder: $why"; return 0; fi
|
||
if ! assert_worker_arm "$log" V2; then
|
||
fail V2 "the session did not run in the worker — see $log"; return 0
|
||
fi
|
||
if logs_have "$log" "$L_INERT_WORKER"; then
|
||
fail V2 "every global priority class was REFUSED even though the worker is capped."
|
||
info "Either the driver does not honour VK_KHR_global_priority here, or the capability did not"
|
||
info "survive to the running process. Check: $(logs_first "$log" "$L_WORKER_READY" | cut -c1-160)"
|
||
return 0
|
||
fi
|
||
local line; line="$(logs_first "$log" "$L_GRANTED")"
|
||
if [ -z "$line" ]; then
|
||
fail V2 "no grant line. Closest match: $(logs_first "$log" "$L_NOPRIO" | cut -c1-160)"
|
||
info "PYROWAVE_QUEUE_PRIORITY=off would produce exactly that; unset it for this leg."
|
||
return 0
|
||
fi
|
||
case "$line" in
|
||
*priority=Realtime*) pass V2 "Ready.granted = Realtime ($(printf '%s' "$line" | sed -n 's/.*device=\([^ ]*\).*/device=\1/p'))" ;;
|
||
*priority=High*) fail V2 "granted HIGH, not REALTIME — the plan's criterion is REALTIME."
|
||
info "A driver that caps at HIGH is a finding, not a kit failure; record it." ;;
|
||
*) fail V2 "grant line carries no recognisable priority= field: $(printf '%s' "$line" | cut -c1-200)" ;;
|
||
esac
|
||
if logs_have "$log" "$L_WORKER_READY"; then
|
||
info "worker: $(logs_first "$log" "$L_WORKER_READY" | sed 's/^.* INFO //' | cut -c1-180)"
|
||
fi
|
||
}
|
||
|
||
# The shared A/B engine. $1 label, $2 arm-A log, $3 arm-B log, $4 gate µs (empty = report only).
|
||
ab_report() {
|
||
local tag="$1" a_log="$2" a_name="$3" b_log="$4" b_name="$5" gate_us="${6:-}"
|
||
local a b a50 a99 an b50 b99 bn
|
||
a="$(perf_summary "$a_log")" || { fail "$tag" "$a_name: fewer than 3 usable perf windows — the"
|
||
info "capture may be starved, or PUNKTFUNK_PERF did not arm. Log: $a_log"; return 1; }
|
||
b="$(perf_summary "$b_log")" || { fail "$tag" "$b_name: fewer than 3 usable perf windows. Log: $b_log"; return 1; }
|
||
read -r a50 a99 an <<<"$a"
|
||
read -r b50 b99 bn <<<"$b"
|
||
info "$(printf '%-28s p50 %6s ms p99 %6s ms (%s windows)' "$a_name" "$(ms "$a50")" "$(ms "$a99")" "$an")"
|
||
info "$(printf '%-28s p50 %6s ms p99 %6s ms (%s windows)' "$b_name" "$(ms "$b50")" "$(ms "$b99")" "$bn")"
|
||
local delta=$((b99 - a99))
|
||
info "$(printf 'p99 delta (%s - %s): %s ms' "$b_name" "$a_name" "$(ms "$delta")")"
|
||
[ -z "$gate_us" ] && return 0
|
||
if [ "$delta" -le "$gate_us" ]; then return 0; fi
|
||
return 2
|
||
}
|
||
|
||
leg_v3a() {
|
||
head2 "V3a — IPC hop cost: in-process vs an UNCAPPED worker (R1's abandonment gate)"
|
||
info "Both arms encode at DEFAULT GPU priority, so the only difference is the process boundary."
|
||
info "The AU crosses via a memfd (pwrite/pread), not a socket copy, so this measures two small"
|
||
info "JSON messages plus one memfd round trip per frame. Needs no capability and no KDE."
|
||
info "⚠ --source synthetic CANNOT be used for this (or any) worker leg: a CPU-backed frame is"
|
||
info "refused by the worker path on purpose (1080p BGRA is ~480 MB/s over the socket) and pins"
|
||
info "the session in-process, so the 'worker' arm would silently be a second in-process arm."
|
||
[ -n "$HOST_BIN" ] && [ -x "$HOST_BIN" ] || { skip V3a "no runnable host binary"; return 0; }
|
||
if [ -z "$WORKER_BIN" ] || [ ! -x "$WORKER_BIN" ]; then skip V3a "no worker binary"; return 0; fi
|
||
setup_logdir
|
||
make_uncapped_copy
|
||
case $? in
|
||
0) : ;;
|
||
2) skip V3a "cannot verify the working copy is uncapped (no getcap) — refusing to measure an arm"
|
||
info "whose priority state is unknown"; return 0 ;;
|
||
3) skip V3a "the working copy carried the capability across — refusing to call it the uncapped arm"; return 0 ;;
|
||
4) skip V3a "the working copy is not executable where it was written (noexec ${TMPDIR:-/tmp}?) —"
|
||
info "re-run with --log-dir pointing somewhere executable"; return 0 ;;
|
||
*) skip V3a "could not copy $WORKER_BIN"; return 0 ;;
|
||
esac
|
||
resolve_source
|
||
|
||
# Arm A: in-process, priority explicitly OFF so the two arms differ in nothing but the boundary.
|
||
run_spike v3a-inline "$OPT_SECONDS" "PUNKTFUNK_ENCODE_WORKER=off" "PYROWAVE_QUEUE_PRIORITY=off"
|
||
wait_spike "$OPT_SECONDS"
|
||
local a_log="$SPIKE_LOG"
|
||
# Arm B: the uncapped worker, same intent.
|
||
run_spike v3a-worker "$OPT_SECONDS" "PUNKTFUNK_ENCODE_WORKER=$UNCAPPED_COPY" "PYROWAVE_QUEUE_PRIORITY=off"
|
||
wait_spike "$OPT_SECONDS"
|
||
local b_log="$SPIKE_LOG"
|
||
|
||
if why="$(spike_failure_reason "$a_log")"; then fail V3a "arm A never ran: $why"; return 0; fi
|
||
if why="$(spike_failure_reason "$b_log")"; then fail V3a "arm B never ran: $why"; return 0; fi
|
||
assert_inline_arm "$a_log" V3a || { fail V3a "arm A is not the in-process arm — see $a_log"; return 0; }
|
||
assert_worker_arm "$b_log" V3a || { fail V3a "arm B is not the worker arm — see $b_log"; return 0; }
|
||
|
||
local gate_us; gate_us="$(LC_ALL=C awk -v m="$OPT_GATE_MS" 'BEGIN{ printf "%d", m*1000 }')"
|
||
ab_report V3a "$a_log" "in-process (default prio)" "$b_log" "uncapped worker" "$gate_us"
|
||
case $? in
|
||
0) pass V3a "the IPC hop costs <= ${OPT_GATE_MS} ms at p99 — R1's abandonment gate does NOT fire" ;;
|
||
2) fail V3a "THE PRE-REGISTERED ABANDONMENT GATE FIRED: the hop costs more than ${OPT_GATE_MS} ms"
|
||
info "at p99. Per the plan, STOP and do the shm-ring AU return before any packaging ships." ;;
|
||
*) : ;; # ab_report already recorded the failure
|
||
esac
|
||
}
|
||
|
||
leg_v3b() {
|
||
head2 "V3b — lever benefit: in-process at default priority vs the CAPPED worker"
|
||
info "PW1's baseline on .21 (RTX 5070 Ti, GRID 2 at 54-87% GPU, 1080p): refused p99 ~6.4 ms,"
|
||
info "REALTIME p99 ~4.4 ms. This leg needs a GPU-BOUND LOAD to mean anything — an idle GPU has"
|
||
info "nothing to preempt and both arms will look identical. Start the game first."
|
||
[ -n "$HOST_BIN" ] && [ -x "$HOST_BIN" ] || { skip V3b "no runnable host binary"; return 0; }
|
||
if [ -z "$WORKER_BIN" ] || [ ! -x "$WORKER_BIN" ]; then skip V3b "no worker binary"; return 0; fi
|
||
local wc; wc="$(file_caps "$WORKER_BIN")"
|
||
if [ "$wc" != "$WANT_WORKER_CAPS" ]; then
|
||
skip V3b "the worker is not capped ($wc) — there is no lever to measure. See I3."; return 0
|
||
fi
|
||
setup_logdir; resolve_source
|
||
|
||
# Arm A is exactly PW1's "refused" arm: in-process, asking for priority and being refused (the
|
||
# host binary is uncapped by construction, so the ladder walks REALTIME -> HIGH -> default).
|
||
run_spike v3b-inline "$OPT_SECONDS" "PUNKTFUNK_ENCODE_WORKER=off" "PYROWAVE_QUEUE_PRIORITY=realtime"
|
||
wait_spike "$OPT_SECONDS"
|
||
local a_log="$SPIKE_LOG"
|
||
run_spike v3b-worker "$OPT_SECONDS" "PUNKTFUNK_ENCODE_WORKER=$WORKER_BIN" "PYROWAVE_QUEUE_PRIORITY=realtime"
|
||
wait_spike "$OPT_SECONDS"
|
||
local b_log="$SPIKE_LOG"
|
||
|
||
if why="$(spike_failure_reason "$a_log")"; then fail V3b "arm A never ran: $why"; return 0; fi
|
||
if why="$(spike_failure_reason "$b_log")"; then fail V3b "arm B never ran: $why"; return 0; fi
|
||
assert_inline_arm "$a_log" V3b || { fail V3b "arm A is not the in-process arm — see $a_log"; return 0; }
|
||
assert_worker_arm "$b_log" V3b || { fail V3b "arm B is not the worker arm — see $b_log"; return 0; }
|
||
if ! logs_have "$b_log" "$L_GRANTED"; then
|
||
fail V3b "arm B did not get a grant — this would be an A/B of two default-priority arms"; return 0
|
||
fi
|
||
if ! logs_have "$a_log" "$L_INERT_HOST"; then
|
||
info "note: arm A did not log the in-process INERT warn; it may already be at default priority"
|
||
info "for another reason (PYROWAVE_QUEUE_PRIORITY=off in this environment?)."
|
||
fi
|
||
|
||
ab_report V3b "$a_log" "in-process, refused" "$b_log" "capped worker, granted"
|
||
case $? in
|
||
0) local a99 b99
|
||
a99="$(perf_summary "$a_log" | awk '{print $2}')"; b99="$(perf_summary "$b_log" | awk '{print $2}')"
|
||
if [ "${b99:-0}" -lt "${a99:-0}" ]; then
|
||
pass V3b "the capped worker's p99 is BELOW the refused arm's — the lever pays for the hop"
|
||
else
|
||
fail V3b "the capped worker's p99 is NOT below the refused arm's. On an idle GPU that is"
|
||
info "expected and the leg is meaningless — re-run under a real GPU-bound load."
|
||
fi ;;
|
||
*) : ;;
|
||
esac
|
||
}
|
||
|
||
leg_v4() {
|
||
head2 "V4 — the fallback ladder: no rung may kill a negotiated session"
|
||
[ -n "$HOST_BIN" ] && [ -x "$HOST_BIN" ] || { skip V4 "no runnable host binary"; return 0; }
|
||
setup_logdir; resolve_source
|
||
local short=$(( OPT_SECONDS < 20 ? OPT_SECONDS : 20 ))
|
||
|
||
# --- rung: the binary is not there. Read-only — a path that does not exist, never a deletion.
|
||
# An operator-set PUNKTFUNK_ENCODE_WORKER is deliberately NOT existence-checked (a named path is
|
||
# entitled to a failure that names it back), so this lands on the SPAWN rung ("did not come up")
|
||
# rather than the not-found one; both are accepted here because both are the same guarantee.
|
||
run_spike v4-missing "$short" "PUNKTFUNK_ENCODE_WORKER=$LOG_DIR/definitely-not-here"
|
||
wait_spike "$short"
|
||
local log="$SPIKE_LOG"
|
||
if logs_have "$log" "$L_NOTUP" || logs_have "$log" "$L_NOTFOUND"; then
|
||
if [ "$(perf_windows "$log")" -ge 1 ]; then
|
||
pass V4.a "a missing worker: one warn, encoding in-process, stream up"
|
||
else
|
||
fail V4.a "the warn fired but nothing encoded — see $log"
|
||
fi
|
||
else
|
||
fail V4.a "no fallback warn for a worker path that does not exist — see $log"
|
||
fi
|
||
|
||
# --- rung: PUNKTFUNK_ENCODE_WORKER=off (the documented escape hatch).
|
||
run_spike v4-off "$short" "PUNKTFUNK_ENCODE_WORKER=off"
|
||
wait_spike "$short"
|
||
log="$SPIKE_LOG"
|
||
if logs_have "$log" "$L_OFF" && [ "$(perf_windows "$log")" -ge 1 ]; then
|
||
pass V4.b "PUNKTFUNK_ENCODE_WORKER=off: one info line, encoding in-process, stream up"
|
||
else
|
||
fail V4.b "the off escape hatch did not behave — see $log"
|
||
fi
|
||
|
||
# --- rung: a worker that starts and immediately dies (spawn ok, handshake never completes).
|
||
if [ -x /bin/false ]; then
|
||
run_spike v4-false "$short" "PUNKTFUNK_ENCODE_WORKER=/bin/false"
|
||
wait_spike "$short"
|
||
log="$SPIKE_LOG"
|
||
if logs_have "$log" "$L_NOTUP" && [ "$(perf_windows "$log")" -ge 1 ]; then
|
||
pass V4.c "a worker that dies at handshake: one warn with error=, in-process, stream up"
|
||
info "$(logs_first "$log" "$L_NOTUP" | sed -n 's/.*\(error=[^ ]*\).*/\1/p' | cut -c1-120)"
|
||
else
|
||
fail V4.c "a /bin/false worker did not produce the handshake-failure rung — see $log"
|
||
fi
|
||
else
|
||
skip V4.c "no /bin/false to stand in for a worker that dies at handshake"
|
||
fi
|
||
|
||
# --- rung: the capability is absent → INERT warn, default priority, stream still up.
|
||
# Uses the uncapped COPY, so nothing on the box is stripped and no sudo is needed.
|
||
if [ -n "$WORKER_BIN" ] && [ -x "$WORKER_BIN" ] && [ "$(file_caps "$WORKER_BIN")" = "$WANT_WORKER_CAPS" ]; then
|
||
make_uncapped_copy
|
||
if [ $? = 0 ]; then
|
||
run_spike v4-inert "$short" "PUNKTFUNK_ENCODE_WORKER=$UNCAPPED_COPY" "PYROWAVE_QUEUE_PRIORITY=realtime"
|
||
wait_spike "$short"
|
||
log="$SPIKE_LOG"
|
||
if logs_have "$log" "$L_INERT" && logs_have "$log" "$L_INERT_WORKER" \
|
||
&& [ "$(perf_windows "$log")" -ge 1 ]; then
|
||
pass V4.d "an uncapped worker: the INERT warn names the WORKER binary, stream still up"
|
||
if logs_have "$log" "$L_INERT_HOST"; then
|
||
fail V4.d2 "the INERT warn ALSO fired with the host-binary wording — that sentence sends"
|
||
info "an operator to setcap the host, which IS the 0.26.0-1 incident. It must not"
|
||
info "double-fire while the worker is active."
|
||
fi
|
||
else
|
||
fail V4.d "an uncapped worker did not produce the INERT warn + a live stream — see $log"
|
||
fi
|
||
else
|
||
skip V4.d "could not produce a verified-uncapped worker copy"
|
||
fi
|
||
else
|
||
skip V4.d "the worker is not capped here, so the INERT arm is already the normal state (see I3)"
|
||
fi
|
||
|
||
# --- rung: kill -9 mid-session. This one genuinely kills a process.
|
||
if [ "$OPT_ALLOW_MUTATE" != 1 ]; then
|
||
skip V4.e "kill -9 of a live worker needs --allow-mutate (it is the one step here that changes"
|
||
info "the state of the box). It kills ONLY a worker that is a child of the spike this script"
|
||
info "started — never a worker belonging to a real session."
|
||
elif [ -z "$WORKER_BIN" ] || [ ! -x "$WORKER_BIN" ]; then
|
||
skip V4.e "no worker binary to kill"
|
||
else
|
||
local kwall=$(( short * 3 ))
|
||
run_spike v4-kill "$kwall" "PUNKTFUNK_ENCODE_WORKER=$WORKER_BIN" "PYROWAVE_QUEUE_PRIORITY=realtime"
|
||
local spike_pid="$SPIKE_PID" wlog="$SPIKE_LOG" wpid='' t=0
|
||
while [ "$t" -lt 30 ] && [ -z "$wpid" ]; do
|
||
wpid="$(worker_pids_of "$spike_pid" | head -1)"
|
||
[ -n "$wpid" ] && break
|
||
sleep 1; t=$((t+1))
|
||
done
|
||
if [ -z "$wpid" ]; then
|
||
stop_spike
|
||
skip V4.e "no worker process appeared as a child of the spike within 30 s — nothing to kill"
|
||
info "(the spike may have fallen back before spawning; check $wlog)"
|
||
else
|
||
sleep 5 # let it encode for a while first, so the death is genuinely mid-session
|
||
kill -9 "$wpid" 2>/dev/null
|
||
info "killed worker pid $wpid (child of spike pid $spike_pid)"
|
||
sleep 8
|
||
local still=1; kill -0 "$spike_pid" 2>/dev/null || still=0
|
||
stop_spike
|
||
local total after
|
||
total="$(perf_windows "$wlog")"; after="$(perf_windows_after "$wlog" "$L_DIED")"
|
||
if ! logs_have "$wlog" "$L_DIED"; then
|
||
# THE VEHICLE, NOT THE LADDER. L_DIED/L_RESPAWNED are emitted by `RemotePyroWave::reset`,
|
||
# and the only caller of `Encoder::reset` is the real session's `reset_stalled_encoder`
|
||
# loop (native/stream.rs). `spike` is a dev tool with no recovery loop at all — it does
|
||
# `encoder.submit(&frame).context("encoder submit")?` and exits — so a worker killed under
|
||
# the spike can never reach reset, and demanding L_DIED here is a FALSE NEGATIVE that
|
||
# reports a shipping blocker for a rung the product implements correctly.
|
||
#
|
||
# What the spike CAN prove, and what is asserted instead: the worker's death is surfaced as
|
||
# an ATTRIBUTABLE proxy error naming the worker — not a hang, not an unexplained failure,
|
||
# and never the host process dying with it. Anything else still fails.
|
||
if { logs_have "$wlog" "$L_PEERGONE" || logs_have "$wlog" "$L_PEERPIPE"; } \
|
||
&& [ "${total:-0}" -ge 1 ]; then
|
||
pass V4.e "kill -9 under the spike: the death surfaces as an attributable worker-IPC error"
|
||
info "after $total encode window(s), and the host process did not die with it."
|
||
info "⚠ THE RESPAWN RUNG IS NOT OBSERVABLE HERE. \`spike\` has no encoder-recovery loop"
|
||
info "(submit errors propagate and it exits), so \`Encoder::reset\` — the only emitter of"
|
||
info "'$L_DIED' — is never called. Verify that half against a REAL session:"
|
||
info " 1. connect a client, then: pkill -f punktfunk-encode-worker"
|
||
info " 2. journalctl --user -u punktfunk-host -b | grep -E 'died mid-session|respawned'"
|
||
info " Confirmed on glass 2026-08-10 (home-nobara-1, KDE, RTX 5070 Ti): stream never"
|
||
info " dropped, 'respawned the encode worker after a mid-session death"
|
||
info " priority=Granted(Realtime)', 'encoder rebuilt in place, forcing an IDR reset=1'."
|
||
else
|
||
fail V4.e "killing pid $wpid produced neither '$L_DIED' nor an attributable worker-IPC"
|
||
info "error — the death was not surfaced at all. See $wlog"
|
||
fi
|
||
elif [ "${after:-0}" -lt 1 ]; then
|
||
fail V4.e "the death was logged but NOTHING encoded afterwards ($total window(s), all before"
|
||
info "the kill) — the session did not actually survive. See $wlog"
|
||
elif logs_have "$wlog" "$L_RESPAWNED"; then
|
||
pass V4.e "kill -9 mid-session: one respawn, then $after more encode window(s) (host alive=$still)"
|
||
elif logs_have "$wlog" "$L_NORESPAWN" || logs_have "$wlog" "$L_FALLBACK"; then
|
||
pass V4.e "kill -9 mid-session: pinned in-process, $after more encode window(s), no dead stream"
|
||
else
|
||
fail V4.e "the death was logged and encoding continued, but neither a respawn nor an"
|
||
info "in-process pin was logged — the ladder took an undeclared path. See $wlog"
|
||
fi
|
||
fi
|
||
fi
|
||
}
|
||
|
||
leg_v5() {
|
||
head2 "V5 — fd hygiene over a long session"
|
||
info "The fd-identity cache passes a dmabuf's fds only on FIRST sight of its buffer key, and the"
|
||
info "PipeWire pool recycles a small set, so steady state passes zero fds. A rising count is R2."
|
||
[ -n "$HOST_BIN" ] && [ -x "$HOST_BIN" ] || { skip V5 "no runnable host binary"; return 0; }
|
||
if [ -z "$WORKER_BIN" ] || [ ! -x "$WORKER_BIN" ]; then skip V5 "no worker binary"; return 0; fi
|
||
setup_logdir; resolve_source
|
||
local wall=$(( OPT_MINUTES * 60 ))
|
||
run_spike v5-fd "$wall" "PUNKTFUNK_ENCODE_WORKER=$WORKER_BIN"
|
||
local spike_pid="$SPIKE_PID" log="$SPIKE_LOG"
|
||
if [ -z "$spike_pid" ]; then skip V5 "the spike did not stay up"; return 0; fi
|
||
|
||
local wpid='' t=0
|
||
while [ "$t" -lt 30 ] && [ -z "$wpid" ]; do
|
||
wpid="$(worker_pids_of "$spike_pid" | head -1)"
|
||
[ -n "$wpid" ] && break
|
||
sleep 1; t=$((t+1))
|
||
done
|
||
if [ -z "$wpid" ]; then
|
||
stop_spike
|
||
skip V5 "no worker child appeared — the session fell back before spawning (see $log)"
|
||
return 0
|
||
fi
|
||
if [ -z "$(fd_count "$wpid")" ]; then
|
||
stop_spike
|
||
skip V5 "cannot read /proc/$wpid/fd. A capability-carrying process is normally opaque, which is"
|
||
info "why the worker sets PR_SET_DUMPABLE(1) at startup — if this fails, that did not take."
|
||
info "Re-run this leg as root to measure anyway, and treat the opacity itself as a finding."
|
||
return 0
|
||
fi
|
||
|
||
# The file capability, cross-checked at RUNTIME on the live worker. /proc/<pid>/status is
|
||
# world-readable and carries CapPrm unconditionally, so this reads across the capability boundary
|
||
# that refuses a /proc/<pid>/exe readlink. (That refusal is EXPECTED here and is not a problem:
|
||
# nothing resolves the worker's exe — it speaks one socketpair to its parent and is not a KWin
|
||
# client. It is only the HOST that must stay readable, which is I5.)
|
||
local wprm; wprm="$(proc_capprm "$wpid")"
|
||
if [ -z "$wprm" ]; then
|
||
info "worker pid $wpid: no CapPrm field to read"
|
||
elif capprm_has_bit "$wprm" "$CAP_SYS_NICE_BIT"; then
|
||
info "worker pid $wpid: CapPrm=$wprm — CAP_SYS_NICE is live in the running process"
|
||
else
|
||
info "worker pid $wpid: CapPrm=$wprm — CAP_SYS_NICE is NOT in the running process's permitted"
|
||
info "set, so the lever is inert however the file looks. A rebuild is a new inode and drops the"
|
||
info "capability; re-run the installer or setcap the worker again."
|
||
fi
|
||
|
||
# Warm up, then sample. The first minute covers pool negotiation and the first sight of every
|
||
# recycled buffer — the identity cache is only expected to be quiet AFTER that.
|
||
local warm=60; [ "$wall" -gt 180 ] || warm=$(( wall / 3 ))
|
||
info "warm-up ${warm}s, then sampling every 15 s for $(( wall - warm ))s (host pid $spike_pid, worker pid $wpid)"
|
||
sleep "$warm"
|
||
local hmin='' hmax='' wmin='' wmax='' hlast='' wlast='' n=0 elapsed="$warm"
|
||
while [ "$elapsed" -lt "$wall" ]; do
|
||
kill -0 "$spike_pid" 2>/dev/null || break
|
||
kill -0 "$wpid" 2>/dev/null || { info "the worker exited at t=${elapsed}s"; break; }
|
||
local h w
|
||
h="$(fd_count "$spike_pid")"; w="$(fd_count "$wpid")"
|
||
if [ -n "$h" ] && [ -n "$w" ]; then
|
||
n=$((n+1)); hlast="$h"; wlast="$w"
|
||
[ -z "$hmin" ] && { hmin="$h"; hmax="$h"; wmin="$w"; wmax="$w"; }
|
||
[ "$h" -lt "$hmin" ] && hmin="$h"; [ "$h" -gt "$hmax" ] && hmax="$h"
|
||
[ "$w" -lt "$wmin" ] && wmin="$w"; [ "$w" -gt "$wmax" ] && wmax="$w"
|
||
printf ' t=%4ss host fd=%-5s worker fd=%s\n' "$elapsed" "$h" "$w"
|
||
fi
|
||
sleep 15; elapsed=$((elapsed+15))
|
||
done
|
||
stop_spike
|
||
|
||
if [ "$n" -lt 4 ]; then
|
||
skip V5 "only $n samples — the session did not stay up long enough to say anything"
|
||
return 0
|
||
fi
|
||
local hg=$((hmax - hmin)) wg=$((wmax - wmin))
|
||
info "host fd min=$hmin max=$hmax last=$hlast growth=$hg"
|
||
info "worker fd min=$wmin max=$wmax last=$wlast growth=$wg"
|
||
if [ "$hg" -le "$OPT_FD_TOLERANCE" ] && [ "$wg" -le "$OPT_FD_TOLERANCE" ]; then
|
||
pass V5 "fd counts stable across ${n} samples over $(( wall - warm ))s (tolerance $OPT_FD_TOLERANCE)"
|
||
else
|
||
fail V5 "fd count grew (host +$hg, worker +$wg) beyond the tolerance $OPT_FD_TOLERANCE — R2."
|
||
info "Compare \`ls -l /proc/$wpid/fd\` at both ends of a run to see WHAT is accumulating."
|
||
fi
|
||
}
|
||
|
||
# Red-teams this kit's own readers against synthetic logs, so the assertions are known to be able to
|
||
# FAIL before anyone trusts a green run on a box. Pure bash + sed/awk: runs anywhere, including a
|
||
# laptop with no GPU, no getcap and no Linux.
|
||
leg_selftest() {
|
||
local fails=0 d; d="$(mktemp -d "${TMPDIR:-/tmp}/pf-wp3-st.XXXXXX")"
|
||
_t() { # _t <label> <want:ok|no> <command...>
|
||
local label="$1" want="$2"; shift 2
|
||
local got=ok; "$@" >/dev/null 2>&1 || got=no
|
||
if [ "$got" = "$want" ]; then printf ' ok %s\n' "$label"
|
||
else printf ' FAIL %s -> %s (wanted %s)\n' "$label" "$got" "$want"; fails=$((fails+1)); fi
|
||
}
|
||
_eq() { # _eq <label> <got> <want>
|
||
if [ "$2" = "$3" ]; then printf ' ok %-46s -> %s\n' "$1" "${2:-<empty>}"
|
||
else printf ' FAIL %-46s -> %s (wanted %s)\n' "$1" "${2:-<empty>}" "$3"; fails=$((fails+1)); fi
|
||
}
|
||
|
||
printf '\nreused canonicalizer (scripts/ci/assert-cap-matrix.sh):\n'
|
||
_eq 'WANT_WORKER_CAPS came across the source' "${WANT_WORKER_CAPS:-}" 'cap_sys_nice=ep'
|
||
_eq 'caps_norm libcap >= 2.36' "$(caps_norm 'cap_sys_nice=ep')" 'cap_sys_nice=ep'
|
||
_eq 'caps_norm older libcap' "$(caps_norm '= cap_sys_nice+ep')" 'cap_sys_nice=ep'
|
||
|
||
printf 'runtime capability masks:\n'
|
||
_t 'CapPrm all zeroes is "no capability"' ok capprm_is_zero '0000000000000000'
|
||
_t 'CapPrm with CAP_SYS_NICE is not zero' no capprm_is_zero '0000000000800000'
|
||
_t 'an empty CapPrm is NOT quietly zero' no capprm_is_zero ''
|
||
_t 'bit 23 set in 0000000000800000' ok capprm_has_bit '0000000000800000' 23
|
||
_t 'bit 23 clear in 0000000000000000' no capprm_has_bit '0000000000000000' 23
|
||
_t 'bit 23 clear in a full root mask minus it' no capprm_has_bit '000001ffff7fffff' 23
|
||
_t 'garbage is an error, not a verdict' no capprm_has_bit 'zzzz' 23
|
||
|
||
printf 'perf parsing (warm-up window dropped, median p99 across the rest):\n'
|
||
local log="$d/perf.log"
|
||
{
|
||
printf '%s\n' " INFO x: frames=30 mean_us=9000 p50_us=9000 p99_us=99000 max_us=99000 depth=1 $L_PERF (CSC…)"
|
||
printf '%s\n' " INFO x: frames=30 mean_us=2600 p50_us=2600 p99_us=6000 max_us=9500 depth=1 $L_PERF (CSC…)"
|
||
printf '%s\n' " INFO x: frames=30 mean_us=2600 p50_us=2800 p99_us=6400 max_us=9500 depth=1 $L_PERF (CSC…)"
|
||
printf '%s\n' " INFO x: frames=30 mean_us=2600 p50_us=3000 p99_us=7000 max_us=9500 depth=1 $L_PERF (CSC…)"
|
||
} > "$log"
|
||
_eq 'median p50/p99/windows, warm-up excluded' "$(perf_summary "$log")" '2800 6400 3'
|
||
_eq 'perf window count' "$(perf_windows "$log")" '4'
|
||
|
||
# A real log is not this clean, and the difference is not cosmetic. `tracing` wraps every field
|
||
# NAME in SGR escapes, so the bytes are `p99_us\e[0m\e[2m=\e[0m6400` and `p99_us=\([0-9]*\)` never
|
||
# matches. The plain fixtures above passed while the kit was structurally unable to read a real
|
||
# log: the first V3a run on .25 encoded 2700 frames in BOTH arms and was reported as "fewer than 3
|
||
# usable perf windows". Same numbers as the plain fixture, same expected answer — the ONLY
|
||
# difference is the escapes, so a regression here can only mean the stripping broke.
|
||
local alog="$d/perf-ansi.log" E=$'\033'
|
||
# One field at a time, deliberately: the first draft of this fixture built the whole line in a
|
||
# single printf and silently emitted 27 placeholders against 23 arguments, so the tail escapes
|
||
# came out empty and the fixture failed for its own reasons rather than the code's.
|
||
_ansi_kv() { printf '%s[3m%s%s[0m%s[2m=%s[0m%s' "$E" "$1" "$E" "$E" "$E" "$2"; }
|
||
_ansi_perf() { # $1 p50_us, $2 p99_us — the exact field shape `tracing` emits
|
||
printf '%s[2m2026-08-09T14:25:07Z%s[0m %s[32m INFO%s[0m %s ' "$E" "$E" "$E" "$E" "$L_PERF"
|
||
_ansi_kv frames 120 ; printf ' '
|
||
_ansi_kv p50_us "$1"; printf ' '
|
||
_ansi_kv p99_us "$2"; printf ' '
|
||
_ansi_kv depth 1 ; printf '\n'
|
||
}
|
||
{ _ansi_perf 9000 99000; _ansi_perf 2600 6000; _ansi_perf 2800 6400; _ansi_perf 3000 7000; } > "$alog"
|
||
_eq 'ANSI-wrapped fields parse identically' "$(perf_summary "$alog")" '2800 6400 3'
|
||
_eq 'ANSI window count' "$(perf_windows "$alog")" '4'
|
||
_eq 'a missing log counts 0, never empty' "$(perf_windows "$d/nope.log")" '0'
|
||
# "the stream survived the kill" is windows AFTER the death line, not windows anywhere.
|
||
local klog="$d/kill.log"
|
||
{ head -2 "$log"; printf '%s\n' " WARN x: pyrowave: $L_DIED"; tail -2 "$log"; } > "$klog"
|
||
_eq 'perf windows after a marker' "$(perf_windows_after "$klog" "$L_DIED")" '2'
|
||
_eq 'a dead-after-kill run scores 0' "$(perf_windows_after "$log" "$L_DIED")" '0'
|
||
: > "$d/thin.log"
|
||
_t 'too few windows is a refusal, not a 0 ms' no perf_summary "$d/thin.log"
|
||
_eq 'microseconds render as ms' "$(ms 6400)" '6.40'
|
||
|
||
printf 'arm identity (the false-PASS this kit exists to refuse):\n'
|
||
local wl="$d/worker.log" il="$d/inline.log" cl="$d/cpu.log"
|
||
{
|
||
printf '%s\n' " INFO x: $L_CAPTURE dmabuf-passthrough → pyrowave"
|
||
printf '%s\n' " INFO x: priority=Realtime device=NVIDIA worker=/usr/bin/punktfunk-encode-worker pyrowave: $L_GRANTED (…)"
|
||
} > "$wl"
|
||
printf '%s\n' " INFO x: $L_OFF — $L_FALLBACK" > "$il"
|
||
# A CPU capture arm: the worker takes the session, then leaves it on the first frame. Every
|
||
# "worker" line is present, so a naive reader calls this the worker arm and A/Bs inline vs inline.
|
||
{
|
||
printf '%s\n' " INFO x: $L_CAPTURE cpu → pyrowave"
|
||
printf '%s\n' " INFO x: priority=Realtime pyrowave: $L_GRANTED (…)"
|
||
printf '%s\n' " WARN x: $L_LEAVING — $L_FALLBACK for the rest of this session"
|
||
} > "$cl"
|
||
_t 'a real worker log IS the worker arm' ok assert_worker_arm "$wl" st
|
||
_t 'a real worker log is NOT the inline arm' no assert_inline_arm "$wl" st
|
||
_t 'an inline log IS the inline arm' ok assert_inline_arm "$il" st
|
||
_t 'an inline log is NOT the worker arm' no assert_worker_arm "$il" st
|
||
_t 'a CPU-capture run is REFUSED as the worker' no assert_worker_arm "$cl" st
|
||
|
||
printf 'log matchers:\n'
|
||
_t 'the shared fallback clause is found' ok logs_have "$il" "$L_FALLBACK"
|
||
_t 'the INERT wordings are distinguishable' no logs_have "$wl" "$L_INERT_WORKER"
|
||
|
||
rm -rf "$d"
|
||
if [ "$fails" != 0 ]; then
|
||
printf '\n %sself-test: %d case(s) wrong — do not trust a run of this kit%s\n' "$C_BAD" "$fails" "$C_OFF"
|
||
return 1
|
||
fi
|
||
printf '\n %sself-test: every reader behaved as specified%s\n' "$C_OK" "$C_OFF"
|
||
return 0
|
||
}
|
||
|
||
leg_recipe() {
|
||
cat <<'EOF'
|
||
|
||
RECIPE — what a human has to do, per box
|
||
========================================
|
||
|
||
Everything below runs ON the box, from a shell inside the graphical session (the KDE legs need
|
||
WAYLAND_DISPLAY set to the KDE socket — an ssh shell has neither that nor XDG_RUNTIME_DIR unless
|
||
you export them).
|
||
|
||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||
export WAYLAND_DISPLAY=wayland-0 # check `ls $XDG_RUNTIME_DIR/wayland-*`
|
||
|
||
⚠ USE THE INSTALLED BINARY. KWin caches its per-executable grant against the path in the installed
|
||
.desktop's Exec=. A build run from a scratch directory is a different path and KWin refuses it —
|
||
that is identification working. This kit resolves the host from the .desktop for exactly that
|
||
reason; only override with --host-bin if you know the installed path.
|
||
|
||
--- every box, first ------------------------------------------------------------------------------
|
||
scripts/validate-encode-worker.sh # pure inspection, changes nothing
|
||
This is also the fastest way to see whether the worker is installed and capped at all.
|
||
|
||
--- Steam Deck (deck@192.168.1.253), DESKTOP mode -- V1 + V2 -------------------------------------
|
||
The Deck is the RADV half of V2 and the KWin×RADV half of V1. /usr is read-only (SteamOS); the
|
||
install is home-scoped under ~/punktfunk, and file capabilities do NOT survive a rebuild (a rebuild
|
||
is a new inode).
|
||
|
||
1. PRIVILEGED, run by a human — sudo on the Deck needs the `deck` password, it is NOT passwordless.
|
||
This also writes ~/.local/share/applications/io.unom.Punktfunk.Host.desktop, which V1 needs and
|
||
which is NOT currently present on that box:
|
||
|
||
cd ~/punktfunk && scripts/steamdeck/install.sh
|
||
|
||
Then LOG OUT AND BACK IN. KWin reads the .desktop when the client first connects and caches the
|
||
grant; an already-running session will not pick up a newly written file.
|
||
|
||
2. Verify by hand, no root needed:
|
||
getcap ~/punktfunk/target-steamos/release/punktfunk-host # MUST print nothing
|
||
getcap ~/punktfunk/target-steamos/release/punktfunk-encode-worker # cap_sys_nice=ep
|
||
|
||
3. Unprivileged, from a Desktop-mode terminal:
|
||
scripts/validate-encode-worker.sh inspect v1 v2 v4
|
||
|
||
--- .21 CachyOS KDE / RTX 5070 Ti -- the A/B home -------------------------------------------------
|
||
PW1's baselines live here and nowhere else, so V3b is only comparable to them on this box. It needs
|
||
a GPU-BOUND LOAD: start the game (PW1 used the GRID 2 benchmark loop, steam:44350, at 54-87% GPU)
|
||
and leave it running for both arms.
|
||
|
||
scripts/validate-encode-worker.sh inspect v1 v2 v3a v3b v4 v5
|
||
|
||
--- .25 Ubuntu 26.04 (enricobuehler@192.168.1.25) -- V3a/V4/V5 + the deb leg ----------------------
|
||
No KDE at all (sway only), so V1 cannot run there and will SKIP with that reason. V3a needs neither
|
||
a capability nor KDE, which is the point of splitting it out.
|
||
|
||
scripts/validate-encode-worker.sh inspect v3a v4 v5 --source portal
|
||
|
||
--- V4.e's second half: the respawn rung needs a REAL session -------------------------------------
|
||
v4.e kills the worker under `spike`, and `spike` has no encoder-recovery loop — submit errors
|
||
propagate and it exits. `Encoder::reset` is therefore never called, and reset is the ONLY emitter of
|
||
"the encode worker died mid-session" / "respawned the encode worker". So the leg asserts the half it
|
||
can see (the death is surfaced as an attributable worker-IPC error, host still alive) and leaves the
|
||
respawn to a real session. To close it, with a client connected and streaming:
|
||
|
||
pkill -f punktfunk-encode-worker
|
||
journalctl --user -u punktfunk-host -b | grep -E 'died mid-session|respawned|rebuilt in place'
|
||
|
||
Expect the stream never to drop, plus "respawned the encode worker after a mid-session death
|
||
priority=Granted(Realtime)" and "encoder submit failed — encoder rebuilt in place, forcing an IDR
|
||
reset=1 max=5". Confirmed on home-nobara-1 (KDE, RTX 5070 Ti) 2026-08-10.
|
||
|
||
--- the one step this script will never do --------------------------------------------------------
|
||
It never calls setcap. If a leg says the worker is uncapped and you want the lever:
|
||
|
||
sudo setcap 'cap_sys_nice=ep' /path/to/punktfunk-encode-worker
|
||
|
||
NEVER setcap punktfunk-host. Not "temporarily", not "to test". A capability on the host makes it
|
||
unidentifiable to KWin and takes out desktop streaming on every KDE box — that is 0.26.0-1, and I2
|
||
above hard-fails on it. To undo one: sudo setcap -r /path/to/punktfunk-host
|
||
|
||
--- reading the logs afterwards -------------------------------------------------------------------
|
||
Every fallback rung ends with the same clause, so one grep finds them all whichever rung fired:
|
||
|
||
journalctl --user -u punktfunk-host -b | grep -F 'encoding in-process at default GPU priority'
|
||
|
||
⚠ The worker's PUNKTFUNK_PERF p50/p99 summary is written by the WORKER process to inherited stderr.
|
||
It reaches journalctl. It does NOT reach the web console's Logs tab — that ring is a tracing layer
|
||
inside the host process and the worker is not in it. Read the journal, not the console.
|
||
EOF
|
||
}
|
||
|
||
# ==================================================================================================
|
||
# main
|
||
# ==================================================================================================
|
||
|
||
case " ${LEGS[*]} " in *" recipe "*) leg_recipe; exit 0 ;; esac
|
||
# Runs anywhere, deliberately: the readers have to be known-good before a box run is trusted, and
|
||
# the person writing them may not have a Linux box in front of them.
|
||
case " ${LEGS[*]} " in *" selftest "*) leg_selftest; exit $? ;; esac
|
||
|
||
if [ "$(uname -s)" != Linux ]; then
|
||
echo "FATAL: this kit validates a Linux-only worker; run it on the box under test." >&2
|
||
exit 3
|
||
fi
|
||
|
||
resolve_binaries
|
||
|
||
printf '%s — punktfunk-encode-worker on-glass validation (%s)\n' "$(hostname 2>/dev/null || echo box)" "$VERSION"
|
||
printf 'kernel %s · compositor %s · uid %s%s\n' \
|
||
"$(uname -r)" "$(compositor_guess)" "$(id -u)" \
|
||
"$([ -n "${WAYLAND_DISPLAY:-}" ] && printf ' · WAYLAND_DISPLAY=%s' "$WAYLAND_DISPLAY")"
|
||
printf 'host %s%s\n' "${HOST_BIN:-<not found>}" "$([ -n "$HOST_BIN_FROM" ] && printf ' (%s)' "$HOST_BIN_FROM")"
|
||
printf 'worker %s%s\n' "${WORKER_BIN:-<not found>}" "$([ -n "$WORKER_BIN_FROM" ] && printf ' (%s)' "$WORKER_BIN_FROM")"
|
||
[ -n "$DESKTOP_FILE" ] && printf 'desktop %s (Exec=%s)\n' "$DESKTOP_FILE" "$DESKTOP_EXEC"
|
||
|
||
RUN=()
|
||
for leg in "${LEGS[@]}"; do
|
||
case "$leg" in
|
||
auto) RUN+=(inspect v1 v2 v3a v4) ;;
|
||
all) RUN+=(inspect v1 v2 v3a v3b v4 v5) ;;
|
||
*) RUN+=("$leg") ;;
|
||
esac
|
||
done
|
||
|
||
DONE=' '
|
||
for leg in "${RUN[@]}"; do
|
||
case "$DONE" in *" $leg "*) continue ;; esac
|
||
DONE="$DONE$leg "
|
||
case "$leg" in
|
||
inspect) leg_inspect ;;
|
||
v1) leg_v1 ;;
|
||
v2) leg_v2 ;;
|
||
v3a) leg_v3a ;;
|
||
v3b) leg_v3b ;;
|
||
v4) leg_v4 ;;
|
||
v5) leg_v5 ;;
|
||
esac
|
||
done
|
||
|
||
head2 "SUMMARY"
|
||
if [ "${#RESULTS[@]}" -gt 0 ]; then
|
||
for r in "${RESULTS[@]}"; do
|
||
IFS='|' read -r verdict tag msg <<<"$r"
|
||
printf ' %-4s %-6s %s\n' "$verdict" "$tag" "$msg"
|
||
done
|
||
fi
|
||
printf '\n %d passed, %d failed, %d skipped\n' "$N_PASS" "$N_FAIL" "$N_SKIP"
|
||
if [ "$N_FAIL" -gt 0 ]; then
|
||
printf ' %sFAILED%s — a red leg here is a shipping blocker, not a flake.\n' "$C_BAD" "$C_OFF"
|
||
exit 1
|
||
fi
|
||
if [ "$N_SKIP" -gt 0 ]; then
|
||
printf ' %sINCOMPLETE%s — %d leg(s) could not run. A skip is never a pass; see the reasons above.\n' \
|
||
"$C_WARN" "$C_OFF" "$N_SKIP"
|
||
exit 2
|
||
fi
|
||
printf ' %sALL PASS%s\n' "$C_OK" "$C_OFF"
|
||
exit 0
|