@@ -435,3 +435,87 @@
- Not done here: typing and mouse from a real Moonlight client (no GUI client on this host),
gcov coverage.
## 2026-09-12 — Phase 1d (#6): multi-VM deployment
Same host (WSL2, KVM, no libvirt installed, systemd 255 running as PID 1 but no units installed).
### libvirt investigation (answered on #6 before coding)
Static reading of libvirt v12.7.0 (and v10.0.0 for Ubuntu 24.04), QEMU 11.1.1, dbus 1.14.10, glib 2.80, Ubuntu's
libvirt 10.0.0 debs (unpacked, not installed). Nothing was run against a real libvirt. Findings that drove the design:
- For `<graphics type='dbus'>` without `p2p`/`address`, libvirt starts a private dbus-daemon per domain at
`/run/libvirt/qemu/dbus/<id>-<name>-dbus.sock` (id changes every start). Its policy only admits the bus owner's uid
(the domain's DAC user) and root from 11.2.0; group membership passes the 0770 directory but dbus-daemon still
refuses. So a separately-running Sunshine can't use bus mode on system libvirt.
- `p2p='yes'` + `virDomainOpenGraphicsFD` (QMP `getfd` + `add_client protocol=@dbus-display`, socket labelled for
the domain, allowed by Ubuntu's AppArmor abstraction) works by design. QEMU serves one p2p control connection; a
newer one unexports the objects from the older one.
- libvirt itself registers no listener; its qemu-rdp (11.2+) and qemu-vnc (12.5+) helpers may.
Before coding, a Python gi script confirmed the QMP sequence on real QEMU 11.1.1 and 8.2.2: `getfd` with
SCM_RIGHTS, `add_client`, then a D-Bus client handshake on the other end reads the VM properties.
### Decisions and deviations from the issue text
- **mDNS was the only collision.** Two instances with separate config files (ports 100 apart, own state paths,
`upnp = disabled`) streamed concurrently without code changes, except that the Avahi service instance name came
from the host name: the second instance was renamed "<host> #2" (red run of `multi_instance.sh`). It now follows
`sunshine_name` (`net::mdns_service_name()`, which falls back to the host name, so a default single instance is
unchanged). Only the Linux publisher was changed; Windows still uses the host name, macOS lets the system pick.
- Web UI ports, UPnP, hard-coded paths and PulseAudio were checked by the same script: every socket of each instance
was inside `port - 5 .. port + 21`, nothing but an empty `~/.config/sunshine` was written to a shared HOME, and no
PulseAudio connection or null sink was made with `capture = qemu`. The units still set `XDG_CONFIG_HOME` per
instance so relative paths and Sunshine's own directory are per VM.
- **mDNS verification without root:** `avahi-daemon` from `apt-get download` debs runs in `unshare -rnm` with a dummy
interface, `/run` on tmpfs and a bind-mounted passwd containing an `avahi` user, registered on a private stand-in
system bus (EXTERNAL auth fails from inside the user namespace, so the bus also allows ANONYMOUS). Sunshine and
avahi-browse reach it through `DBUS_SYSTEM_BUS_ADDRESS`. Nothing is sent on the host network.
- **p2p implemented** (the issue's "if p2p is the only practical route"). `qemu_dbus_address` gained `qmp:<socket>`
and `libvirt:<domain>[?uri=]` instead of a new config key. libvirt is `dlopen()`ed (`libvirt.so.0`), so Sunshine
doesn't link or require it; for a non-p2p libvirt display the bus address is taken from the live XML (session
libvirt, where the uid matches). QEMU's pid for the render-node check comes from SO_PEERCRED on the QMP socket; it's
unknown through libvirt.
- `shared_session()` still caches a weak pointer, so each encoder probe after the last user dropped the session makes
a new `add_client` connection. It works (the E2E probes several times), but through libvirt each probe is an RPC.
- **Port allocation:** helper plus convention. `sunshine-qemu-port` picks the lowest slot (48089 + 100k) whose range
doesn't overlap another config in the directory, `--write` appends it, `--check` reports overlaps.
- **Start order:** Sunshine doesn't exit when `verify_qemu()` fails at startup; it logs "Unable to initialize capture
method" and "Unable to find display or encoder during startup" and keeps serving the web UI without being able to
stream, even after the VM appears (checked with a `qmp:` address to a missing socket). So `Restart=on-failure`
alone doesn't help: the units' `ExecStartPre` (`sunshine-qemu-wait-vm`) waits for `org.qemu` / the QMP socket /
`virsh domstate running`, and fails after 120 s so systemd retries.
- **Hook opt-in** is the presence of `/etc/sunshine-qemu/<domain>.conf` (no `<metadata>` element). It uses
`systemctl --no-block` (a hook must not wait on something that calls back into libvirt), acts on `started`,
`reconnect` and `stopped`, drains the XML on stdin, and always exits 0.
- The system unit's `Documentation=` and `SupplementaryGroups=` were dropped: no published URL exists, and a missing
group fails the unit (sysusers adds the video/render memberships instead).
### Results
- Red checks: `multi_instance.sh` against the phase-1 build before the mDNS change: FAIL only on the mDNS assertions
(services "DESKTOP-12GQBKK" and "DESKTOP-12GQBKK #2", "Service name collision" logged); `MdnsServiceNameTest` failed
against a stub; 14 of 15 new p2p/libvirt tests failed against stubs (`PeerSessionFailsWhenNoQemuAnswers` passes
trivially). Every packaging test assertion was confirmed by a mutated script (hook without stdin drain → writer
exit 141; without `--no-block`; without opt-in; wait helper not waiting; port overlap off by one; unit without
`ExecStartPre`; shared StateDirectory; unknown unit key → systemd-analyze warning).
- `multi_instance.sh`, KVM, QEMU 11.1.1, Debug, software: PASS with both VMs on buses and with VM 2 over QMP p2p:
services `sq-vm-1`/`sq-vm-2` on 48089/48189, sockets tcp 48084/48089/48090/48110 + udp 48098-48100 (and +100),
PairStatus 1 on the own instance and HTTP 401 on the other, one paired client each.
- `multi_instance.sh` with the Release + CUDA build, NVENC, 1920x1080@60, 300 frames each, VM 2 over QMP: PASS, both
streams p50 3.7 ms / p95 10.8-10.9 ms. The first run failed the shared-HOME check on `~/.nv/ComputeCache` (the
NVIDIA driver's CUDA kernel cache, safe to share); the check now allows it. Also PASS on distro QEMU 8.2.2 with VM 2
over QMP.
- Full `test_sunshine`: Debug 707 tests, 691 passed, 16 skipped, 0 failed tests; `cmake-build-noqemu` 570 tests, 556
passed, 0 failed tests (baseline plus `MdnsServiceNameTest`); both exit 1 only for the known AudioTest,
MouseHIDTest and EncoderTest setup failures. `cmake-build-release-cuda` builds.
- `E2E_TRANSPORT=qmp`: pattern PASS on QEMU 11.1.1 and 8.2.2; on 11.1.1 also `E2E_AUDIO=1 E2E_SESSIONS=2` (999.997 Hz,
0 silent blocks), `E2E_INPUT=absolute` and `E2E_RESET_AFTER_FRAMES=60` (4 displays, 38 changes, 91 ms max gap).
- Unit tests: `Qemu*` 135 passed, 2 skipped (DMABUF GPU tests), 3 of 3 with `--gtest_repeat=3`.
- `tests/packaging/linux/qemu/test_sunshine_qemu.sh`: 13 ok, including `systemd-analyze verify` (system and
`--user`) with no output, and shellcheck.
- Doxygen 1.18.0 as upstream with `docs/qemu.md` added to INPUT: exit 0, no warnings.
- Not verified here: a real libvirt domain (virt-manager), `virDomainOpenGraphicsFD` against real libvirt, the polkit
rule's action names, SELinux/AppArmor, units running under a real systemd instance, and a GUI Moonlight client
seeing both hosts through real mDNS.