@@ -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})"