fangorn/sunshine-qemu
public
Journal (fork-only, not upstreamable)
Working notes for the QEMU D-Bus backend. Keep out of the upstream series (#8).
2026-09-12 — Phase 0 (#2)
Environment
- Host: Ubuntu 24.04.4 on WSL2 (kernel 6.18), 20 cores,
/dev/kvmpresent, NVIDIA RTX 4090 (Windows driver 610.88, exposed through/dev/dxgand/usr/lib/wsl/lib, which includelibcudaandlibnvidia-encode). CUDA toolkits 12.8 and 13.2 are in/usr/local. - WSL2 has no DRM render node (
/dev/driis absent): no VAAPI, no GBM, and no DMABUF export. QEMU refuses-device virtio-vga-gl -display dbus,gl=onwith “egl: no drm render node available”. So the DMABUF zero-copy path (#3) can’t run here; NVENC encoding of the shm path can. An earlier version of these notes wrongly said this host has no GPU because/dev/driwas missing. - Toolchain: upstream needs gcc-14 on Ubuntu 24.04 (
scripts/linux_build.shpicks it;src/platform/linux/kmsgrab.cppusesstd::ranges::to). The default gcc 13 fails to build a clean upstream tree. Configure withCC=gcc-14 CXX=g++-14. - CUDA isn’t installed: configure with
-DCUDA_FAIL_ON_MISSING=OFF. - Distro QEMU is 8.2.2. Its D-Bus display has no
Listener.Unix.Mapand noListener.Unix.ScanoutDMABUF2(checked the strings inui-dbus.so), so it only sendsScanout/Updatepixel copies. Built QEMU 11.1.1 from the release tarball into~/.cache/sunshine-qemu/qemu-installwith--target-list=x86_64-softmmu --enable-dbus-display --enable-kvm --enable-slirp --enable-opengl --enable-modules.
Baseline (clean upstream dd7a1f7, SUNSHINE_ENABLE_QEMU=OFF)
xvfb-run -a cmake-build-noqemu/tests/test_sunshine: 569 tests, 555 passed, 14 skipped,
exit code 1. The failure is PlatformTestSuite::SetUpTestSuite (tests/tests_common.h:162):
platf::init() logs “Unable to initialize capture method” on WSL2 (no KMS, portal interfaces
missing, X11 capture unavailable under Xvfb here). That makes the MouseHIDTest, audio and video
platform suites skip and the binary exit 1. Not caused by this work.
Decisions and deviations from the issue text
- Console selection uses Sunshine’s existing
output_name, not a newqemu_consolekey.platf::display_names()returns the graphical console ids, sooutput_name = 1(or a label) selects a console exactly like it selects a monitor for other backends. One fewer config key for upstream review; an unknown name falls back to the first graphical console with a warning. - Bindings are generated at configure time (
execute_process, likeGEN_WAYLAND), with the XML inCMAKE_CONFIGURE_DEPENDS. A build-timeadd_custom_commandwould not propagate to thetest_sunshinetarget intests/without extra custom targets. - VM gone / listener closed / scanout size change all return
capture_e::reinit.video.cppthen re-creates the display in a retry loop, so a VM restart doesn’t end the stream. - Frames are pushed only when QEMU reports damage; the encoder’s minimum-FPS repeat covers static
screens.
img_t::frame_timestampis the receipt time of the QEMU call, so Sunshine’s existingframe_processing_latency(logged at debug and sent to Moonlight in each frame header) measures “listener callback receipt → packet out” for REQ-NFR-001. - Connection setup and the listener handshake have a deadline (a watchdog cancels the
GCancellable). Without it, aqemu_dbus_addressthat accepts but never authenticates hangs Sunshine forever. - Two GLib threads per session. After replying to
RegisterListener, QEMU makes synchronous calls on the new listener (propertyGetAll,ScanoutMap). With one thread, a secondRegisterListenerissued while QEMU waited on the first listener deadlocked both processes until the call timeout (found byQemuSessionTest.SupportsMultipleListenersAndReregistration). The bus thread makes blocking calls; listener connections are dispatched on a thread that never blocks on QEMU.
Results
- Unit tests: 37 QEMU tests (session against a fake QEMU on a private dbus-daemon, frame store,
capture, REQ-CMP-001 “never connects unless selected”), stable across
--gtest_repeat=5. - E2E (
tests/e2e/qemu/e2e_stream.sh), KVM, software encoding:- QEMU 11.1.1, shared memory map path: PASS, quadrants
[253,0,0] [0,254,0] [0,0,254] [255,255,255]. - QEMU 8.2.2 (distro), D-Bus message path: PASS, same colors.
- Red check: the same harness against a Sunshine built without the backend fails at
launchwith HTTP 503 “Failed to initialize video capture/encoding”.
- QEMU 11.1.1, shared memory map path: PASS, quadrants
- The harness was written after the capture spike rather than strictly first; the red check above is the evidence that it fails without the feature.
REQ-NFR-001 measurements (host processing latency: QEMU call receipt → encoded frame sent)
Release build, gcc-14, 20-core host under WSL2, KVM, guest framebuffer 640x400 scaled by
Sunshine, ~330 samples per run over 18 s. NVENC runs use a CUDA build
(-DCMAKE_CUDA_COMPILER=/usr/local/cuda-13.2/bin/nvcc, encoder = nvenc, h264_nvenc with the
RAM→CUDA upload) on the RTX 4090.
| Transport (QEMU) | Encoder | Stream | p50 | p95 |
|---|---|---|---|---|
| Shared memory map (11.1.1) | NVENC | 1920x1080@60 | 11.1 ms | 20.4 ms |
| D-Bus messages (8.2.2) | NVENC | 1920x1080@60 | 12.2 ms | 19.5 ms |
| Shared memory map (11.1.1) | software x264 | 1920x1080@60 | 14.1 ms | 21.4 ms |
| D-Bus messages (8.2.2) | software x264 | 1920x1080@60 | 14.1 ms | 21.1 ms |
| Shared memory map, Debug build | software x264 | 1280x800@30 | 17.2 ms | 32.9 ms |
| D-Bus messages, Debug build | software x264 | 1280x800@30 | 20.2 ms | 34.6 ms |
The transports tie because the guest framebuffer is small.
Capture pacing dominates, not encoding. The capture loop wakes on a fixed tick of one frame interval (16.7 ms at 60 fps) and copies whatever damage arrived since the last tick. Damage lands uniformly within the interval, so it waits 0–16.7 ms (p50 ≈ 8 ms, p95 ≈ 16 ms) before the copy. Add ~3 ms for NVENC and that predicts p50 ≈ 11 ms and p95 ≈ 19–20 ms, which matches the measurements. Hardware encoding only saves the 2–3 ms that x264 spends. Waking the capture thread on damage (a condition variable with the next frame deadline as timeout) should remove most of the wait. That belongs with the pacing work in #3.
The draft “p95 ≤ 10 ms on the DMABUF path” can’t be measured here, because WSL2 has no DRM render node for QEMU’s GL display. It stays for #3 on native Linux.
Seen during encoder probing with the CUDA build, not caused by this backend: “cuda::cuda_t doesn’t support any format other than AV_PIX_FMT_NV12 and AV_PIX_FMT_YUV444P” (the RAM→CUDA upload rejects 10-bit probes, as it does for x11 capture). The 8-bit H.264 stream is unaffected.
Observations for later phases
- Encoder probing creates many short-lived displays;
qemu::shared_sessiononly caches aweak_ptr, so each probe reconnects (~2-5 ms each). Cheap, but a short keep-alive would avoid it. platf::init()runsverify_qemu()at Sunshine startup, so QEMU must already ownorg.qemuwhen Sunshine starts. Relevant for the systemd/libvirt ordering in #6.- In the E2E runs, PulseAudio isn’t running (“Failed to create client: Daemon not running”) and libvirtualhid gamepads are unavailable; video streaming is unaffected. #4 and #5 replace both.