@@ -1,0 +1,340 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-DEP-001]
# Multi-VM integration test (REQ-DEP-001): two VMs, two Sunshine instances and two Moonlight clients
# at the same time on one host, configured only through per-VM config files.
#
# 1. start a private Avahi daemon (avahi_sandbox.sh) so the instances' mDNS services can be browsed
# 2. start two pattern VMs, each with its own D-Bus display (VM 2 over QMP p2p with E2E_VM2_TRANSPORT=qmp)
# 3. write vm-1.conf and vm-2.conf into a config directory, allocate their base ports with
# packaging/linux/qemu/sunshine-qemu-port and check the ranges don't overlap
# 4. start both Sunshine instances with the same HOME, so anything written to a hard-coded
# ~/.config/sunshine path is shared and detected
# 5. pair one client with each instance and stream both at the same time; both must decode the pattern
# 6. assert:
# - distinct mDNS service names, equal to each sunshine_name, advertising each instance's HTTP port
# - every port an instance listens on lies in its own range and no port is used by both
# - each client is paired with its own instance only (serverinfo PairStatus with the client certificate,
# and the web API client list), and the two instances have different unique ids
# - each instance streamed its own VM
# - no PulseAudio null sinks were created (capture = qemu takes audio from QEMU)
# - nothing but Sunshine's own directory was written under the shared ~/.config/sunshine
#
# Environment:
# SUNSHINE_BIN, CLIENT_BIN, QEMU, E2E_CACHE, E2E_TIMEOUT, E2E_WIDTH/E2E_HEIGHT/E2E_FPS, E2E_ENCODER: as e2e_stream.sh
# E2E_FRAMES decoded frames per client (default: 90, so the two streams overlap)
# E2E_MDNS 1 (default) requires the Avahi sandbox; 0 skips the mDNS assertions
# E2E_VM2_TRANSPORT bus (default: private dbus-daemon) or qmp (QEMU -display dbus,p2p=on; Sunshine connects
# with qemu_dbus_address = qmp:<socket>)
# E2E_ARTIFACTS where logs and summaries go (default: <work-dir>/artifacts, kept on failure)
# E2E_KEEP set to 1 to keep the work directory
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-debug/sunshine}"
client_bin="${CLIENT_BIN:-${repo}/cmake-build-e2e-client/moonlight_e2e_client}"
port_helper="${repo}/packaging/linux/qemu/sunshine-qemu-port"
cache="${E2E_CACHE:-${HOME}/.cache/sunshine-qemu/e2e}"
timeout_s="${E2E_TIMEOUT:-120}"
width="${E2E_WIDTH:-1280}"
height="${E2E_HEIGHT:-800}"
fps="${E2E_FPS:-30}"
frames="${E2E_FRAMES:-90}"
encoder="${E2E_ENCODER:-software}"
mdns="${E2E_MDNS:-1}"
vm2_transport="${E2E_VM2_TRANSPORT:-bus}"
for bin in "${sunshine_bin}" "${client_bin}"; do
if [[ ! -x "${bin}" ]]; then
echo "missing ${bin}; build it first" >&2
exit 2
fi
done
case "${vm2_transport}" in
bus | qmp) ;;
*)
echo "multi: E2E_VM2_TRANSPORT must be bus or qmp" >&2
exit 2
;;
esac
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-multi.XXXXXX")"
artifacts="${E2E_ARTIFACTS:-${work}/artifacts}"
mkdir -p "${artifacts}" "${work}/etc" "${work}/home"
result=1
failures=()
fail() {
echo "multi: FAIL: $*" >&2
failures+=("$*")
}
# shellcheck disable=SC2329 # called by the EXIT trap
cleanup() {
set +e
for pidfile in "${work}"/sunshine-*.pid "${work}"/client-*.pid "${work}"/vm-*/qemu.pid "${work}"/vm-*/dbus.pid "${work}/avahi/avahi.pid" "${work}/avahi/dbus.pid"; do
[[ -f "${pidfile}" ]] || continue
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
done
for i in 1 2; do
cp -f "${work}/sunshine-${i}.log" "${work}/sunshine-${i}.stdout" "${work}/client-${i}.log" "${artifacts}/" 2> /dev/null
cp -f "${work}/vm-${i}/qemu.log" "${artifacts}/qemu-${i}.log" 2> /dev/null
cp -f "${work}/etc/vm-${i}.conf" "${artifacts}/" 2> /dev/null
done
cp -f "${work}/avahi/avahi.log" "${artifacts}/" 2> /dev/null
if [[ ${result} == 0 && "${E2E_KEEP:-0}" != 1 && -z "${E2E_ARTIFACTS:-}" ]]; then
rm -rf "${work}"
else
echo "multi: work directory kept at ${work} (artifacts: ${artifacts})" >&2
fi
}
trap cleanup EXIT
# 1. mDNS
avahi_bus=""
if [[ "${mdns}" == 1 ]]; then
set +e
avahi_bus="$(E2E_CACHE="${cache}" "${script_dir}/avahi_sandbox.sh" "${work}/avahi" 2> "${work}/avahi-sandbox.log")"
status=$?
set -e
if [[ ${status} != 0 ]]; then
cat "${work}/avahi-sandbox.log" >&2
echo "multi: the Avahi sandbox is required for the mDNS checks (set E2E_MDNS=0 to skip them)" >&2
exit "${status}"
fi
avahi_root="${cache}/avahi/root"
echo "multi: Avahi sandbox on ${avahi_bus}" >&2
fi
# 2. VMs
image="${cache}/pattern-$(sha256sum "${script_dir}/guest/pattern.S" | cut -c1-16).img"
if [[ ! -f "${image}" ]]; then
"${script_dir}/guest/build_guest.sh" "${image}" > /dev/null
fi
declare -A address
address[1]="$(VM_NAME=sq-vm-1 "${script_dir}/run_vm.sh" "${work}/vm-1" "${image}")"
address[2]="$(VM_NAME=sq-vm-2 VM_DBUS="${vm2_transport}" "${script_dir}/run_vm.sh" "${work}/vm-2" "${image}")"
echo "multi: VMs up on ${address[1]} and ${address[2]}" >&2
# 3. per-VM configuration, only through config files
declare -A port api_pass
for i in 1 2; do
state="${work}/state-${i}"
mkdir -p "${state}"
cat > "${work}/etc/vm-${i}.conf" <<EOF
capture = qemu
qemu_dbus_address = ${address[${i}]}
encoder = ${encoder}
sunshine_name = sq-vm-${i}
file_state = ${state}/sunshine_state.json
credentials_file = ${state}/sunshine_credentials.json
pkey = ${state}/sunshine.key
cert = ${state}/sunshine.crt
file_apps = ${state}/apps.json
log_path = ${work}/sunshine-${i}.log
min_log_level = debug
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
cat > "${state}/apps.json" <<'EOF'
{"env": {}, "apps": [{"name": "Desktop", "image-path": "desktop.png"}]}
EOF
port[${i}]="$("${port_helper}" --dir "${work}/etc" --write "vm-${i}")"
done
"${port_helper}" --dir "${work}/etc" --check
echo "multi: base ports ${port[1]} and ${port[2]}" >&2
# 4. Sunshine instances, sharing one HOME
run_sunshine() {
exec env HOME="${work}/home" XDG_CONFIG_HOME= ${avahi_bus:+DBUS_SYSTEM_BUS_ADDRESS="${avahi_bus}"} "${sunshine_bin}" "$@"
}
for i in 1 2; do
api_pass[${i}]="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
(run_sunshine "${work}/etc/vm-${i}.conf" --creds e2e "${api_pass[${i}]}") > "${work}/creds-${i}.log" 2>&1
done
for i in 1 2; do
# exec, so $! is Sunshine's pid (the port check matches sockets by pid)
(run_sunshine "${work}/etc/vm-${i}.conf" > "${work}/sunshine-${i}.stdout" 2>&1) &
echo $! > "${work}/sunshine-${i}.pid"
done
for i in 1 2; do
for _ in $(seq 1 300); do
if ! kill -0 "$(cat "${work}/sunshine-${i}.pid")" 2> /dev/null; then
echo "multi: Sunshine ${i} exited early" >&2
tail -50 "${work}/sunshine-${i}.stdout" >&2
exit 1
fi
if curl -s --max-time 1 "http://127.0.0.1:${port[${i}]}/serverinfo" | grep -q 'status_code="200"'; then
break
fi
sleep 0.1
done
echo "multi: Sunshine ${i} up on port ${port[${i}]}" >&2
done
# 5. pair and stream both at the same time
for i in 1 2; do
"${client_bin}" \
--port "${port[${i}]}" \
--api-user e2e \
--api-pass "${api_pass[${i}]}" \
--state-dir "${work}/client-${i}" \
--width "${width}" --height "${height}" --fps "${fps}" \
--frames "${frames}" \
--timeout "${timeout_s}" \
--ready-file "${work}/client-${i}.ready" --ready-frames 15 \
--out "${artifacts}/last_frame-${i}.ppm" \
--summary "${artifacts}/summary-${i}.json" \
--expect "255,0,0;0,255,0;0,0,255;255,255,255" --tolerance 48 \
2> "${work}/client-${i}.log" &
echo $! > "${work}/client-${i}.pid"
done
# while both streams run: every socket of each instance
both_streaming=0
for _ in $(seq 1 $((timeout_s * 10))); do
if [[ -f "${work}/client-1.ready" && -f "${work}/client-2.ready" ]]; then
both_streaming=1
break
fi
if ! kill -0 "$(cat "${work}/client-1.pid")" 2> /dev/null && ! kill -0 "$(cat "${work}/client-2.pid")" 2> /dev/null; then
break
fi
sleep 0.1
done
if ((both_streaming)); then
ss -Htanup > "${work}/sockets.txt" 2> /dev/null || true
cp "${work}/sockets.txt" "${artifacts}/"
echo "multi: both clients are streaming" >&2
else
fail "the two clients never streamed at the same time"
fi
client_status=()
for i in 1 2; do
set +e
wait "$(cat "${work}/client-${i}.pid")"
client_status[i]=$?
set -e
rm -f "${work}/client-${i}.pid"
if [[ ${client_status[${i}]} != 0 ]]; then
fail "client ${i} exit ${client_status[${i}]}"
tail -20 "${work}/client-${i}.log" >&2
else
echo "multi: stream ${i} passed: $(cat "${artifacts}/summary-${i}.json")" >&2
fi
done
# 6a. ports: each instance only listens inside its own range, and never on a port of the other
if ((both_streaming)); then
declare -A ports_of
for i in 1 2; do
pid="$(cat "${work}/sunshine-${i}.pid")"
ports_of[${i}]="$(awk -v pid="pid=${pid}," '
index($0, pid) && ($2 == "LISTEN" || $1 == "udp") {
n = split($5, parts, ":"); print $1 "/" parts[n]
}' "${work}/sockets.txt" | sort -u | tr '\n' ' ')"
echo "multi: Sunshine ${i} sockets: ${ports_of[${i}]}" >&2
[[ -n "${ports_of[${i}]}" ]] || fail "no sockets found for Sunshine ${i}"
for entry in ${ports_of[${i}]}; do
p="${entry#*/}"
if ((p < port[${i}] - 5 || p > port[${i}] + 21)); then
fail "Sunshine ${i} (port ${port[${i}]}) listens on ${entry}, outside its range"
fi
done
for expected in tcp/$((port[${i}] - 5)) tcp/${port[${i}]} tcp/$((port[${i}] + 1)) tcp/$((port[${i}] + 21)) udp/$((port[${i}] + 9)) udp/$((port[${i}] + 10)) udp/$((port[${i}] + 11)); do
[[ " ${ports_of[${i}]} " == *" ${expected} "* ]] || fail "Sunshine ${i} doesn't listen on ${expected}"
done
done
for entry in ${ports_of[1]}; do
[[ " ${ports_of[2]} " != *" ${entry} "* ]] || fail "both instances use ${entry}"
done
fi
for i in 1 2; do
if grep -qiE "couldn't bind|address already in use" "${work}/sunshine-${i}.log"; then
fail "Sunshine ${i} logged a bind failure: $(grep -iE "couldn't bind|address already in use" "${work}/sunshine-${i}.log" | head -1)"
fi
done
# 6b. pairing is per instance: with its certificate a client gets serverinfo (PairStatus 1) from its own
# instance, while the other instance refuses the certificate (HTTP 401) or reports it unpaired
declare -A unique_id
for j in 1 2; do
unique_id[${j}]="$(curl -s --max-time 5 "http://127.0.0.1:${port[${j}]}/serverinfo" | sed -n 's:.*<uniqueid>\([^<]*\)</uniqueid>.*:\1:p')"
done
for i in 1 2; do
for j in 1 2; do
info="$(curl -sk --max-time 5 --cert "${work}/client-${i}/client.pem" --key "${work}/client-${i}/client.key" \
"https://127.0.0.1:$((port[${j}] - 5))/serverinfo?uniqueid=$(cat "${work}/client-${i}/uniqueid")" || true)"
status="$(sed -n 's:.*<root status_code="\([0-9]*\)".*:\1:p' <<< "${info}")"
paired="$(sed -n 's:.*<PairStatus>\([01]\)</PairStatus>.*:\1:p' <<< "${info}")"
if [[ ${i} == "${j}" ]]; then
[[ "${status}" == 200 && "${paired}" == 1 ]] || fail "client ${i} isn't paired with its own instance (status '${status}', PairStatus '${paired}')"
elif [[ "${status}" == 200 && "${paired}" != 0 ]] || [[ "${status}" != 200 && "${status}" != 401 ]]; then
fail "client ${i} on instance ${j}: status '${status}', PairStatus '${paired}', expected unpaired"
fi
done
clients="$(curl -sk --max-time 5 -u "e2e:${api_pass[${i}]}" "https://127.0.0.1:$((port[${i}] + 1))/api/clients/list" || true)"
count="$(python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("named_certs", [])))' <<< "${clients}" 2> /dev/null || echo "?")"
[[ "${count}" == 1 ]] || fail "instance ${i} has ${count} paired clients, expected 1: ${clients}"
done
if [[ -z "${unique_id[1]}" || "${unique_id[1]}" == "${unique_id[2]}" ]]; then
fail "the instances don't have distinct unique ids ('${unique_id[1]}', '${unique_id[2]}')"
fi
# 6c. each instance streamed its own VM
for i in 1 2; do
grep -q "qemu: connected to VM \[sq-vm-${i}\]" "${work}/sunshine-${i}.log" || fail "Sunshine ${i} never connected to sq-vm-${i}"
other=$((3 - i))
if grep -q "qemu: connected to VM \[sq-vm-${other}\]" "${work}/sunshine-${i}.log"; then
fail "Sunshine ${i} connected to sq-vm-${other}"
fi
done
# 6d. mDNS: one service per instance, named after sunshine_name, on that instance's HTTP port
if [[ -n "${avahi_bus}" ]]; then
services="$(DBUS_SYSTEM_BUS_ADDRESS="${avahi_bus}" LD_LIBRARY_PATH="${avahi_root}/usr/lib/x86_64-linux-gnu" \
timeout 10 "${avahi_root}/usr/bin/avahi-browse" -tprk _nvstream._tcp 2> "${work}/avahi-browse.log" | grep '^=' | sort -u || true)"
echo "${services}" > "${artifacts}/mdns.txt"
names="$(cut -d';' -f4 <<< "${services}" | sort -u | tr '\n' ' ')"
echo "multi: mDNS services: ${names}" >&2
for i in 1 2; do
grep -qE "^=;[^;]*;IPv4;sq-vm-${i};_nvstream\._tcp;local;[^;]*;[^;]*;${port[${i}]};" <<< "${services}" \
|| fail "no mDNS service 'sq-vm-${i}' on port ${port[${i}]} (found: ${names:-none})"
done
count="$(cut -d';' -f4 <<< "${services}" | sort -u | grep -c . || true)"
[[ "${count}" == 2 ]] || fail "expected 2 mDNS services, found ${count}: ${names}"
if grep -h "Avahi service name collision\|Service name collision" "${work}"/sunshine-*.log; then
fail "an instance renamed its mDNS service after a collision"
fi
fi
# 6e. no PulseAudio null sinks with capture = qemu
for i in 1 2; do
# audio.cpp logs these at debug level when it connects to PulseAudio and loads its null sinks
if grep -qE "null-sink args|Connecting to pulseaudio|virtual sink" "${work}/sunshine-${i}.log"; then
fail "Sunshine ${i} used PulseAudio: $(grep -E "null-sink args|Connecting to pulseaudio|virtual sink" "${work}/sunshine-${i}.log" | head -1)"
fi
done
# 6f. nothing shared under the hard-coded appdata directory
find "${work}/home" -mindepth 1 | sed "s:^${work}/home:~:" > "${artifacts}/home-files.txt"
unexpected="$(grep -v -E '^~/\.config$|^~/\.config/sunshine$' "${artifacts}/home-files.txt" || true)"
[[ -z "${unexpected}" ]] || fail "files written under the shared HOME: $(tr '\n' ' ' <<< "${unexpected}")"
if ((${#failures[@]} == 0)); then
result=0
echo "multi: PASS {\"ports\":[${port[1]},${port[2]}],\"vm2_transport\":\"${vm2_transport}\",\"mdns\":$([[ -n "${avahi_bus}" ]] && echo true || echo false)}"
else
echo "multi: FAIL (${#failures[@]} problem(s))" >&2
fi
exit "${result}"