ref:6d4a64865e8bfc1c94030797a8fa97b437d6f77b

feat(linux): let an app name the VM it streams

One Sunshine can now offer several VMs: an app in apps.json carries "qemu-vm", and launching it streams that VM. A client pairs once, and there's one set of ports and one name on the network, instead of an instance per VM. Apps without the field stream the configured VM, so nothing changes for a single-VM install. This fits how Sunshine is already built: capture, audio and input are made per streaming session and read the address when they start, so choosing the VM at launch, before the display opens, is enough. One VM streams at a time, because Sunshine runs one app at a time; two at once still needs an instance each. The QEMU backend also stops requiring a reachable VM at startup. It used to disable itself for the process lifetime when no VM answered, which is wrong when the VM is still booting, and impossible to satisfy when apps name different VMs. Adds the SteamOS-style guest's build and run scripts: Bazzite's deck image is a bootc image, so it builds into a qcow2 with no installer, and boots UEFI. REQ-DEP-003 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 6d4a64865e8bfc1c94030797a8fa97b437d6f77b
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 16:05
Parents: c0b8a0a
12 files changed +292 -6
Type
docs/qemu.md +25 −0
@@ -163,6 +163,31 @@
streamed at 1080p reaches 37.6 dB PSNR at 8 Mbps and gains nothing from 100 Mbps, while the same guest
streamed at 720p and scaled back up falls to 20.1 dB.
## Several VMs from one Sunshine
An app can name the VM it streams, so one Sunshine offers several VMs and a client picks one from
Moonlight's app list. The client pairs once, there's one set of ports and one name on the network.
```json
{
"env": {},
"apps": [
{"name": "Desktop", "qemu-vm": "libvirt:desktop", "image-path": "desktop.png"},
{"name": "SteamOS", "qemu-vm": "libvirt:steamos", "image-path": "steamos.png"}
]
}
```
`qemu-vm` takes the same forms as [qemu_dbus_address](configuration.md#qemu_dbus_address); an app without
one streams the configured VM. The VM is chosen when the client launches the app, before the display opens,
so it applies from that stream on and never moves a stream already running.
**One VM at a time.** Sunshine runs one app at a time, and a second client joins the running one. Streaming
two VMs at once needs one Sunshine per VM, below.
An app doesn't need a `cmd`: with none, Sunshine treats it as running for as long as the client streams,
which is what a VM wants — the VM's life isn't tied to the stream.
## Several VMs on one host
Every instance needs its own ports, name and state. Nothing else is shared, so configuration files are enough:
src/nvhttp.cpp +18 −0
@@ -38,5 +38,8 @@
#include "utility.h"
#include "uuid.h"
#include "video.h"
#ifdef SUNSHINE_BUILD_QEMU
#include "platform/linux/qemu/session.h"
#endif
using namespace std::literals;
@@ -1376,6 +1379,21 @@
host_audio = util::from_view(get_arg(args, "localAudioPlayMode"));
auto launch_session = make_launch_session(host_audio, args);
#ifdef SUNSHINE_BUILD_QEMU
// an app may name the VM it streams, so one Sunshine can offer several. This has to happen before
// the display is configured and the encoders are probed, both of which open the display below.
if (config::video.capture == "qemu") {
std::string vm;
for (const auto &app : proc::proc.get_apps()) {
if (app.id == std::to_string(appid)) {
vm = app.qemu_vm;
break;
}
}
qemu::select_vm(vm);
}
#endif
if (rtsp_stream::session_count() == 0) {
// The display should be restored in case something fails as there are no other sessions.
src/platform/linux/misc.cpp +6 −2
@@ -1406,8 +1406,12 @@
#endif
#ifdef SUNSHINE_BUILD_QEMU
// Only used when explicitly selected; a VM display is never auto-detected.
if (config::video.capture == "qemu" && verify_qemu()) {
// Only used when explicitly selected; a VM display is never auto-detected. The VM itself doesn't
// have to be reachable yet: it may still be booting, and an app may name a different one anyway.
if (config::video.capture == "qemu") {
sources[source::QEMU] = true;
if (!verify_qemu()) {
BOOST_LOG(info) << "qemu: no VM answered at startup; a stream will connect when one is there"sv;
}
}
#endif
src/platform/linux/qemu/audio.cpp +1 −1
@@ -398,6 +398,6 @@
* @return Audio control reading the guest's audio from `qemu_dbus_address`.
*/
std::unique_ptr<audio_control_t> qemu_audio_control() {
return qemu::make_audio_control(config::video.qemu_dbus_address);
return qemu::make_audio_control(qemu::session_address());
}
} // namespace platf
src/platform/linux/qemu/capture.cpp +3 −3
@@ -70,7 +70,7 @@
*/
std::optional<console_connection_t> connect_console(const std::string &display_name, bool dmabuf_readback) {
console_connection_t conn;
conn.session = shared_session(config::video.qemu_dbus_address);
conn.session = shared_session(session_address());
if (!conn.session) {
return std::nullopt;
}
@@ -533,7 +533,7 @@
if (config::video.capture != "qemu") {
return false;
}
return qemu::shared_session(config::video.qemu_dbus_address) != nullptr;
return qemu::shared_session(qemu::session_address()) != nullptr;
}
/**
@@ -544,7 +544,7 @@
* @return Console names, or an empty list when QEMU is unreachable.
*/
std::vector<std::string> qemu_display_names() {
auto session = qemu::shared_session(qemu::session_address());
auto session = qemu::shared_session(config::video.qemu_dbus_address);
if (!session) {
return {};
}
src/platform/linux/qemu/session.cpp +19 −0
@@ -1664,9 +1664,28 @@
}
namespace {
std::mutex selected_vm_mutex; ///< Guards `selected_vm`.
std::string selected_vm; ///< VM chosen by the app a client launched, empty for the configured one.
std::mutex unreachable_mutex; ///< Guards `unreachable`.
std::set<std::string> unreachable; ///< Addresses whose last connection failed, already reported.
} // namespace
std::string session_address() {
std::scoped_lock lock {selected_vm_mutex};
return selected_vm.empty() ? config::video.qemu_dbus_address : selected_vm;
}
void select_vm(const std::string &address) {
std::scoped_lock lock {selected_vm_mutex};
if (selected_vm == address) {
return;
}
selected_vm = address;
if (!address.empty()) {
BOOST_LOG(info) << "qemu: the next stream uses the VM at ["sv << address << "]"sv;
}
}
bool first_failure(const std::string &address) {
std::scoped_lock lock {unreachable_mutex};
src/platform/linux/qemu/session.h +19 −0
@@ -610,6 +610,25 @@
std::shared_ptr<session_t> shared_session(const std::string &address);
/**
* @brief Address of the VM the next stream uses.
* @details `qemu_dbus_address` names one VM for the whole process. An app may name its own, so one
* Sunshine can offer several VMs and a client picks which to stream; see `select_vm()`.
*
* @return The app's address when one was selected, otherwise the configured one.
*/
std::string session_address();
/**
* @brief Choose the VM the next stream uses.
* @details Called when a client launches an app, before the display is opened. Capture, audio and
* input are built per streaming session and read `session_address()` when they start, so the choice
* applies from the next stream on; it never moves a stream that is already running.
*
* @param address Address of the VM, or empty to go back to the configured one.
*/
void select_vm(const std::string &address);
/**
* @brief Report whether a failure to reach an address is the first since it last worked.
* @details Capture, audio and input retry a VM that isn't there several times a second. The first
* failure is worth an error in the log; the ones after it aren't, until the VM answers again.
src/process.cpp +5 −0
@@ -697,6 +697,7 @@
auto auto_detach = app_node.get_optional<bool>("auto-detach"s);
auto wait_all = app_node.get_optional<bool>("wait-all"s);
auto exit_timeout = app_node.get_optional<int>("exit-timeout"s);
auto qemu_vm = app_node.get_optional<std::string>("qemu-vm"s);
std::vector<proc::cmd_t> prep_cmds;
if (!exclude_global_prep.value_or(false)) {
@@ -756,6 +757,10 @@
// so we can safely strip them all out here to avoid confusing the user.
boost::erase_all(ctx.working_dir, "\"");
#endif
}
if (qemu_vm) {
ctx.qemu_vm = parse_env_val(this_env, *qemu_vm);
}
if (image_path) {
src/process.h +1 −0
@@ -77,6 +77,7 @@
std::string output; ///< Captured output from the launched process.
std::string image_path; ///< Image path.
std::string id; ///< Stable identifier for the configured application.
std::string qemu_vm; ///< VM this app streams, for `capture = qemu`; empty uses `qemu_dbus_address`.
bool elevated; ///< Whether the process should be launched elevated.
bool auto_detach; ///< Whether the process should detach automatically.
bool wait_all; ///< Whether Sunshine waits for all child processes.
tests/e2e/qemu/steamos/build_steamos.sh +85 −0
@@ -1,0 +1,85 @@
#!/usr/bin/env bash
# Build a console-style guest (Steam Game Mode) for Sunshine QEMU testing. The image lives in STEAMOS_CACHE.
#
# Bazzite's deck image is a bootc image, so bootc-image-builder turns it straight into a bootable qcow2 with
# no installer to click through. The ISO installs through Anaconda instead, which has no unattended path.
#
# On a host whose GPU has no DRM render node the guest renders in software: Game Mode boots and can be
# navigated, games can't be played. See docs/qemu.md and issue #14.
#
# Needs: docker or podman that can run a privileged container, ~25 GB of disk, and a long download.
#
# Usage: build_steamos.sh
#
# Environment:
# STEAMOS_CACHE where the image goes (default: ~/.cache/sunshine-qemu/steamos)
# STEAMOS_IMAGE bootc image to build from (default: ghcr.io/ublue-os/bazzite-deck:stable)
# STEAMOS_USER user created in the guest (default: sq)
# STEAMOS_PASS that user's password (default: sunshine)
# CONTAINER docker or podman (default: whichever is there)
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cache="${STEAMOS_CACHE:-${HOME}/.cache/sunshine-qemu/steamos}"
image="${STEAMOS_IMAGE:-ghcr.io/ublue-os/bazzite-deck:stable}"
user="${STEAMOS_USER:-sq}"
password="${STEAMOS_PASS:-sunshine}"
# bootc-image-builder reads the image out of podman's storage, so podman it is
container="${CONTAINER:-$(command -v podman || true)}"
builder="quay.io/centos-bootc/bootc-image-builder:latest"
[[ -n "${container}" ]] || { echo "steamos: needs podman (apt install podman)" >&2; exit 2; }
mkdir -p "${cache}"
# the same key the desktop guest uses, so the test tooling reaches both the same way
key="${cache}/ssh_key"
if [[ ! -f "${key}" ]]; then
if [[ -f "${HOME}/.cache/sunshine-qemu/desktop/ssh_key" ]]; then
cp "${HOME}/.cache/sunshine-qemu/desktop/ssh_key" "${key}"
cp "${HOME}/.cache/sunshine-qemu/desktop/ssh_key.pub" "${key}.pub"
else
ssh-keygen -t ed25519 -N "" -f "${key}" -C "sunshine-qemu-steamos" > /dev/null
fi
chmod 600 "${key}"
fi
if [[ -f "${cache}/steamos.qcow2" ]]; then
echo "steamos: ${cache}/steamos.qcow2 is already built; delete it to build again"
exit 0
fi
cat > "${cache}/config.toml" <<EOF
[[customizations.user]]
name = "${user}"
password = "${password}"
key = "$(cat "${key}.pub")"
groups = ["wheel"]
[customizations.services]
enabled = ["sshd"]
EOF
# a display agent, so the guest follows the size the client streams at like the desktop guest does
install -m 755 "${script_dir}/../desktop/sq-follow-resolution" "${cache}/sq-follow-resolution" 2> /dev/null || true
echo "steamos: pulling ${image}; this is several GB"
sudo "${container}" pull "${image}"
echo "steamos: building the qcow2; this takes a while"
mkdir -p "${cache}/output"
# the builder reads the pulled image from the host's container storage and writes the disk into /output
sudo "${container}" run --rm --privileged \
--security-opt label=type:unconfined_t \
-v /var/lib/containers/storage:/var/lib/containers/storage \
-v "${cache}/config.toml":/config.toml:ro \
-v "${cache}/output":/output \
"${builder}" \
--type qcow2 --rootfs btrfs --local "${image}"
sudo chown -R "$(id -u):$(id -g)" "${cache}/output"
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"
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 +67 −0
@@ -1,0 +1,67 @@
#!/usr/bin/env bash
# Boot the SteamOS-style guest (build_steamos.sh) with its display and audio on a private D-Bus.
#
# Unlike the other guests this one is a bootc image and boots UEFI, so it needs OVMF and its own copy of
# the variable store. It renders in software on a host without a DRM render node: Game Mode starts and can
# be navigated, games can't be played.
#
# Usage: run_steamos.sh <work-dir> [width] [height]
# Writes <work>/bus.sock, dbus.pid, qemu.pid, qmp.sock, serial.log, ssh.port; prints the bus address.
#
# Environment: QEMU, STEAMOS_CACHE, STEAMOS_IMAGE_FILE (disk to boot), SSH_PORT, SNAPSHOT (on keeps the disk),
# STEAMOS_MEMORY (default 8G), STEAMOS_CPUS (default 4), OVMF_CODE
set -euo pipefail
work="${1:?usage: run_steamos.sh <work-dir> [width] [height]}"
width="${2:-1920}"
height="${3:-1080}"
D="${STEAMOS_CACHE:-${HOME}/.cache/sunshine-qemu/steamos}"
Q="${QEMU:-qemu-system-x86_64}"
disk="${STEAMOS_IMAGE_FILE:-${D}/steamos.qcow2}"
ovmf_code="${OVMF_CODE:-/usr/share/OVMF/OVMF_CODE_4M.fd}"
ovmf_vars_template="${OVMF_VARS:-/usr/share/OVMF/OVMF_VARS_4M.fd}"
[[ -f "${disk}" ]] || { echo "missing ${disk}; run build_steamos.sh" >&2; exit 2; }
[[ -f "${ovmf_code}" ]] || { echo "missing ${ovmf_code}; install the ovmf package" >&2; exit 2; }
mkdir -p "${work}"
rm -f "${work}/bus.sock" "${work}/qmp.sock"
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" &
echo $! > "${work}/dbus.pid"
for _ in $(seq 1 50); do
[[ -S "${work}/bus.sock" ]] && break
sleep 0.1
done
ssh_port="${SSH_PORT:-$(shuf -i 22000-23000 -n 1)}"
echo "${ssh_port}" > "${work}/ssh.port"
"${Q}" -name sq-steamos \
-accel kvm -cpu host -smp "${STEAMOS_CPUS:-4}" -m "${STEAMOS_MEMORY:-8G}" -nodefaults \
-machine q35 \
-drive if=pflash,format=raw,unit=0,readonly=on,file="${ovmf_code}" \
-drive if=pflash,format=raw,unit=1,file="${work}/OVMF_VARS.fd" \
-drive file="${disk}",if=virtio,format=qcow2,snapshot="${SNAPSHOT:-on}" \
-device virtio-vga,xres="${width}",yres="${height}",edid=on \
-display dbus,addr="unix:path=${work}/bus.sock",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" &
echo $! > "${work}/qemu.pid"
for _ in $(seq 1 100); do
if ! kill -0 "$(cat "${work}/qemu.pid")" 2> /dev/null; then
echo "QEMU exited; see ${work}/serial.log" >&2
exit 1
fi
[[ -S "${work}/qmp.sock" ]] && break
sleep 0.2
done
echo "unix:path=${work}/bus.sock"
tests/unit/platform/linux/qemu/test_capture.cpp +43 −0
@@ -111,6 +111,7 @@
config::video.qemu_dbus_address = saved_address;
config::video.output_name = saved_output_name;
config::video.qemu_resize_guest = saved_resize_guest;
qemu::select_vm("");
fake.reset();
bus.reset();
BaseTest::TearDown();
@@ -177,6 +178,48 @@
bool saved_resize_guest {true};
};
} // namespace
// @tag requirements: [REQ-DEP-003]
TEST_F(QemuCaptureTest, StreamsTheVmTheAppSelected) {
// a second VM on its own bus, as a second app in Moonlight's list would have
qemu_test::private_bus_t other_bus;
ASSERT_TRUE(other_bus.ok());
qemu_test::fake_qemu_t other {
other_bus.address(),
"second-vm",
"00000000-0000-0000-0000-000000000009",
std::vector<qemu_test::fake_console_t> {{0, "virtio-gpu-pci.0", "Graphic", 96, 64}}
};
ASSERT_TRUE(other.ok());
// with nothing selected the configured VM is used
EXPECT_EQ(qemu::session_address(), bus->address());
auto first = open_display("1", 1, 8, 4);
ASSERT_NE(first, nullptr);
first.reset();
qemu::select_vm(other_bus.address());
EXPECT_EQ(qemu::session_address(), other_bus.address());
const int before = other.registrations(0);
std::thread sender {[&]() {
if (qemu_test::wait_until([&]() {
return other.registrations(0) > before;
})) {
other.scanout(0, 6, 2, 6 * 4, qemu::pixman_format::x8r8g8b8, solid(6, 2, 0x40, 0x50, 0x60));
}
}};
auto second = platf::qemu_display(platf::mem_type_e::system, "", stream_config());
sender.join();
ASSERT_NE(second, nullptr);
EXPECT_EQ(second->width, 6);
EXPECT_EQ(second->height, 2);
EXPECT_GT(other.registrations(0), before);
// and back to the configured VM when an app names none
qemu::select_vm("");
EXPECT_EQ(qemu::session_address(), bus->address());
}
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, AsksTheVmToRenderAtTheSizeTheClientStreams) {