ref:a604b635bfe8ec4b7885a1d6b65554fea6dce55c

test(e2e): add the SteamOS-style guest and serve VMs as apps

build_steamos.sh builds Bazzite's deck image into a bootable qcow2 with bootc-image-builder, so there's no installer to click through, and finishes the disk offline: the builder's service and session customizations don't reach a bootc image's /etc, so ssh, a journal that survives a reboot, and the session to log into are set in the built image. Game Mode doesn't run without a GPU. gamescope picks lavapipe and then refuses it -- "vulkan: not a valid physical device", "Failed to create backend" -- so on a host with no render node the guest logs into the desktop instead, which renders in software. Game Mode needs real hardware. serve_apps.sh runs both guests behind one Sunshine, each an app. Verified by streaming Desktop, SteamOS and Desktop again through one pairing: the log shows the VM switching each time, and the two pictures differ. Encoders are probed again when the VM changes: the probe at startup has no VM to open when each app names its own, and an encoder found on one VM says nothing about the next. Without this, every launch failed with "Failed to initialize video capture/encoding". Both background processes in run_steamos.sh now log to files: one that keeps the script's stdout open makes a command substitution around it hang, which is what stopped serve_apps.sh. REQ-DEP-003 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: a604b635bfe8ec4b7885a1d6b65554fea6dce55c
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 17:04
Parents: 6d4a648
4 files changed +189 -6
Type
src/platform/linux/misc.cpp +19 −4
@@ -1277,12 +1277,27 @@
// Full re-probing on every reconnect leaks ~20 MB due to FFmpeg CBS
// allocations during HEVC/AV1 codec validation.
static std::string last_render_device;
bool changed = false;
#ifdef SUNSHINE_BUILD_QEMU
// A VM is a display that comes and goes: the probe at startup has no VM to open when none is running
// yet, or when each app names its own, and an encoder found on one VM says nothing about the next.
if (config::video.capture == "qemu") {
static std::string last_vm;
auto vm = qemu::session_address();
if (vm != last_vm) {
last_vm = vm;
changed = true;
}
}
#endif
auto current = platf::resolve_render_device();
if (current != last_render_device) {
last_render_device = current;
changed = true;
if (current == last_render_device) {
return false;
}
return changed;
last_render_device = current;
return true;
}
std::shared_ptr<display_t> display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) {
tests/e2e/qemu/serve_apps.sh +139 −0
@@ -1,0 +1,139 @@
#!/usr/bin/env bash
# Serve several VMs from one Sunshine, each as an app in Moonlight's list.
#
# Boots the desktop guest and, when it's built, the SteamOS-style guest, then starts one Sunshine that
# offers both. A client pairs once and picks which VM to stream; one streams at a time.
#
# Usage: serve_apps.sh
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-release-cuda/sunshine)
# QEMU QEMU binary (default: qemu-system-x86_64)
# SERVE_DIR Sunshine state, logs and the web UI password (default: ~/.local/state/sunshine-qemu/apps)
# PORT Sunshine base port (default: 47989, what Moonlight expects)
# ENCODER nvenc, vaapi, vulkan or software (default: nvenc)
# GUEST_WIDTH/GUEST_HEIGHT size the guests boot at (default: 1920x1080)
# SERVE_EXIT_AFTER seconds to run before shutting down, for automated checks
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}"
serve_dir="${SERVE_DIR:-${HOME}/.local/state/sunshine-qemu/apps}"
port="${PORT:-47989}"
width="${GUEST_WIDTH:-1920}"
height="${GUEST_HEIGHT:-1080}"
[[ -x "${sunshine_bin}" ]] || { echo "missing ${sunshine_bin}; build Sunshine first" >&2; exit 2; }
mkdir -p "${serve_dir}"
chmod 700 "${serve_dir}"
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-apps.XXXXXX")"
sunshine_pid=""
declare -a vm_pids=()
cleanup() {
set +e
echo
echo "stopping"
[[ -n "${sunshine_pid}" ]] && kill "${sunshine_pid}" 2> /dev/null
for pid in "${vm_pids[@]}"; do
kill "${pid}" 2> /dev/null
done
rm -rf "${work}"
}
trap cleanup EXIT INT TERM
remember_vm() {
local dir="$1"
for pidfile in "${dir}/qemu.pid" "${dir}/dbus.pid"; do
[[ -f "${pidfile}" ]] && vm_pids+=("$(cat "${pidfile}")")
done
}
apps='{"env": {}, "apps": ['
separator=''
# the desktop guest keeps its changes between runs, like the machine someone actually uses
desktop_cache="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
if [[ -f "${desktop_cache}/desktop-base.qcow2" ]]; then
overlay="${serve_dir}/desktop.qcow2"
[[ -f "${overlay}" ]] || qemu-img create -q -f qcow2 -F qcow2 -b "${desktop_cache}/desktop-base.qcow2" "${overlay}"
echo "booting the desktop guest..."
desktop_bus="$(SNAPSHOT=off DESKTOP_IMAGE="${overlay}" "${script_dir}/desktop/run_desktop.sh" "${work}/desktop" "${width}" "${height}")"
remember_vm "${work}/desktop"
apps+="${separator}"'{"name": "Desktop", "qemu-vm": "'"${desktop_bus}"'", "image-path": "desktop.png"}'
separator=', '
echo " desktop: ssh -p $(cat "${work}/desktop/ssh.port") -i ${desktop_cache}/ssh_key sq@127.0.0.1"
fi
steamos_cache="${STEAMOS_CACHE:-${HOME}/.cache/sunshine-qemu/steamos}"
if [[ -f "${steamos_cache}/steamos.qcow2" ]]; then
steam_overlay="${serve_dir}/steamos.qcow2"
[[ -f "${steam_overlay}" ]] || qemu-img create -q -f qcow2 -F qcow2 -b "${steamos_cache}/steamos.qcow2" "${steam_overlay}"
echo "booting the SteamOS guest..."
steamos_bus="$(SNAPSHOT=off STEAMOS_IMAGE_FILE="${steam_overlay}" "${script_dir}/steamos/run_steamos.sh" "${work}/steamos" "${width}" "${height}")"
remember_vm "${work}/steamos"
apps+="${separator}"'{"name": "SteamOS", "qemu-vm": "'"${steamos_bus}"'", "image-path": "steamos.png"}'
separator=', '
echo " steamos: ssh -p $(cat "${work}/steamos/ssh.port") -i ${steamos_cache}/ssh_key sq@127.0.0.1"
else
echo "no SteamOS guest yet; run ${script_dir}/steamos/build_steamos.sh to add it" >&2
fi
apps+=']}'
[[ "${separator}" == ", " ]] || { echo "no guest images; build one first" >&2; exit 2; }
printf '%s\n' "${apps}" > "${serve_dir}/apps.json"
cat > "${serve_dir}/sunshine.conf" <<EOF
capture = qemu
encoder = ${ENCODER:-nvenc}
port = ${port}
sunshine_name = sunshine-qemu-vms
file_state = ${serve_dir}/sunshine_state.json
credentials_file = ${serve_dir}/sunshine_credentials.json
pkey = ${serve_dir}/sunshine.key
cert = ${serve_dir}/sunshine.crt
file_apps = ${serve_dir}/apps.json
log_path = ${serve_dir}/sunshine.log
min_log_level = ${LOG_LEVEL:-debug}
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = lan
EOF
if [[ ! -f "${serve_dir}/web-ui-password" ]]; then
head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n' > "${serve_dir}/web-ui-password"
chmod 600 "${serve_dir}/web-ui-password"
"${sunshine_bin}" "${serve_dir}/sunshine.conf" --creds sunshine "$(cat "${serve_dir}/web-ui-password")" > "${serve_dir}/creds.log" 2>&1
fi
"${sunshine_bin}" "${serve_dir}/sunshine.conf" > "${serve_dir}/sunshine.stdout" 2>&1 &
sunshine_pid=$!
for _ in $(seq 1 300); do
kill -0 "${sunshine_pid}" 2> /dev/null || { echo "Sunshine exited; see ${serve_dir}/sunshine.log" >&2; exit 1; }
curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q 'status_code="200"' && break
sleep 0.2
done
address="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<EOF
Sunshine is serving these VMs as apps:
$(python3 -c 'import json,sys; [print(" -", a["name"]) for a in json.load(open(sys.argv[1]))["apps"]]' "${serve_dir}/apps.json")
Moonlight: add the host ${address:-<IP of this machine>} (port ${port}), pair, then pick a VM
Web UI: https://${address:-localhost}:$((port + 1)) user: sunshine password: $(cat "${serve_dir}/web-ui-password")
Logs: ${serve_dir}/sunshine.log
One VM streams at a time: stop the stream to switch to the other.
EOF
if [[ -n "${SERVE_EXIT_AFTER:-}" ]]; then
sleep "${SERVE_EXIT_AFTER}"
else
echo "Ctrl+C to stop"
while kill -0 "${sunshine_pid}" 2> /dev/null; do
sleep 2
done
fi
tests/e2e/qemu/steamos/build_steamos.sh +29 −0
@@ -77,9 +77,38 @@
--type qcow2 --rootfs btrfs --local "${image}"
sudo chown -R "$(id -u):$(id -g)" "${cache}/output"
# The builder's service and session customizations don't reach a bootc image's /etc, so the last few
# settings are made in the built disk: ssh to drive the guest from tests, a journal that survives a reboot,
# and the session to log into. Game Mode needs a real Vulkan device -- gamescope refuses llvmpipe with
# "not a valid physical device" -- so a host without one logs into the desktop instead.
prepare_image() {
local disk="$1" nbd="${NBD_DEVICE:-/dev/nbd0}" mnt
mnt="$(mktemp -d)"
sudo modprobe nbd max_part=8 2> /dev/null || true
sudo qemu-nbd --connect="${nbd}" "${disk}"
sleep 2
sudo mount -o subvol=root "${nbd}p4" "${mnt}"
local deploy
deploy="$(sudo find "${mnt}/ostree/deploy" -mindepth 4 -maxdepth 4 -type d -name '*.0' | head -1)"
if [[ -n "${deploy}" ]]; then
sudo ln -sf /usr/lib/systemd/system/sshd.service "${deploy}/etc/systemd/system/multi-user.target.wants/sshd.service"
sudo mkdir -p "${mnt}/ostree/deploy/default/var/log/journal"
if [[ "${STEAMOS_SESSION:-auto}" == desktop ]] || { [[ "${STEAMOS_SESSION:-auto}" == auto ]] && [[ ! -e /dev/dri/renderD128 ]]; }; then
echo "steamos: no GPU on this host, so the guest logs into the desktop; Game Mode needs a real Vulkan device"
printf '[Autologin]\nSession=plasma.desktop\n' | sudo tee "${deploy}/etc/sddm.conf.d/zz-sunshine-session.conf" > /dev/null
fi
fi
sudo umount "${mnt}"
sudo qemu-nbd --disconnect "${nbd}" > /dev/null
rmdir "${mnt}"
}
built="$(find "${cache}/output" -name '*.qcow2' -print -quit)"
[[ -n "${built}" ]] || { echo "steamos: the builder produced no qcow2; see the output above" >&2; exit 1; }
mv "${built}" "${cache}/steamos.qcow2"
prepare_image "${cache}/steamos.qcow2"
rm -rf "${cache}/output"
echo "steamos: built ${cache}/steamos.qcow2 ($(du -h "${cache}/steamos.qcow2" | cut -f1))"
echo "steamos: boot it with run_steamos.sh; the user is ${user} with password ${password}"
tests/e2e/qemu/steamos/run_steamos.sh +2 −2
@@ -29,7 +29,7 @@
cp "${ovmf_vars_template}" "${work}/OVMF_VARS.fd"
# Bazzite enrolls its own Secure Boot key, so boot with Secure Boot off and the plain firmware
dbus-daemon --session --nofork --nopidfile --address="unix:path=${work}/bus.sock" &
dbus-daemon --session --nofork --nopidfile --address="unix:path=${work}/bus.sock" > "${work}/dbus.log" 2>&1 &
echo $! > "${work}/dbus.pid"
for _ in $(seq 1 50); do
[[ -S "${work}/bus.sock" ]] && break
@@ -52,7 +52,7 @@
-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" &
-serial file:"${work}/serial.log" > "${work}/qemu.log" 2>&1 &
echo $! > "${work}/qemu.pid"
for _ in $(seq 1 100); do