Phase 0: QEMU D-Bus display capture backend and end-to-end harness #1
issue-2-phase0
into main
Closes #2
Phase 0 of #1: a Sunshine capture backend for QEMU’s D-Bus display, the end-to-end harness every later phase keeps green, and the first latency numbers.
What’s in it
Backend (src/platform/linux/qemu/, built when SUNSHINE_ENABLE_QEMU=ON)
session.{h,cpp}:qemu::session_tconnects to the bus where QEMU ownsorg.qemu, reads the VM and console properties, and registers peer-to-peer listeners. QEMU is the authentication server on that socket. The listener advertisesorg.qemu.Display1.Listener.Unix.Map, so QEMU shares the framebuffer instead of copying it over D-Bus.frame_store.{h,cpp}: rebuilds the guest framebuffer (BGRX) fromScanout/UpdateandScanoutMap/UpdateMap, for the 32 bpp pixman formats.capture.cpp:display_tfor system-memory images, plus VAAPI/CUDA upload through the existing RAM encode path. Frames are pushed only on damage and stamped with the time QEMU’s call arrived. A resize or a VM that goes away returnsreinit.source::QEMUinmisc.cpp. It’s used only whencapture = qemu, never auto-detected (REQ-CMP-001).- New config key
qemu_dbus_address, with docs, web UI andenlocale. Empty means the session bus. third-party/qemu-dbus-display/: QEMU 11.1.1dbus-display1.xml, preprocessed for Linux, with provenance and license notes. Bindings are generated at configure time.
Tests
tests/unit/platform/linux/qemu/: 37 gtests against an in-process fake QEMU on a privatedbus-daemon, covering the session, frame store, capture, and “never connects unless selected”. All pass across--gtest_repeat=5.tests/e2e/: a boot-sector guest that draws a known pattern,run_vm.sh,e2e_stream.sh, and a headless Moonlight client (standalone CMake project: GameStream pairing/launch, moonlight-common-c, FFmpeg decode). Seetests/e2e/README.md.
Deviations from the issue text (reasons in JOURNAL.md)
- Console selection uses the existing
output_nameinstead of a newqemu_consolekey.display_names()returns the graphical console ids, sooutput_name = 1(or a label) works the same way it does for monitors. That’s one fewer option for upstream review. - Bindings are generated at configure time (
execute_process, likeGEN_WAYLAND), with the XML inCMAKE_CONFIGURE_DEPENDS. A build-time rule wouldn’t reach thetest_sunshinetarget without extra custom targets. - REQ-NFR-001 is still draft. Its target is about the DMABUF + hardware encoder path. This host has an RTX 4090, but it runs WSL2, which has no DRM render node, so QEMU’s GL display (needed for DMABUF) won’t start. I recorded the measured shm numbers for NVENC and software encoding, plus proposed targets, in the requirement.
- The E2E harness was written after the capture code, not strictly first. The red check below shows it fails without the feature.
Bugs found and fixed along the way
- Deadlock: QEMU calls a new listener synchronously right after replying to
RegisterListener. With one GLib thread, a second registration deadlocked both processes until the call timed out. Listener connections now dispatch on their own thread. - Hang: connection setup and authentication have no timeout of their own, so a
qemu_dbus_addressthat accepts but never authenticates would hang Sunshine forever. A deadline now cancels them. - Missed disconnect: capture didn’t notice a dropped listener while the guest wasn’t drawing. It now returns
reinit.
Verification (Ubuntu 24.04 on WSL2, gcc-14, KVM, RTX 4090)
| Check | Result |
|---|---|
| E2E, QEMU 11.1.1 (shared memory map) | PASS: quadrants [253,0,0] [0,254,0] [0,0,254] [255,255,255] |
| E2E, QEMU 8.2.2 distro (D-Bus messages) | PASS, same colors |
E2E with encoder = nvenc (CUDA build, h264_nvenc), both QEMU versions |
PASS |
| E2E against Sunshine built without the backend (red check) | FAIL as expected: launch returns 503 |
test_sunshine, SUNSHINE_ENABLE_QEMU=ON |
592 passed, 14 skipped, 0 failed |
test_sunshine, SUNSHINE_ENABLE_QEMU=OFF |
555 passed, 14 skipped, 0 failed (same as clean upstream) |
| clang-format 23.1.1 (pip, as upstream lint) | clean |
doxygen 1.18.0 with upstream doxyconfig (FAIL_ON_WARNINGS) |
exit 0, no warnings |
anvil requirement status |
passes. Linked: CAP-001 (23), CAP-002 (12), CMP-001 (2), E2E-001, NFR-001 |
Both test_sunshine runs exit 1 because of three PlatformTestSuite setup failures (Audio, MouseHID, Encoder). platf::init() finds no capture method under Xvfb on WSL2. The clean upstream tree fails the same way; see JOURNAL.md.
Latency (REQ-NFR-001): Release build, 1920x1080@60 stream, 640x400 guest framebuffer, about 330 samples each. Measured from receiving QEMU’s call to sending the encoded frame, as reported to Moonlight:
| Transport | Encoder | p50 | p95 |
|---|---|---|---|
| Shared memory map (QEMU 11.1.1) | NVENC | 11.1 ms | 20.4 ms |
| D-Bus messages (QEMU 8.2.2) | NVENC | 12.2 ms | 19.5 ms |
| Shared memory map (QEMU 11.1.1) | software x264 | 14.1 ms | 21.4 ms |
| D-Bus messages (QEMU 8.2.2) | software x264 | 14.1 ms | 21.1 ms |
Most of this comes from capture pacing, not encoding. The capture loop wakes on a fixed 16.7 ms tick, so new damage waits 0–16.7 ms (about 8 ms at p50, 16 ms at p95) before it’s copied. That plus ~3 ms of NVENC matches the numbers above. Waking the capture thread on damage should remove most of it; that’s suggested for #3.
Not done here
- DMABUF, cursor, full mode-change and reboot handling (#3). DMABUF needs native Linux with a render node; it can’t run under WSL2. Audio (#4) and input (#5) still go through the host backends.
- Damage-driven capture wakeup (the latency finding above).
- CI on Anvil (#9). A local run needs QEMU with
-display dbus,dbus-daemon, and gcc-14 on Ubuntu 24.04, because upstream’skmsgrab.cppneedsstd::ranges::to.
🤖 Generated with Claude Code