ref:cdaea6808a3bd7b5e75aad280afd883bc74edccf

fix(linux): make the per-VM units work with the system libvirt and stale QMP sockets

- libvirt:<domain> without ?uri= now means qemu:///system in Sunshine and in sunshine-qemu-wait-vm, as documented; the unprivileged unit user would otherwise get libvirt's qemu:///session default. An empty ?uri= still leaves the choice to libvirt. - The units move Sunshine's directory with CONFIGURATION_DIRECTORY instead of XDG_CONFIG_HOME, which virsh and libvirt inherited (a session libvirt started from the user unit would read the wrong config). - sunshine-qemu-wait-vm waits for QEMU's QMP greeting, not just the socket file a killed QEMU leaves behind. - sunshine-qemu-port --write adds a missing final newline first, --check reports configs with the same sunshine_name, and example.conf leaves port commented so --write allocates for copies of it; it also disables native_pen_touch, since QEMU accepts touches that are lost in a VM without virtio-multitouch-pci. - Packaging tests: negative assertions now fail their test, a set failure message fails a test that returns 0, SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1 turns skips into failures, and the script is registered with CTest (sunshine_qemu_packaging). - multi_instance.sh E2E_STATE_LAYOUT=unit runs example.conf's relative state paths with a CONFIGURATION_DIRECTORY per instance. Refs #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: cdaea6808a3bd7b5e75aad280afd883bc74edccf
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 02:16
Parents: f06ce3f
12 files changed +304 -47
Type
packaging/linux/qemu/example.conf +13 −5
@@ -6,17 +6,18 @@
capture = qemu
# Where QEMU's display is. One of:
# libvirt:<domain> a system libvirt domain with <graphics type='dbus' p2p='yes'/>;
# libvirt:<domain> a domain of the system libvirt (qemu:///system) with
# <graphics type='dbus' p2p='yes'/>; add ?uri=qemu:///session for session libvirt
# add ?uri=qemu:///session for session libvirt
# qmp:/run/sunshine-qemu/vm.qmp a QEMU started with -display dbus,p2p=on and a QMP socket for Sunshine
# unix:path=/run/vm/bus.sock a D-Bus bus where QEMU (-display dbus,addr=...) owns org.qemu
qemu_dbus_address = libvirt:example
# Base port. Every port of this instance is an offset from it (port - 5 to port + 21), so VMs use ports
# 100 apart: sunshine-qemu-port --dir /etc/sunshine-qemu --write <vm> appends the next free one, for
# 100 apart: sunshine-qemu-port --dir /etc/sunshine-qemu --write <vm> picks the next free one.
port = 48089
# example port = 48089. It keeps a port that is already set, so leave this line commented.
# port = 48089
# Name shown in Moonlight and advertised over mDNS; keep it unique per host.
# Name shown in Moonlight and advertised over mDNS (up to 63 bytes); keep it unique per host.
sunshine_name = example
# Per-VM state. Relative paths are resolved in the instance's state directory
@@ -37,5 +38,10 @@
# Graphical console to stream, by id or label; empty streams the first one.
# output_name = 0
# Native touch goes to QEMU's multi-touch interface, which every VM has but only a VM with
# -device virtio-multitouch-pci turns into touches; elsewhere they are lost. Disabled, Moonlight sends
# touches as mouse input. Enable it for VMs with a multi-touch device.
native_pen_touch = disabled
# Several instances on one host must not fight over router port mappings.
upnp = disabled
@@ -44,4 +50,6 @@
system_tray = disabled
# Web UI (port + 1): only from this machine by default; use lan to manage the VM from the network.
# With the system unit this file is read-only for Sunshine, so settings saved in the web UI fail: edit
# it as root and restart the unit.
origin_web_ui_allowed = pc
packaging/linux/qemu/sunshine-qemu-port +23 −3
@@ -14,8 +14,9 @@
# DIR, so running it again gives the same answer. --write appends `port = <port>` to
# DIR/<vm>.conf when the file has no port yet.
# sunshine-qemu-port [--dir DIR] --check
# Exit 1 and list the problems when two configs in DIR have overlapping port ranges or a range
# Exit 1 and list the problems when two configs in DIR have overlapping port ranges, a range
# leaves 1024-65535, or two configs have the same sunshine_name (Moonlight and mDNS would show
# them as one; a config without sunshine_name uses the host name).
# leaves 1024-65535.
#
# Environment:
# SUNSHINE_QEMU_CONF_DIR default for --dir (default: /etc/sunshine-qemu)
@@ -69,6 +70,11 @@
sed -n -e 's/^[[:space:]]*port[[:space:]]*=[[:space:]]*\([0-9][0-9]*\)[[:space:]]*$/\1/p' "$1" | tail -n 1
}
# name_of <file>: the value of the last `sunshine_name = ...` line, or nothing
name_of() {
sed -n -e 's/^[[:space:]]*sunshine_name[[:space:]]*=[[:space:]]*//p' "$1" | tail -n 1 | sed -e 's/[[:space:]]*$//'
}
# overlaps <a> <b>: whether the port ranges of two base ports share a port
overlaps() {
local diff=$(($1 - $2))
@@ -86,8 +92,18 @@
if ((check)); then
[[ -z "${vm}" && ${write} == 0 ]] || usage
status=0
declare -A seen=()
declare -A seen=() names=()
for conf in "${configs[@]}"; do
name="$(name_of "${conf}")"
# mDNS compares names without regard to case
key="${name:-<host name>}"
key="${key,,}"
if [[ -n "${names["${key}"]:-}" ]]; then
echo "$(basename "${conf}") has the same sunshine_name as ${names[${key}]} (${key}); Moonlight would list them as one" >&2
status=1
else
names["${key}"]="$(basename "${conf}")"
fi
port="$(port_of "${conf}")"
if [[ -z "${port}" ]]; then
echo "$(basename "${conf}"): no port set (Sunshine would use 47989)" >&2
@@ -152,6 +168,10 @@
if [[ ! -f "${own}" ]]; then
echo "sunshine-qemu-port: ${own} doesn't exist" >&2
exit 1
fi
# don't glue the setting onto a last line without a newline
if [[ -s "${own}" && -n "$(tail -c 1 "${own}")" ]]; then
echo >> "${own}"
fi
printf 'port = %s\n' "${candidate}" >> "${own}"
fi
packaging/linux/qemu/sunshine-qemu-wait-vm +29 −4
@@ -5,8 +5,10 @@
# unit waits for the display named by qemu_dbus_address in <config>:
# unix:path=<socket>,... the bus socket exists and QEMU owns org.qemu on it (gdbus)
# other bus addresses QEMU owns org.qemu on it (gdbus)
# qmp:<socket> the QMP socket exists
# qmp:<socket> QEMU answers on the QMP socket with its greeting (python3), or the socket
# exists (without python3)
# libvirt:<domain>[?uri=URI] virsh reports the domain running on URI, qemu:///system by default as in
# Sunshine (skipped without virsh)
# libvirt:<domain>[?uri=URI] virsh reports the domain running (skipped without virsh)
# empty (session bus) not checked
#
# Usage: sunshine-qemu-wait-vm <config>
@@ -45,12 +47,35 @@
qmp:*)
socket="${address#qmp:}"
what="QMP socket ${socket}"
if command -v python3 > /dev/null; then
# a socket file stays behind when QEMU is killed: wait for the greeting, then hang up at once
# (QEMU serves one QMP client at a time, and Sunshine connects next)
check() {
python3 - "${socket}" 2> /dev/null <<'PY'
import socket, sys
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(2)
s.connect(sys.argv[1])
data = b""
while b"\n" not in data and len(data) < 65536:
check() { [[ -S "${socket}" ]]; }
chunk = s.recv(4096)
if not chunk:
break
data += chunk
s.close()
sys.exit(0 if b'"QMP"' in data else 1)
PY
}
else
log "python3 isn't installed; only checking that ${socket} exists"
check() { [[ -S "${socket}" ]]; }
fi
;;
libvirt:*)
target="${address#libvirt:}"
domain="${target%%\?uri=*}"
uri=""
# like Sunshine: the system libvirt unless ?uri= says otherwise; an empty ?uri= is libvirt's default
uri="qemu:///system"
[[ "${target}" == *"?uri="* ]] && uri="${target#*\?uri=}"
if ! command -v virsh > /dev/null; then
log "virsh isn't installed; not waiting for libvirt domain ${domain}"
packaging/linux/qemu/sunshine-qemu@.service +8 −2
@@ -20,14 +20,20 @@
User=sunshine-qemu
Group=sunshine-qemu
# Per-VM state: relative paths in the config (file_state, credentials_file, pkey, cert, file_apps,
# log_path) and everything Sunshine keeps in its own directory land in /var/lib/sunshine-qemu/<vm>/sunshine.
# CONFIGURATION_DIRECTORY moves only Sunshine's directory; XDG_CONFIG_HOME would also move the
# configuration of libvirt and the other libraries Sunshine and virsh load.
# log_path) and everything Sunshine keeps in its own directory land in /var/lib/sunshine-qemu/<vm>.
StateDirectory=sunshine-qemu/%i
StateDirectoryMode=0750
WorkingDirectory=%S/sunshine-qemu/%i
Environment=CONFIGURATION_DIRECTORY=%S/sunshine-qemu/%i
# ProtectSystem=strict leaves only the state directory writable, so caches (for example the GPU
# driver's shader cache under $HOME) go there too.
Environment=HOME=%S/sunshine-qemu/%i
Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i
# platf::init() verifies QEMU's display once at startup, so wait until the VM is up.
ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm /etc/sunshine-qemu/%I.conf
# The config is read-only here (root-owned and ProtectSystem=strict): settings saved in the web UI
# fail; edit /etc/sunshine-qemu/<vm>.conf as root and restart the unit instead.
ExecStart=/usr/bin/sunshine /etc/sunshine-qemu/%I.conf
TimeoutStartSec=180
Restart=on-failure
packaging/linux/qemu/user/sunshine-qemu@.service +4 −2
@@ -10,10 +10,12 @@
[Service]
Type=simple
# Per-VM state in ~/.local/state/sunshine-qemu/<vm>, so instances don't share ~/.config/sunshine.
# Per-VM state in ~/.local/state/sunshine-qemu/<vm>/sunshine, so instances don't share ~/.config/sunshine.
# CONFIGURATION_DIRECTORY moves only Sunshine's directory: XDG_CONFIG_HOME would also be inherited by
# virsh and libvirt, and a session libvirt they start would look for your VMs in the wrong place.
StateDirectory=sunshine-qemu/%i
WorkingDirectory=%S/sunshine-qemu/%i
Environment=CONFIGURATION_DIRECTORY=%S/sunshine-qemu/%i
Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i
# platf::init() verifies QEMU's display once at startup, so wait until the VM is up.
ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm %E/sunshine-qemu/%I.conf
ExecStart=/usr/bin/sunshine %E/sunshine-qemu/%I.conf
src/platform/linux/qemu/p2p.cpp +4 −0
@@ -32,6 +32,7 @@
constexpr auto qmp_prefix = "qmp:"sv; ///< Prefix of QMP socket addresses.
constexpr auto libvirt_prefix = "libvirt:"sv; ///< Prefix of libvirt domain addresses.
constexpr auto uri_option = "?uri="sv; ///< Separates the domain from the libvirt URI.
constexpr auto default_libvirt_uri = "qemu:///system"sv; ///< libvirt URI of `libvirt:<domain>` without `?uri=`.
/**
* @brief Describe the current `errno`.
@@ -338,6 +339,9 @@
}
if (address.starts_with(libvirt_prefix)) {
result.kind = display_address_t::kind_e::libvirt;
// the system libvirt by default, also for the unprivileged user of a system unit, whose own
// libvirt default would be qemu:///session; an empty ?uri= leaves the choice to libvirt
result.uri = default_libvirt_uri;
auto rest = address.substr(libvirt_prefix.size());
if (auto option = rest.find('?'); option != std::string_view::npos) {
if (!rest.substr(option).starts_with(uri_option)) {
src/platform/linux/qemu/p2p.h +2 −2
@@ -31,14 +31,14 @@
enum class kind_e {
bus, ///< A D-Bus bus where QEMU owns `org.qemu` (`unix:path=...`, or empty for the session bus).
qmp, ///< `qmp:<socket>`: peer-to-peer connection added through a QMP socket.
libvirt, ///< `libvirt:<domain>[?uri=<uri>]`: the display of a libvirt domain.
libvirt, ///< `libvirt:<domain>[?uri=<uri>]`: the display of a libvirt domain, on `qemu:///system` by default.
};
kind_e kind {kind_e::bus}; ///< Kind of address.
std::string bus; ///< Bus address for `kind_e::bus`; empty means the session bus.
std::string qmp_socket; ///< QMP socket path for `kind_e::qmp`.
std::string domain; ///< Domain name for `kind_e::libvirt`.
std::string uri; ///< libvirt connection URI for `kind_e::libvirt`; empty uses libvirt's default.
std::string uri; ///< libvirt connection URI for `kind_e::libvirt`: `qemu:///system` unless `?uri=` is given; empty (`?uri=`) uses libvirt's default.
};
/**
tests/CMakeLists.txt +11 −0
@@ -259,6 +259,17 @@
endif()
target_link_libraries(${PROJECT_NAME} ${TEST_LINK_LIBRARIES})
# systemd units, libvirt hook and helper scripts for per-VM instances (REQ-DEP-001, REQ-DEP-002):
# ctest --test-dir <build>/tests -R sunshine_qemu_packaging; set SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1 in CI so
# missing systemd-analyze or shellcheck fails instead of skipping
if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND SUNSHINE_ENABLE_QEMU)
find_program(BASH_EXECUTABLE bash)
if(BASH_EXECUTABLE)
add_test(NAME sunshine_qemu_packaging
COMMAND "${BASH_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/tests/packaging/linux/qemu/test_sunshine_qemu.sh")
endif()
endif()
target_compile_definitions(${PROJECT_NAME} PUBLIC ${SUNSHINE_DEFINITIONS} ${TEST_DEFINITIONS})
target_compile_options(${PROJECT_NAME} PRIVATE $<$<COMPILE_LANGUAGE:CXX>:${SUNSHINE_COMPILE_OPTIONS}>;$<$<COMPILE_LANGUAGE:CUDA>:${SUNSHINE_COMPILE_OPTIONS_CUDA};-std=c++17>) # cmake-lint: disable=C0301
target_link_options(${PROJECT_NAME} PRIVATE ${SUNSHINE_LINK_OPTIONS})
tests/e2e/qemu/multi_instance.sh +58 −12
@@ -25,6 +25,9 @@
# 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_STATE_LAYOUT absolute (default: state files at absolute paths) or unit (relative paths as in
# packaging/linux/qemu/example.conf, each instance with its own CONFIGURATION_DIRECTORY as
# the systemd units set it; asserts every state file lands in that instance's directory)
# 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
@@ -44,6 +47,7 @@
encoder="${E2E_ENCODER:-software}"
mdns="${E2E_MDNS:-1}"
vm2_transport="${E2E_VM2_TRANSPORT:-bus}"
state_layout="${E2E_STATE_LAYOUT:-absolute}"
for bin in "${sunshine_bin}" "${client_bin}"; do
if [[ ! -x "${bin}" ]]; then
@@ -58,6 +62,13 @@
exit 2
;;
esac
case "${state_layout}" in
absolute | unit) ;;
*)
echo "multi: E2E_STATE_LAYOUT must be absolute or unit" >&2
exit 2
;;
esac
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-multi.XXXXXX")"
artifacts="${E2E_ARTIFACTS:-${work}/artifacts}"
@@ -125,27 +136,43 @@
# 3. per-VM configuration, only through config files
declare -A port api_pass
declare -A appdir
for i in 1 2; do
state="${work}/state-${i}"
if [[ "${state_layout}" == unit ]]; then
# the unit's CONFIGURATION_DIRECTORY is the state directory; Sunshine's own directory is <dir>/sunshine
appdir[${i}]="${state}/sunshine"
mkdir -p "${appdir[${i}]}"
ln -sf "${appdir[${i}]}/sunshine.log" "${work}/sunshine-${i}.log"
paths="file_state = sunshine_state.json
credentials_file = sunshine_credentials.json
pkey = credentials/cakey.pem
cert = credentials/cacert.pem
file_apps = apps.json
log_path = sunshine.log"
else
appdir[${i}]="${state}"
mkdir -p "${state}"
paths="file_state = ${state}/sunshine_state.json
mkdir -p "${state}"
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"
fi
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
${paths}
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'
cat > "${appdir[${i}]}/apps.json" <<'EOF'
{"env": {}, "apps": [{"name": "Desktop", "image-path": "desktop.png"}]}
EOF
port[${i}]="$("${port_helper}" --dir "${work}/etc" --write "vm-${i}")"
@@ -154,16 +181,22 @@
echo "multi: base ports ${port[1]} and ${port[2]}" >&2
# 4. Sunshine instances, sharing one HOME
# run_sunshine <instance> <args...>
run_sunshine() {
local instance="$1"
shift
local configuration_directory=""
[[ "${state_layout}" == unit ]] && configuration_directory="${work}/state-${instance}"
exec env HOME="${work}/home" XDG_CONFIG_HOME= CONFIGURATION_DIRECTORY="${configuration_directory}" \
exec env HOME="${work}/home" XDG_CONFIG_HOME= ${avahi_bus:+DBUS_SYSTEM_BUS_ADDRESS="${avahi_bus}"} "${sunshine_bin}" "$@"
${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
(run_sunshine "${i}" "${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) &
(run_sunshine "${i}" "${work}/etc/vm-${i}.conf" > "${work}/sunshine-${i}.stdout" 2>&1) &
echo $! > "${work}/sunshine-${i}.pid"
done
for i in 1 2; do
@@ -332,9 +365,22 @@
unexpected="$(grep -v -E '^~/\.config$|^~/\.config/sunshine$|^~/\.nv(/ComputeCache.*)?$' "${artifacts}/home-files.txt" || true)"
[[ -z "${unexpected}" ]] || fail "files written under the shared HOME: $(tr '\n' ' ' <<< "${unexpected}")"
# 6g. unit layout: the relative paths of example.conf land in each instance's own directory
if [[ "${state_layout}" == unit ]]; then
for i in 1 2; do
find "${work}/state-${i}" -mindepth 1 | sed "s:^${work}/state-${i}:<state-${i}>:" > "${artifacts}/state-${i}-files.txt"
for file in sunshine_state.json sunshine_credentials.json credentials/cakey.pem credentials/cacert.pem apps.json sunshine.log; do
[[ -s "${appdir[${i}]}/${file}" ]] || fail "Sunshine ${i} didn't write ${file} into its state directory ${appdir[${i}]}"
done
done
if [[ -e "${work}/home/.config/sunshine" ]]; then
fail "an instance used ~/.config/sunshine despite CONFIGURATION_DIRECTORY: $(find "${work}/home/.config/sunshine" | tr '\n' ' ')"
fi
fi
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)}"
echo "multi: PASS {\"ports\":[${port[1]},${port[2]}],\"vm2_transport\":\"${vm2_transport}\",\"state_layout\":\"${state_layout}\",\"mdns\":$([[ -n "${avahi_bus}" ]] && echo true || echo false)}"
else
echo "multi: FAIL (${#failures[@]} problem(s))" >&2
fi
tests/e2e/README.md +10 −2
@@ -136,6 +136,7 @@
```bash
tests/e2e/qemu/multi_instance.sh # both VMs on private buses
E2E_VM2_TRANSPORT=qmp tests/e2e/qemu/multi_instance.sh # the second VM peer to peer
E2E_STATE_LAYOUT=unit tests/e2e/qemu/multi_instance.sh # relative state paths as in example.conf
```
Starts two pattern VMs and two Sunshine instances whose config files differ only in the VM address, `sunshine_name`
@@ -151,6 +152,9 @@
web UI lists one paired client, and the instances have different unique ids
- each instance streamed its own VM, no PulseAudio sinks were created, and nothing but the empty
`~/.config/sunshine` directory was written to the shared `HOME`
- with `E2E_STATE_LAYOUT=unit`: the configs use `example.conf`'s relative state paths and each instance runs with its
own `CONFIGURATION_DIRECTORY`, as the systemd units start it; every state file (state, credentials, certificate,
key, apps, log) is in that instance's `<dir>/sunshine` and nothing in `~/.config/sunshine`
mDNS uses `qemu/avahi_sandbox.sh`: `avahi-daemon` from packages fetched with `apt-get download` (not installed) runs
in a user, network and mount namespace with only a dummy interface, registered on a private stand-in for the system
@@ -161,10 +165,14 @@
```bash
tests/packaging/linux/qemu/test_sunshine_qemu.sh
ctest --test-dir cmake-build-debug/tests -R sunshine_qemu_packaging # the same, registered with CTest
SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1 ctest --test-dir cmake-build-debug/tests -R sunshine_qemu_packaging # CI
```
Not an E2E test, but it lives outside `test_sunshine` too: `systemd-analyze verify` on the units, the libvirt hook with a
fake `systemctl`, `sunshine-qemu-wait-vm` with fake `gdbus` and `virsh`, `sunshine-qemu-port`, and shellcheck
(REQ-DEP-001, REQ-DEP-002).
fake `systemctl`, `sunshine-qemu-wait-vm` with fake `gdbus`, `virsh` and QMP servers, `sunshine-qemu-port`, and
shellcheck (REQ-DEP-001, REQ-DEP-002). CMake registers it as the CTest test `sunshine_qemu_packaging` in builds with
`SUNSHINE_ENABLE_QEMU=ON`. Tests whose tool (`systemd-analyze`, `shellcheck`) is missing report `skip` and don't fail
the run, so a host without them checks less; with `SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1` they fail instead.
## GL display (DMABUF)
tests/packaging/linux/qemu/test_sunshine_qemu.sh +134 −14
@@ -6,5 +6,10 @@
#
# Usage: test_sunshine_qemu.sh [test-name...] (default: every test_* function)
# Prints "ok <name>", "skip <name>: <reason>" or "FAIL <name>: <reason>" per test; exits 1 on any failure.
# A test that sets a failure message fails even when it returns 0.
#
# Environment:
# SUNSHINE_QEMU_PKG_REQUIRE_TOOLS 1 turns skips (systemd-analyze or shellcheck missing) into failures,
# for CI where the tools must be there
# shellcheck disable=SC2329 # test functions are called by name from the runner at the end
set -uo pipefail
@@ -86,7 +91,10 @@
local unit
for unit in "${pkg}/sunshine-qemu@.service" "${pkg}/user/sunshine-qemu@.service"; do
grep -qE '^StateDirectory=sunshine-qemu/%i$' "${unit}" || fail "$(basename "$(dirname "${unit}")")/$(basename "${unit}"): no per-instance StateDirectory" || return 1
grep -qE '^Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i$' "${unit}" || fail "${unit}: Sunshine's appdata isn't per instance" || return 1
grep -qE '^Environment=CONFIGURATION_DIRECTORY=%S/sunshine-qemu/%i$' "${unit}" || fail "${unit}: Sunshine's appdata isn't per instance" || return 1
# XDG_CONFIG_HOME would be inherited by virsh and libvirt too (a session libvirt they start would
# read its domains from the per-VM directory)
! grep -qE '^Environment=XDG_CONFIG_HOME=' "${unit}" || fail "${unit}: sets XDG_CONFIG_HOME for every child process" || return 1
grep -qE '^ExecStart=/usr/bin/sunshine (/etc|%E)/sunshine-qemu/%I\.conf$' "${unit}" || fail "${unit}: ExecStart doesn't read the instance's config" || return 1
grep -qE '^ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm (/etc|%E)/sunshine-qemu/%I\.conf$' "${unit}" || fail "${unit}: doesn't wait for the VM before Sunshine's startup check" || return 1
grep -qE '^Restart=on-failure$' "${unit}" || fail "${unit}: no Restart=on-failure" || return 1
@@ -151,9 +159,23 @@
# @tag requirements: [REQ-DEP-002]
test_wait_vm_waits_for_the_qmp_socket() {
printf 'capture = qemu\nqemu_dbus_address = qmp:%s/vm.qmp\n' "${tmp}" > "${tmp}/vm.conf"
# a QMP server that greets every client, as QEMU does
(
sleep 1
python3 -c '
import socket, sys, time
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[1])
s.listen()
s.settimeout(5)
try:
while True:
c, _ = s.accept()
c.sendall(b"{\"QMP\": {\"version\": {}, \"capabilities\": []}}\r\n")
c.close()
except OSError:
pass
python3 -c 'import socket, sys, time; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(); time.sleep(5)' "${tmp}/vm.qmp"
' "${tmp}/vm.qmp"
) &
local server=$!
local start=${SECONDS} status
@@ -166,6 +188,36 @@
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_doesnt_take_a_stale_qmp_socket_for_a_running_vm() {
command -v python3 > /dev/null || {
failure="skip: python3 isn't installed"
return 2
}
# QEMU killed: its socket file stays, but nothing accepts connections on it
python3 -c 'import socket, sys; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "${tmp}/stale.qmp"
[[ -S "${tmp}/stale.qmp" ]] || fail "couldn't create a stale socket" || return 1
printf 'qemu_dbus_address = qmp:%s/stale.qmp\n' "${tmp}" > "${tmp}/vm.conf"
local status
SUNSHINE_QEMU_WAIT_TIMEOUT=1 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
expect_eq "${status}" 1 "exit status for a stale socket ($(cat "${tmp}/wait.log"))" || return 1
# a socket that accepts but never greets (another program) isn't QEMU either
python3 -c 'import socket, sys, time; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(); time.sleep(10)' "${tmp}/silent.qmp" &
local server=$!
for _ in $(seq 1 50); do
[[ -S "${tmp}/silent.qmp" ]] && break
sleep 0.05
done
printf 'qemu_dbus_address = qmp:%s/silent.qmp\n' "${tmp}" > "${tmp}/vm.conf"
SUNSHINE_QEMU_WAIT_TIMEOUT=1 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
kill "${server}" 2> /dev/null
wait "${server}" 2> /dev/null
expect_eq "${status}" 1 "exit status for a socket without a QMP greeting"
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_waits_until_qemu_owns_its_bus_name() {
# the fake gdbus answers "(false,)" for the first two calls
fake_bin gdbus "n=\$(cat '${tmp}/gdbus.count' 2> /dev/null || echo 0); echo \$((n + 1)) > '${tmp}/gdbus.count'; echo \"\$*\" >> '${tmp}/gdbus.log'; if ((n < 2)); then echo '(false,)'; else echo '(true,)'; fi"
@@ -195,7 +247,21 @@
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
expect_eq "${status}" 0 "exit status ($(cat "${tmp}/wait.log"))" || return 1
expect_eq "$(sort -u "${tmp}/virsh.log")" "-c qemu:///system domstate win11" "virsh calls"
expect_eq "$(sort -u "${tmp}/virsh.log")" "-c qemu:///system domstate win11" "virsh calls" || return 1
# without ?uri= the system libvirt, as Sunshine uses: an unprivileged unit user would get qemu:///session
rm -f "${tmp}/virsh.log"
printf 'qemu_dbus_address = libvirt:win11\n' > "${tmp}/vm.conf"
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
expect_eq "${status}" 0 "exit status without ?uri= ($(cat "${tmp}/wait.log"))" || return 1
expect_eq "$(sort -u "${tmp}/virsh.log")" "-c qemu:///system domstate win11" "virsh calls without ?uri=" || return 1
# ?uri=qemu:///session for session libvirt
rm -f "${tmp}/virsh.log"
printf 'qemu_dbus_address = libvirt:win11?uri=qemu:///session\n' > "${tmp}/vm.conf"
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
expect_eq "$(sort -u "${tmp}/virsh.log")" "-c qemu:///session domstate win11" "virsh calls with ?uri=qemu:///session"
}
# @tag requirements: [REQ-DEP-002]
@@ -218,17 +284,19 @@
printf 'qemu_dbus_address = libvirt:win11\n' > "${tmp}/libvirt.conf"
PATH="${tmp}/nobin" SUNSHINE_QEMU_WAIT_TIMEOUT=1 "$(command -v bash)" "${pkg}/sunshine-qemu-wait-vm" "${tmp}/libvirt.conf" 2> "${tmp}/wait.log" || fail "a libvirt address without virsh must not block: $(cat "${tmp}/wait.log")" || return 1
SUNSHINE_QEMU_WAIT_TIMEOUT=1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/missing.conf" 2> /dev/null && fail "a missing config must fail"
return 0
if SUNSHINE_QEMU_WAIT_TIMEOUT=1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/missing.conf" 2> /dev/null; then
fail "a missing config must fail"
return 1
fi
}
# @tag requirements: [REQ-DEP-001]
test_port_helper_allocates_non_overlapping_slots() {
local port="${pkg}/sunshine-qemu-port" etc="${tmp}/etc"
mkdir -p "${etc}"
printf 'capture = qemu\n' > "${etc}/a.conf"
printf 'capture = qemu\n' > "${etc}/b.conf"
printf 'port = 48150\n' > "${etc}/manual.conf"
printf 'capture = qemu\nsunshine_name = a\n' > "${etc}/a.conf"
printf 'capture = qemu\nsunshine_name = b' > "${etc}/b.conf" # no newline at the end
printf 'port = 48150\nsunshine_name = manual\n' > "${etc}/manual.conf"
expect_eq "$("${port}" --dir "${etc}" a)" 48089 "first VM, while b has no port yet (counts as 47989)" || return 1
expect_eq "$("${port}" --dir "${etc}" a)" 48089 "same answer again" || return 1
@@ -238,20 +306,65 @@
expect_eq "$(grep -c '^port' "${etc}/a.conf")" 1 "--write doesn't add a second port" || return 1
# slot 1 is taken by a; 48189 (48184-48210) doesn't overlap manual.conf's 48150 (48145-48171)
expect_eq "$("${port}" --dir "${etc}" --write b)" 48189 "second VM skips the used slot" || return 1
expect_eq "$(tail -n 2 "${etc}/b.conf")" $'sunshine_name = b\nport = 48189' "--write after a last line without a newline" || return 1
"${port}" --dir "${etc}" --check || fail "--check failed on non-overlapping ports" || return 1
printf 'port = 48200\nsunshine_name = c\n' > "${etc}/c.conf"
printf 'port = 48200\n' > "${etc}/c.conf"
local output
if output="$("${port}" --dir "${etc}" --check 2>&1)"; then
fail "--check passed with 48189 and 48200 overlapping"
output="$("${port}" --dir "${etc}" --check 2>&1)" && fail "--check passed with 48189 and 48200 overlapping" || true
return 1
fi
[[ "${output}" == *"overlaps"* ]] || fail "--check didn't name the overlap: ${output}" || return 1
expect_eq "$("${port}" --dir "${etc}" d)" 48289 "a new VM skips every overlapping slot" || return 1
if "${port}" --dir "${etc}" "../x" 2> /dev/null; then
"${port}" --dir "${etc}" "../x" 2> /dev/null && fail "accepted a path as VM name"
printf 'port = 65530\n' > "${etc}/c.conf"
"${port}" --dir "${etc}" --check 2> /dev/null && fail "--check accepted a range above 65535"
return 0
fail "accepted a path as VM name"
return 1
fi
printf 'port = 65530\nsunshine_name = c\n' > "${etc}/c.conf"
if "${port}" --dir "${etc}" --check 2> /dev/null; then
fail "--check accepted a range above 65535"
return 1
fi
}
# @tag requirements: [REQ-DEP-001]
test_port_helper_checks_that_names_differ() {
local port="${pkg}/sunshine-qemu-port" etc="${tmp}/etc"
mkdir -p "${etc}"
printf 'port = 48089\nsunshine_name = Gaming VM (1)\n' > "${etc}/a.conf"
printf 'port = 48189\nsunshine_name = Gaming VM (2)\n' > "${etc}/b.conf"
"${port}" --dir "${etc}" --check || fail "--check failed on distinct names" || return 1
local output
printf 'port = 48189\nsunshine_name = gaming vm (1) \n' > "${etc}/b.conf"
if output="$("${port}" --dir "${etc}" --check 2>&1)"; then
fail "--check passed with the same name in two configs (mDNS ignores case)"
return 1
fi
[[ "${output}" == *"same sunshine_name"* ]] || fail "--check didn't name the duplicate: ${output}" || return 1
# configs without sunshine_name all advertise the host name
printf 'port = 48089\n' > "${etc}/a.conf"
printf 'port = 48189\n' > "${etc}/b.conf"
if "${port}" --dir "${etc}" --check 2> /dev/null; then
fail "--check passed with two configs without sunshine_name"
return 1
fi
}
# @tag requirements: [REQ-DEP-001]
test_port_helper_allocates_for_a_copy_of_the_example_config() {
local port="${pkg}/sunshine-qemu-port" etc="${tmp}/etc"
mkdir -p "${etc}"
sed -e 's/^sunshine_name = .*/sunshine_name = vm-a/' "${pkg}/example.conf" > "${etc}/vm-a.conf"
sed -e 's/^sunshine_name = .*/sunshine_name = vm-b/' "${pkg}/example.conf" > "${etc}/vm-b.conf"
expect_eq "$("${port}" --dir "${etc}" --write vm-a)" 48089 "first copy" || return 1
expect_eq "$("${port}" --dir "${etc}" --write vm-b)" 48189 "second copy" || return 1
"${port}" --dir "${etc}" --check || fail "--check failed on two copies of example.conf"
}
# @tag requirements: [REQ-DEP-002]
test_scripts_are_shellcheck_clean() {
[[ -x "${shellcheck_bin}" ]] || {
@@ -273,6 +386,13 @@
failure=""
"${name}"
status=$?
if [[ ${status} == 0 && -n "${failure}" ]]; then
status=1
fi
if [[ ${status} == 2 && "${SUNSHINE_QEMU_PKG_REQUIRE_TOOLS:-0}" == 1 ]]; then
failure="${failure#skip: } (SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1)"
status=1
fi
case "${status}" in
0) echo "ok ${name}" ;;
2) echo "skip ${name}: ${failure#skip: }" ;;
tests/unit/platform/linux/qemu/test_p2p.cpp +8 −1
@@ -341,7 +341,14 @@
ASSERT_TRUE(libvirt);
EXPECT_EQ(libvirt->kind, qemu::display_address_t::kind_e::libvirt);
EXPECT_EQ(libvirt->domain, "win11");
EXPECT_TRUE(libvirt->uri.empty());
// the system libvirt, whoever runs Sunshine (libvirt's own default is qemu:///session for non-root users)
EXPECT_EQ(libvirt->uri, "qemu:///system");
EXPECT_EQ(qemu::describe(*libvirt), "libvirt domain [win11] on [qemu:///system]");
auto libvirt_default = qemu::parse_display_address("libvirt:win11?uri=", error);
ASSERT_TRUE(libvirt_default);
EXPECT_TRUE(libvirt_default->uri.empty()) << "an empty uri= leaves the choice to libvirt (LIBVIRT_DEFAULT_URI)";
EXPECT_EQ(qemu::describe(*libvirt_default), "libvirt domain [win11] on [default URI]");
auto with_uri = qemu::parse_display_address("libvirt:win11?uri=qemu:///system", error);
ASSERT_TRUE(with_uri);