ref:main

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/kvm present, NVIDIA RTX 4090 (Windows driver 610.88, exposed through /dev/dxg and /usr/lib/wsl/lib, which include libcuda and libnvidia-encode). CUDA toolkits 12.8 and 13.2 are in /usr/local.
  • WSL2 has no DRM render node (/dev/dri is absent): no VAAPI, no GBM, and no DMABUF export. QEMU refuses -device virtio-vga-gl -display dbus,gl=on with “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/dri was missing.
  • Toolchain: upstream needs gcc-14 on Ubuntu 24.04 (scripts/linux_build.sh picks it; src/platform/linux/kmsgrab.cpp uses std::ranges::to). The default gcc 13 fails to build a clean upstream tree. Configure with CC=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.Map and no Listener.Unix.ScanoutDMABUF2 (checked the strings in ui-dbus.so), so it only sends Scanout/Update pixel copies. Built QEMU 11.1.1 from the release tarball into ~/.cache/sunshine-qemu/qemu-install with --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 new qemu_console key. platf::display_names() returns the graphical console ids, so output_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, like GEN_WAYLAND), with the XML in CMAKE_CONFIGURE_DEPENDS. A build-time add_custom_command would not propagate to the test_sunshine target in tests/ without extra custom targets.
  • VM gone / listener closed / scanout size change all return capture_e::reinit. video.cpp then 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_timestamp is the receipt time of the QEMU call, so Sunshine’s existing frame_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, a qemu_dbus_address that accepts but never authenticates hangs Sunshine forever.
  • Two GLib threads per session. After replying to RegisterListener, QEMU makes synchronous calls on the new listener (property GetAll, ScanoutMap). With one thread, a second RegisterListener issued while QEMU waited on the first listener deadlocked both processes until the call timeout (found by QemuSessionTest.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 launch with HTTP 503 “Failed to initialize video capture/encoding”.
  • 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_session only caches a weak_ptr, so each probe reconnects (~2-5 ms each). Cheap, but a short keep-alive would avoid it.
  • platf::init() runs verify_qemu() at Sunshine startup, so QEMU must already own org.qemu when 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.