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.