ref:b91a63512bba8f2d6ba9d6364320fdab728b674a

test(e2e): stream a real desktop VM for latency and soak tests

tests/e2e/qemu/desktop builds an Ubuntu 24.04 XFCE guest with cloud-init (image kept in the cache), boots it with its display and audio on a private bus at a chosen EDID resolution, and streams it through Sunshine to the headless client with an idle or full-screen video workload. It samples Sunshine's RSS, descriptors and threads across repeated streams and optional guest reboots and fails when they keep growing. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: b91a63512bba8f2d6ba9d6364320fdab728b674a
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 10:16
Parents: 6ca811e
3 files changed +370 -0
Type
tests/e2e/qemu/desktop/build_desktop.sh +106 −0
@@ -1,0 +1,106 @@
#!/usr/bin/env bash
# Build a realistic desktop guest for Sunshine QEMU testing. The image lives in DESKTOP_CACHE, never in the repo.
# Needs network access, KVM, cloud-image-utils (cloud-localds) and qemu-img.
# Ubuntu 24.04 cloud image + XFCE autologin + PipeWire + test tools, configured by cloud-init on first boot.
set -euo pipefail
D="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
Q="${QEMU:-qemu-system-x86_64}"
mkdir -p "$D"
cd "$D"
if [[ ! -f noble-server-cloudimg-amd64.img ]]; then
wget -q https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
wget -q -O SHA256SUMS https://cloud-images.ubuntu.com/noble/current/SHA256SUMS
grep " \*\?noble-server-cloudimg-amd64.img$" SHA256SUMS | sed 's/ \*/ /' | sha256sum -c -
fi
[[ -f ssh_key ]] || ssh-keygen -q -t ed25519 -N "" -C sunshine-qemu-desktop -f ssh_key
cat > user-data <<EOF
#cloud-config
hostname: sq-desktop
users:
- name: sq
groups: [sudo, audio, video, input]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: false
plain_text_passwd: sunshine
ssh_authorized_keys:
- $(cat ssh_key.pub)
package_update: true
packages:
- xfce4
- xfce4-terminal
- lightdm
- lightdm-gtk-greeter
- xserver-xorg-core
- xserver-xorg-video-all
- xserver-xorg-input-libinput
- dbus-x11
- pipewire
- pipewire-pulse
- wireplumber
- alsa-utils
- pulseaudio-utils
- mpv
- x11-xserver-utils
- xdotool
- evtest
write_files:
- path: /etc/lightdm/lightdm.conf.d/50-autologin.conf
content: |
[Seat:*]
autologin-user=sq
autologin-user-timeout=0
user-session=xfce
- path: /etc/default/grub.d/99-sunshine-qemu.cfg
content: |
GRUB_CMDLINE_LINUX_DEFAULT="console=ttyS0 console=tty1"
GRUB_TIMEOUT=0
- path: /home/sq/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml
owner: sq:sq
defer: true
content: |
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-screensaver" version="1.0">
<property name="saver" type="empty"><property name="enabled" type="bool" value="false"/></property>
<property name="lock" type="empty"><property name="enabled" type="bool" value="false"/></property>
</channel>
- path: /home/sq/.config/autostart/no-blank.desktop
owner: sq:sq
defer: true
content: |
[Desktop Entry]
Type=Application
Name=no-blank
Exec=sh -c "xset s off -dpms; echo SQ_DESKTOP_READY > /dev/ttyS0 2>/dev/null; touch /tmp/sq-desktop-ready"
runcmd:
- DEBIAN_FRONTEND=noninteractive apt-get install -y linux-modules-extra-$(uname -r)
- systemctl set-default graphical.target
- systemctl enable lightdm
- update-grub
- chmod a+rw /dev/ttyS0 || true
- echo 'KERNEL=="ttyS0", MODE="0666"' > /etc/udev/rules/99-ttyS0.rules
- loginctl enable-linger sq
- apt-get clean
power_state:
mode: poweroff
timeout: 120
EOF
printf 'instance-id: sq-desktop-1\nlocal-hostname: sq-desktop\n' > meta-data
cloud-localds seed.img user-data meta-data
if [[ ! -f desktop-base.qcow2 ]]; then
cp noble-server-cloudimg-amd64.img desktop-build.qcow2
qemu-img resize desktop-build.qcow2 24G
# first boot: cloud-init installs the desktop and powers off
timeout 3600 "$Q" -accel kvm -cpu host -smp 8 -m 6G -nodefaults \
-drive file=desktop-build.qcow2,if=virtio,format=qcow2 \
-drive file=seed.img,if=virtio,format=raw \
-nic user,model=virtio-net-pci \
-display none -serial file:"$D/first-boot.log"
mv desktop-build.qcow2 desktop-base.qcow2
fi
echo "built $D/desktop-base.qcow2"
tests/e2e/qemu/desktop/desktop_stream.sh +214 −0
@@ -1,0 +1,214 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-NFR-001, REQ-E2E-001]
# Stream a real desktop VM (Ubuntu 24.04, XFCE, built by build_desktop.sh) through Sunshine to the headless client.
#
# Used for latency at realistic guest resolutions and for soak testing: it samples Sunshine's resident memory,
# open file descriptors and threads while one or more streams run, and fails when they keep growing.
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-release-cuda/sunshine)
# CLIENT_BIN E2E client (default: <repo>/cmake-build-e2e-client/moonlight_e2e_client)
# QEMU QEMU binary (default: qemu-system-x86_64); QEMU 10+ uses the shared memory map
# DESKTOP_CACHE where build_desktop.sh put desktop-base.qcow2 (default: ~/.cache/sunshine-qemu/desktop)
# GUEST_WIDTH/GUEST_HEIGHT guest framebuffer size, set through virtio-vga's EDID (default: 1920x1080)
# STREAM_WIDTH/STREAM_HEIGHT/STREAM_FPS stream mode (default: 1920x1080 at 60)
# WORKLOAD idle (static desktop) or video (full-screen 60 fps mpv test pattern) (default: video)
# ENCODER Sunshine encoder (default: nvenc)
# FRAMES decoded frames per stream (default: 600)
# SESSIONS streams in a row against the same Sunshine and VM (default: 1)
# RESTART_VM_EVERY restart the guest with QMP system_reset after every N sessions (default: 0 = never)
# SAMPLE_SECONDS resource sampling interval (default: 5)
# MAX_RSS_GROWTH_MB allowed growth of Sunshine's RSS between the end of the first and the last session (default: 64)
# MAX_FD_GROWTH allowed growth of Sunshine's open descriptors between the same points (default: 8)
# ARTIFACTS output directory (default: a new directory under /tmp); gets summary-<n>.json, resources.csv,
# result.json and the logs
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-release-cuda/sunshine}"
client_bin="${CLIENT_BIN:-${repo}/cmake-build-e2e-client/moonlight_e2e_client}"
guest_width="${GUEST_WIDTH:-1920}"
guest_height="${GUEST_HEIGHT:-1080}"
stream_width="${STREAM_WIDTH:-1920}"
stream_height="${STREAM_HEIGHT:-1080}"
stream_fps="${STREAM_FPS:-60}"
workload="${WORKLOAD:-video}"
encoder="${ENCODER:-nvenc}"
frames="${FRAMES:-600}"
sessions="${SESSIONS:-1}"
restart_every="${RESTART_VM_EVERY:-0}"
sample_seconds="${SAMPLE_SECONDS:-5}"
max_rss_growth_mb="${MAX_RSS_GROWTH_MB:-64}"
max_fd_growth="${MAX_FD_GROWTH:-8}"
artifacts="${ARTIFACTS:-$(mktemp -d "${TMPDIR:-/tmp}/sunshine-desktop.XXXXXX")}"
port="$((21000 + (RANDOM % 190) * 100))"
desktop_cache="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
for bin in "${sunshine_bin}" "${client_bin}"; do
[[ -x "${bin}" ]] || { echo "missing ${bin}; build it first" >&2; exit 2; }
done
[[ -f "${desktop_cache}/desktop-base.qcow2" ]] || { echo "missing ${desktop_cache}/desktop-base.qcow2; run build_desktop.sh" >&2; exit 2; }
mkdir -p "${artifacts}"
work="${artifacts}/work"
mkdir -p "${work}"
sampler_pid=""
cleanup() {
set +e
[[ -n "${sampler_pid}" ]] && kill "${sampler_pid}" 2>/dev/null
for pidfile in "${work}/sunshine.pid" "${work}/vm/qemu.pid" "${work}/vm/dbus.pid"; do
if [[ -f "${pidfile}" ]]; then
pid="$(cat "${pidfile}")"
kill "${pid}" 2>/dev/null
for _ in $(seq 1 50); do kill -0 "${pid}" 2>/dev/null || break; sleep 0.1; done
kill -9 "${pid}" 2>/dev/null
fi
done
}
trap cleanup EXIT
# 1. desktop VM and workload
bus_address="$("${script_dir}/run_desktop.sh" "${work}/vm" "${guest_width}" "${guest_height}")"
ssh_port="$(cat "${work}/vm/ssh.port")"
guest() {
ssh -q -i "${desktop_cache}/ssh_key" -p "${ssh_port}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 sq@127.0.0.1 "$@"
}
start_workload() {
guest "DISPLAY=:0 xrandr --output Virtual-1 --mode ${guest_width}x${guest_height}" || true
if [[ "${workload}" == video ]]; then
guest "pkill -x mpv; DISPLAY=:0 XDG_RUNTIME_DIR=/run/user/1000 setsid nohup mpv --really-quiet --fs --loop=inf --no-audio \
--vo=x11 'av://lavfi:testsrc2=size=${guest_width}x${guest_height}:rate=60' > /dev/null 2>&1 < /dev/null &"
fi
sleep 3
guest "DISPLAY=:0 xrandr | head -1" > "${artifacts}/guest-mode.txt" 2>&1 || true
}
start_workload
echo "desktop: VM up at ${guest_width}x${guest_height}, workload ${workload}" >&2
# 2. Sunshine
cat > "${work}/apps.json" <<'EOF'
{"env": {}, "apps": [{"name": "Desktop", "image-path": "desktop.png"}]}
EOF
cat > "${work}/sunshine.conf" <<EOF
capture = qemu
qemu_dbus_address = ${bus_address}
encoder = ${encoder}
port = ${port}
sunshine_name = sunshine-desktop-${port}
file_state = ${work}/sunshine_state.json
credentials_file = ${work}/sunshine_credentials.json
pkey = ${work}/sunshine.key
cert = ${work}/sunshine.crt
file_apps = ${work}/apps.json
log_path = ${work}/sunshine.log
min_log_level = info
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
api_user="e2e"
api_pass="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
"${sunshine_bin}" "${work}/sunshine.conf" --creds "${api_user}" "${api_pass}" > "${work}/creds.log" 2>&1
"${sunshine_bin}" "${work}/sunshine.conf" > "${work}/sunshine.stdout" 2>&1 &
sunshine_pid=$!
echo "${sunshine_pid}" > "${work}/sunshine.pid"
for _ in $(seq 1 300); do
kill -0 "${sunshine_pid}" 2>/dev/null || { echo "Sunshine exited early" >&2; tail -30 "${work}/sunshine.stdout" >&2; exit 1; }
curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q 'status_code="200"' && break
sleep 0.1
done
# 3. resource sampler: time, session, RSS kB, open fds, threads
echo "seconds,session,rss_kb,fds,threads" > "${artifacts}/resources.csv"
echo 0 > "${work}/session"
(
start=$(date +%s)
while kill -0 "${sunshine_pid}" 2>/dev/null; do
rss=$(awk '/^VmRSS/ {print $2}' "/proc/${sunshine_pid}/status" 2>/dev/null || echo 0)
threads=$(awk '/^Threads/ {print $2}' "/proc/${sunshine_pid}/status" 2>/dev/null || echo 0)
fds=$(ls "/proc/${sunshine_pid}/fd" 2>/dev/null | wc -l)
echo "$(( $(date +%s) - start )),$(cat "${work}/session"),${rss},${fds},${threads}" >> "${artifacts}/resources.csv"
sleep "${sample_seconds}"
done
) &
sampler_pid=$!
# 4. streams
timeout_s=$(( frames / stream_fps * 3 + 120 ))
first_end_rss=""
first_end_fds=""
for ((session = 1; session <= sessions; session++)); do
echo "${session}" > "${work}/session"
set +e
"${client_bin}" --port "${port}" --api-user "${api_user}" --api-pass "${api_pass}" --state-dir "${work}/client" \
--width "${stream_width}" --height "${stream_height}" --fps "${stream_fps}" --frames "${frames}" --timeout "${timeout_s}" \
--out "${artifacts}/last-frame.ppm" --summary "${artifacts}/summary-${session}.json" 2>> "${work}/client.log"
rc=$?
set -e
if [[ ${rc} != 0 ]]; then
echo "desktop: FAIL: stream ${session} of ${sessions} (client exit ${rc})" >&2
tail -30 "${work}/client.log" >&2
cp -f "${work}/sunshine.log" "${work}/vm/qemu.log" "${artifacts}/" 2>/dev/null || true
exit 1
fi
sleep 2
rss=$(awk '/^VmRSS/ {print $2}' "/proc/${sunshine_pid}/status")
fds=$(ls "/proc/${sunshine_pid}/fd" | wc -l)
if (( session == 1 )); then
first_end_rss=${rss}
first_end_fds=${fds}
fi
echo "desktop: stream ${session}/${sessions}: $(cat "${artifacts}/summary-${session}.json") rss=${rss}kB fds=${fds}" >&2
if (( restart_every > 0 && session % restart_every == 0 && session < sessions )); then
sleep 5
python3 "${script_dir}/../qmp.py" "${work}/vm/qmp.sock" system_reset > /dev/null 2>&1 || \
printf '{"execute":"qmp_capabilities"}\n{"execute":"system_reset"}\n' | socat -t2 - UNIX-CONNECT:"${work}/vm/qmp.sock" > /dev/null
for _ in $(seq 1 120); do
guest 'test -e /tmp/sq-desktop-ready && [ "$(stat -c %Y /tmp/sq-desktop-ready)" -ge "$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) ))" ]' 2>/dev/null && break
sleep 1
done
start_workload
fi
done
kill "${sampler_pid}" 2>/dev/null || true
sampler_pid=""
cp -f "${work}/sunshine.log" "${work}/vm/qemu.log" "${artifacts}/" 2>/dev/null || true
rss_growth_kb=$(( rss - first_end_rss ))
fd_growth=$(( fds - first_end_fds ))
python3 - "${artifacts}" "${sessions}" "${rss_growth_kb}" "${fd_growth}" <<'EOF'
import glob, json, statistics, sys
artifacts, sessions, rss_growth_kb, fd_growth = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
summaries = [json.load(open(f)) for f in sorted(glob.glob(f"{artifacts}/summary-*.json"))]
p50 = [s["host_latency_ms"]["p50"] for s in summaries]
p95 = [s["host_latency_ms"]["p95"] for s in summaries]
result = {
"sessions": sessions,
"decoded_frames": sum(s["decoded_frames"] for s in summaries),
"latency_p50_ms_median": statistics.median(p50) if p50 else None,
"latency_p95_ms_median": statistics.median(p95) if p95 else None,
"latency_p95_ms_max": max(p95) if p95 else None,
"rss_growth_kb_after_first_session": rss_growth_kb,
"fd_growth_after_first_session": fd_growth,
}
json.dump(result, open(f"{artifacts}/result.json", "w"), indent=1)
print(json.dumps(result))
EOF
if (( sessions > 1 )); then
if (( rss_growth_kb > max_rss_growth_mb * 1024 )); then
echo "desktop: FAIL: Sunshine RSS grew ${rss_growth_kb} kB after the first stream" >&2
exit 1
fi
if (( fd_growth > max_fd_growth )); then
echo "desktop: FAIL: Sunshine open descriptors grew by ${fd_growth} after the first stream" >&2
exit 1
fi
fi
echo "desktop: PASS (artifacts: ${artifacts})"
tests/e2e/qemu/desktop/run_desktop.sh +50 −0
@@ -1,0 +1,50 @@
#!/usr/bin/env bash
# Boot the desktop guest with its display and audio on a private D-Bus bus.
# Usage: run_desktop.sh <work-dir> [width] [height] [extra qemu args...]
# Environment: QEMU, DESKTOP_CACHE, SSH_PORT, SNAPSHOT=off to write to the base image (maintenance only).
# Writes <work>/bus.sock, dbus.pid, qemu.pid, qmp.sock, serial.log, ssh.port; prints the bus address when the desktop is up.
set -euo pipefail
work="${1:?usage: run_desktop.sh <work-dir> [width] [height]}"
width="${2:-1920}"
height="${3:-1080}"
D="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
Q="${QEMU:-qemu-system-x86_64}"
ssh_port="${SSH_PORT:-$((22000 + RANDOM % 1000))}"
mkdir -p "$work"
bus="unix:path=$work/bus.sock"
rm -f "$work/bus.sock" "$work/qmp.sock"
echo "$ssh_port" > "$work/ssh.port"
dbus-daemon --session --nofork --nopidfile --address="$bus" > "$work/dbus.log" 2>&1 &
echo $! > "$work/dbus.pid"
for _ in $(seq 100); do [[ -S "$work/bus.sock" ]] && break; sleep 0.05; done
"$Q" -name sq-desktop -accel kvm -cpu host -smp 4 -m 4G -nodefaults \
-drive file="$D/desktop-base.qcow2",if=virtio,format=qcow2,snapshot=${SNAPSHOT:-on} \
-device virtio-vga,xres="$width",yres="$height",edid=on \
-display dbus,addr="$bus",audiodev=snd0 \
-audiodev dbus,id=snd0 \
-device intel-hda -device hda-duplex,audiodev=snd0 \
-device virtio-tablet-pci -device virtio-keyboard-pci \
-nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:"$ssh_port"-:22 \
-qmp unix:"$work/qmp.sock",server=on,wait=off \
-serial file:"$work/serial.log" \
"${@:4}" > "$work/qemu.log" 2>&1 &
echo $! > "$work/qemu.pid"
ssh_cmd=(ssh -q -i "$D/ssh_key" -p "$ssh_port" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=2 sq@127.0.0.1)
for _ in $(seq 180); do
if ! kill -0 "$(cat "$work/qemu.pid")" 2>/dev/null; then
echo "QEMU exited; see $work/qemu.log" >&2
exit 1
fi
if "${ssh_cmd[@]}" 'test -e /tmp/sq-desktop-ready && [ "$(stat -c %Y /tmp/sq-desktop-ready)" -ge "$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) ))" ]' 2>/dev/null; then
echo "$bus"
exit 0
fi
sleep 1
done
echo "desktop didn't become ready in 180 s" >&2
exit 1