ref:c2936260d945e01e89af97fd7cdae52592c63c45

feat(linux): capture QEMU VMs over the D-Bus display (capture = qemu)

Add a capture backend for VMs started with -display dbus: - qemu::session_t connects to the bus where QEMU owns org.qemu, reads the VM and console properties, and registers peer-to-peer listeners (QEMU is the authentication server on the socket). The listener advertises org.qemu.Display1.Listener.Unix.Map so QEMU shares the framebuffer instead of copying it over D-Bus. Listener connections run on their own GLib thread because QEMU calls them synchronously right after RegisterListener. - qemu::frame_store_t rebuilds the guest framebuffer from Scanout/Update and ScanoutMap/UpdateMap for the 32 bpp pixman formats. - The display pushes frames only on damage, stamps them with the time QEMU's call arrived, and returns reinit on resize or when the VM goes away. - New qemu_dbus_address option; consoles are selected with output_name. The backend is only used when capture = qemu and never auto-detected. Tests run against an in-process fake QEMU on a private dbus-daemon. Refs #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: c2936260d945e01e89af97fd7cdae52592c63c45
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 21:14
Parents: 7bbf6d6
17 files changed +3590 -1
Type
cmake/compile_definitions/linux.cmake +43 −0
@@ -286,6 +286,49 @@
message(FATAL_ERROR "SUNSHINE_ENABLE_KWIN requires SUNSHINE_ENABLE_WAYLAND — KWin capture disabled")
endif()
# QEMU D-Bus display (capture = qemu)
set(QEMU_FOUND OFF)
if(${SUNSHINE_ENABLE_QEMU})
pkg_check_modules(QEMU_GIO gio-2.0>=2.64 gio-unix-2.0>=2.64)
find_program(GDBUS_CODEGEN_EXECUTABLE gdbus-codegen)
if(QEMU_GIO_FOUND AND GDBUS_CODEGEN_EXECUTABLE)
set(QEMU_FOUND ON)
else()
message(WARNING
"QEMU D-Bus display capture disabled: it needs gio-2.0 >= 2.64, gio-unix-2.0 >= 2.64 and gdbus-codegen")
endif()
endif()
if(QEMU_FOUND)
set(QEMU_DBUS_XML "${CMAKE_SOURCE_DIR}/third-party/qemu-dbus-display/dbus-display1.xml")
set(QEMU_DBUS_GENERATED_DIR "${CMAKE_BINARY_DIR}/generated-src/qemu")
file(MAKE_DIRECTORY "${QEMU_DBUS_GENERATED_DIR}")
# generated at configure time, like the wayland protocols; editing the XML re-runs cmake
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${QEMU_DBUS_XML}")
execute_process(
COMMAND "${GDBUS_CODEGEN_EXECUTABLE}"
--glib-min-required 2.64
--output-directory "${QEMU_DBUS_GENERATED_DIR}"
--interface-prefix org.qemu.
--c-namespace QemuDBus
--generate-c-code dbus-display1
"${QEMU_DBUS_XML}"
RESULT_VARIABLE QEMU_DBUS_CODEGEN_RESULT)
if(NOT QEMU_DBUS_CODEGEN_RESULT EQUAL 0)
message(FATAL_ERROR "gdbus-codegen failed for ${QEMU_DBUS_XML}")
endif()
add_compile_definitions(SUNSHINE_BUILD_QEMU)
list(APPEND PLATFORM_TARGET_FILES
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.c"
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/capture.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.cpp")
endif()
if(NOT ${CUDA_FOUND}
AND NOT ${LIBDRM_FOUND}
AND NOT ${LIBVA_FOUND}
docs/configuration.md +31 −1
@@ -2195,7 +2195,7 @@
@endcode</td>
</tr>
<tr>
<td rowspan="6">Choices</td>
<td rowspan="7">Choices</td>
<td>nvfbc</td>
<td>Use NVIDIA Frame Buffer Capture to capture direct to GPU memory. This is usually the fastest method for
NVIDIA cards. NvFBC does not have native Wayland support and does not work with XWayland.
@@ -2223,6 +2223,13 @@
@note{Applies to FreeBSD and Linux only.}</td>
</tr>
<tr>
<td>qemu</td>
<td>Stream a QEMU virtual machine started with `-display dbus`, with no software in the guest.
Select the bus with [qemu_dbus_address](#qemu_dbus_address) and the console with
[output_name](#output_name). This method is never selected automatically.
@note{Applies to Linux only.}</td>
</tr>
<tr>
<td>ddx</td>
<td>Use DirectX Desktop Duplication API to capture the display. This is well-supported on Windows machines.
@note{Applies to Windows only.}</td>
@@ -2232,5 +2239,28 @@
<td>(beta feature) Use Windows.Graphics.Capture to capture the display.
@note{Applies to Windows only.}
@attention{This capture method is not compatible with the Sunshine service.}</td>
</tr>
</table>
### qemu_dbus_address
<table>
<tr>
<td>Description</td>
<td colspan="2">
D-Bus address of the bus that a QEMU VM's display is on. Only used when [capture](#capture) is `qemu`.
Use the same address given to QEMU with `-display dbus,addr=`.
@note{Applies to Linux only.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">Empty, which uses the session bus.</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
qemu_dbus_address = unix:path=/run/sunshine-qemu/vm0/bus.sock
@endcode</td>
</tr>
</table>
src/config.cpp +2 −0
@@ -780,6 +780,7 @@
{}, // encoder
{}, // adapter_name
{}, // output_name
{}, // qemu_dbus_address
{
video_t::dd_t::config_option_e::disabled, // configuration_option
@@ -1698,6 +1699,7 @@
string_f(vars, "encoder", video.encoder);
string_f(vars, "adapter_name", video.adapter_name);
string_f(vars, "output_name", video.output_name);
string_f(vars, "qemu_dbus_address", video.qemu_dbus_address);
generic_f(vars, "dd_configuration_option", video.dd.configuration_option, dd::config_option_from_view);
generic_f(vars, "dd_resolution_option", video.dd.resolution_option, dd::resolution_option_from_view);
src/config.h +1 −0
@@ -137,6 +137,7 @@
std::string encoder; ///< Encoder backend name selected by configuration.
std::string adapter_name; ///< Display adapter name selected in configuration.
std::string output_name; ///< Display output name selected in configuration.
std::string qemu_dbus_address; ///< D-Bus address of the bus QEMU's display is on; empty uses the session bus.
/**
* @brief Display-device integration settings.
src/platform/linux/misc.cpp +28 −0
@@ -1125,6 +1125,9 @@
#ifdef SUNSHINE_BUILD_PORTAL
PORTAL, ///< XDG PORTAL
#endif
#ifdef SUNSHINE_BUILD_QEMU
QEMU, ///< QEMU D-Bus display
#endif
MAX_FLAGS ///< The maximum number of flags
};
} // namespace source
@@ -1205,10 +1208,21 @@
}
#endif
#ifdef SUNSHINE_BUILD_QEMU
std::vector<std::string> qemu_display_names();
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config);
bool verify_qemu();
#endif
/**
* @brief List display names accepted by the selected capture backend.
*/
std::vector<std::string> display_names(mem_type_e hwdevice_type) {
#ifdef SUNSHINE_BUILD_QEMU
if (sources[source::QEMU]) {
return qemu_display_names();
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
// display using NvFBC only supports mem_type_e::cuda
if (sources[source::NVFBC] && hwdevice_type == mem_type_e::cuda) {
@@ -1275,6 +1289,13 @@
drop_elevated_privileges(false);
}
#ifdef SUNSHINE_BUILD_QEMU
if (sources[source::QEMU]) {
BOOST_LOG(info) << "Screencasting with QEMU D-Bus display"sv;
return qemu_display(hwdevice_type, display_name, config);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (sources[source::NVFBC] && hwdevice_type == mem_type_e::cuda) {
BOOST_LOG(info) << "Screencasting with NvFBC"sv;
@@ -1371,5 +1392,12 @@
#ifdef SUNSHINE_BUILD_KWIN
if (((config::video.capture.empty() && sources.none()) || config::video.capture == "kwin") && verify_kwin()) {
sources[source::KWIN] = true;
}
#endif
#ifdef SUNSHINE_BUILD_QEMU
// Only used when explicitly selected; a VM display is never auto-detected.
if (config::video.capture == "qemu" && verify_qemu()) {
sources[source::QEMU] = true;
}
#endif
src/platform/linux/qemu/capture.cpp +270 −0
@@ -1,0 +1,270 @@
/**
* @file src/platform/linux/qemu/capture.cpp
* @brief Definitions for the QEMU D-Bus display capture backend (`capture = qemu`).
*/
// standard includes
#include <cstring>
#include <mutex>
// local includes
#include "frame_store.h"
#include "session.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/platform/linux/misc.h"
#include "src/video.h"
#ifdef SUNSHINE_BUILD_VAAPI
#include "src/platform/linux/vaapi.h"
#endif
#ifdef SUNSHINE_BUILD_CUDA
#include "src/platform/linux/cuda.h"
#endif
using namespace std::literals;
namespace qemu {
/**
* @brief System-memory image owned by the QEMU capture backend.
*/
struct img_t: public platf::img_t {
~img_t() override {
delete[] data;
data = nullptr;
}
};
/**
* @brief Display backend that streams a QEMU console from shared memory scanouts.
*/
class display_t: public platf::display_t {
public:
/**
* @brief Create an uninitialized display.
*
* @param mem_type Memory type the encoder expects.
*/
explicit display_t(platf::mem_type_e mem_type):
mem_type {mem_type} {
}
/**
* @brief Connect to QEMU, register a listener on the selected console, and wait for a frame.
*
* @param display_name Console id or label; empty selects the first graphical console.
* @param config Stream configuration.
* @return 0 on success, -1 on failure.
*/
int init(const std::string &display_name, const ::video::config_t &config) {
delay = ::video::capture_frame_interval(config);
session = shared_session(config::video.qemu_dbus_address);
if (!session) {
return -1;
}
auto vm = session->vm();
auto console = find_console(vm, display_name);
if (!console) {
BOOST_LOG(warning) << "qemu: console ["sv << display_name << "] not found, using the first graphical console"sv;
console = find_console(vm, "");
}
if (!console) {
BOOST_LOG(error) << "qemu: VM ["sv << vm.name << "] has no graphical console"sv;
return -1;
}
store = std::make_shared<frame_store_t>();
registration = session->register_listener(console->id, store);
if (!registration) {
return -1;
}
// QEMU sends the current surface right after registration
if (store->wait_for_frame(2s)) {
width = store->width();
height = store->height();
} else {
BOOST_LOG(warning) << "qemu: no scanout received yet; using the console size"sv;
width = (int) console->width;
height = (int) console->height;
}
if (width <= 0 || height <= 0) {
BOOST_LOG(error) << "qemu: console "sv << console->id << " has no size"sv;
return -1;
}
env_width = width;
env_height = height;
logical_width = width;
logical_height = height;
env_logical_width = width;
env_logical_height = height;
BOOST_LOG(info) << "qemu: streaming VM ["sv << vm.name << "] console "sv << console->id << " ["sv << console->label << "] at "sv << width << 'x' << height;
return 0;
}
platf::capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const pull_free_image_cb_t &pull_free_image_cb, bool *cursor) override {
auto next_frame = std::chrono::steady_clock::now();
sleep_overshoot_logger.reset();
while (true) {
platf::handle_pacing(next_frame, delay, sleep_overshoot_logger);
std::shared_ptr<platf::img_t> img_out;
auto status = snapshot(pull_free_image_cb, img_out);
switch (status) {
case platf::capture_e::reinit:
case platf::capture_e::error:
case platf::capture_e::interrupted:
return status;
case platf::capture_e::timeout:
if (!push_captured_image_cb(std::move(img_out), false)) {
return platf::capture_e::ok;
}
break;
case platf::capture_e::ok:
if (!push_captured_image_cb(std::move(img_out), true)) {
return platf::capture_e::ok;
}
break;
default:
BOOST_LOG(error) << "Unrecognized capture status ["sv << (int) status << ']';
return status;
}
}
}
/**
* @brief Copy the latest frame if the guest changed it.
*
* @param pull_free_image_cb Callback providing an image to fill.
* @param img_out Filled image on `ok`.
* @return `ok` with a new frame, `timeout` when nothing changed, `reinit` when the size
* changed or QEMU went away, or `interrupted`.
*/
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out) {
if (!session->alive() || !store->connected() || store->width() != width || store->height() != height) {
return platf::capture_e::reinit;
}
if (store->sequence() == copied_sequence) {
return platf::capture_e::timeout;
}
if (!pull_free_image_cb(img_out)) {
return platf::capture_e::interrupted;
}
std::chrono::steady_clock::time_point timestamp;
switch (store->copy_if_newer(copied_sequence, width, height, img_out->data, timestamp)) {
case frame_status_e::new_frame:
img_out->frame_timestamp = timestamp;
damage_to_capture_logger.first_point(timestamp);
damage_to_capture_logger.second_point_now_and_log();
return platf::capture_e::ok;
case frame_status_e::unchanged:
return platf::capture_e::timeout;
case frame_status_e::size_changed:
case frame_status_e::disconnected:
default:
return platf::capture_e::reinit;
}
}
std::shared_ptr<platf::img_t> alloc_img() override {
auto img = std::make_shared<qemu::img_t>();
img->width = width;
img->height = height;
img->pixel_pitch = 4;
img->row_pitch = img->pixel_pitch * width;
img->data = new std::uint8_t[(std::size_t) height * img->row_pitch];
return img;
}
int dummy_img(platf::img_t *img) override {
if (!img) {
return -1;
}
std::memset(img->data, 0, (std::size_t) img->height * img->row_pitch);
return 0;
}
std::unique_ptr<platf::avcodec_encode_device_t> make_avcodec_encode_device(platf::pix_fmt_e pix_fmt) override {
#ifdef SUNSHINE_BUILD_VAAPI
if (mem_type == platf::mem_type_e::vaapi) {
return va::make_avcodec_encode_device(width, height, false);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (mem_type == platf::mem_type_e::cuda) {
return cuda::make_avcodec_encode_device(width, height, false);
}
#endif
return std::make_unique<platf::avcodec_encode_device_t>();
}
private:
platf::mem_type_e mem_type; ///< Memory type the encoder expects.
std::chrono::nanoseconds delay {}; ///< Capture interval for the client frame rate.
std::shared_ptr<session_t> session; ///< Session to the VM.
std::shared_ptr<frame_store_t> store; ///< Frame reconstructed from listener calls.
std::unique_ptr<listener_registration_t> registration; ///< Keeps the listener registered.
std::uint64_t copied_sequence {0}; ///< Store sequence of the last copied frame.
logging::time_delta_periodic_logger damage_to_capture_logger {debug, "qemu: damage received to frame captured"}; ///< Latency from QEMU's call to the capture copy.
};
} // namespace qemu
namespace platf {
/**
* @brief Check whether the QEMU backend was selected and QEMU is reachable.
* @details Never connects unless `capture = qemu`, so other backends are unaffected.
*
* @return True when `capture = qemu` and a QEMU display was found.
*/
bool verify_qemu() {
if (config::video.capture != "qemu") {
return false;
}
return qemu::shared_session(config::video.qemu_dbus_address) != nullptr;
}
/**
* @brief List the graphical consoles of the VM.
*
* @return Console ids, or an empty list when QEMU is unreachable.
*/
std::vector<std::string> qemu_display_names() {
auto session = qemu::shared_session(config::video.qemu_dbus_address);
if (!session) {
return {};
}
auto names = qemu::graphic_console_names(session->vm());
for (const auto &name : names) {
BOOST_LOG(info) << "qemu: found console "sv << name;
}
return names;
}
/**
* @brief Create a QEMU display capture backend.
*
* @param hwdevice_type Memory type the encoder expects.
* @param display_name Console id or label.
* @param config Stream configuration.
* @return Display backend, or nullptr on failure.
*/
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) {
if (hwdevice_type != mem_type_e::system && hwdevice_type != mem_type_e::vaapi && hwdevice_type != mem_type_e::cuda) {
BOOST_LOG(error) << "qemu: could not initialize display with the given hw device type"sv;
return nullptr;
}
auto display = std::make_shared<qemu::display_t>(hwdevice_type);
if (display->init(display_name, config)) {
return nullptr;
}
return display;
}
} // namespace platf
src/platform/linux/qemu/frame_store.cpp +287 −0
@@ -1,0 +1,287 @@
/**
* @file src/platform/linux/qemu/frame_store.cpp
* @brief Definitions for the shared-memory frame store of the QEMU capture backend.
*/
// class header include
#include "frame_store.h"
// standard includes
#include <algorithm>
#include <cstring>
// platform includes
#include <sys/mman.h>
#include <sys/stat.h>
// local includes
#include "src/logging.h"
using namespace std::literals;
namespace qemu {
namespace {
/**
* @brief Report whether a pixman format is 32 bpp with red and blue swapped relative to BGRX.
*
* @param format Pixman format code.
* @return True for `x8b8g8r8` and `a8b8g8r8`.
*/
bool is_rgbx(std::uint32_t format) {
return format == pixman_format::x8b8g8r8 || format == pixman_format::a8b8g8r8;
}
/**
* @brief Report whether the frame store can convert a pixman format.
*
* @param format Pixman format code.
* @return True for the supported 32 bpp formats.
*/
bool is_supported(std::uint32_t format) {
return format == pixman_format::x8r8g8b8 || format == pixman_format::a8r8g8b8 || is_rgbx(format);
}
/**
* @brief Log an unsupported format once per format code.
*
* @param format Pixman format code.
*/
void log_unsupported(std::uint32_t format) {
static std::mutex logged_mutex;
static std::vector<std::uint32_t> logged;
std::lock_guard lock {logged_mutex};
if (std::ranges::find(logged, format) == logged.end()) {
logged.push_back(format);
BOOST_LOG(warning) << "qemu: unsupported pixman format 0x"sv << std::hex << format << std::dec << "; ignoring scanout"sv;
}
}
/**
* @brief Check that a pixel buffer covers a rectangle.
*
* @param available Bytes available.
* @param width Rectangle width.
* @param height Rectangle height.
* @param stride Bytes per row.
* @return True when the buffer is large enough and the stride fits the width.
*/
bool covers(std::size_t available, std::int64_t width, std::int64_t height, std::int64_t stride) {
if (width <= 0 || height <= 0 || stride < width * 4) {
return false;
}
return (std::size_t) (stride * (height - 1) + width * 4) <= available;
}
} // namespace
frame_store_t::~frame_store_t() {
std::lock_guard lock {mutex};
unmap_locked();
}
void frame_store_t::scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) {
if (!is_supported(format)) {
log_unsupported(format);
return;
}
if (!covers(data.size(), width, height, stride)) {
BOOST_LOG(warning) << "qemu: scanout data doesn't cover "sv << width << 'x' << height << " with stride "sv << stride;
return;
}
std::lock_guard lock {mutex};
unmap_locked();
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, data.data(), stride, format);
touch_locked();
}
void frame_store_t::update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) {
if (!is_supported(format)) {
log_unsupported(format);
return;
}
if (!covers(data.size(), width, height, stride)) {
BOOST_LOG(warning) << "qemu: update data doesn't cover "sv << width << 'x' << height << " with stride "sv << stride;
return;
}
std::lock_guard lock {mutex};
if (frame_width == 0 || map_addr) {
return;
}
// clip the rectangle to the frame, skipping the source rows and columns that fall outside
int x0 = std::max(x, 0);
int y0 = std::max(y, 0);
int x1 = std::min<std::int64_t>((std::int64_t) x + width, frame_width);
int y1 = std::min<std::int64_t>((std::int64_t) y + height, frame_height);
if (x0 >= x1 || y0 >= y1) {
return;
}
auto src = data.data() + (std::size_t) (y0 - y) * stride + (std::size_t) (x0 - x) * 4;
blit_locked(x0, y0, x1 - x0, y1 - y0, src, stride, format);
touch_locked();
}
void frame_store_t::scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) {
if (fd.get() < 0) {
BOOST_LOG(warning) << "qemu: ScanoutMap without a descriptor"sv;
return;
}
if (!is_supported(format)) {
log_unsupported(format);
return;
}
struct stat st {};
if (fstat(fd.get(), &st) != 0 || !covers(st.st_size > offset ? (std::size_t) st.st_size - offset : 0, width, height, stride)) {
BOOST_LOG(warning) << "qemu: ScanoutMap descriptor doesn't cover "sv << width << 'x' << height << " at offset "sv << offset;
return;
}
std::size_t size = (std::size_t) offset + (std::size_t) stride * height;
size = std::min<std::size_t>(size, st.st_size);
auto addr = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd.get(), 0);
if (addr == MAP_FAILED) {
BOOST_LOG(warning) << "qemu: couldn't map scanout: "sv << std::strerror(errno);
return;
}
std::lock_guard lock {mutex};
unmap_locked();
map_fd = std::move(fd);
map_addr = (const std::uint8_t *) addr;
map_size = size;
map_offset = offset;
map_stride = stride;
map_format = format;
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, map_addr + map_offset, map_stride, map_format);
touch_locked();
}
void frame_store_t::update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
std::lock_guard lock {mutex};
if (!map_addr) {
return;
}
int x0 = std::max(x, 0);
int y0 = std::max(y, 0);
int x1 = std::min<std::int64_t>((std::int64_t) x + width, frame_width);
int y1 = std::min<std::int64_t>((std::int64_t) y + height, frame_height);
if (x0 >= x1 || y0 >= y1) {
return;
}
auto src = map_addr + map_offset + (std::size_t) y0 * map_stride + (std::size_t) x0 * 4;
blit_locked(x0, y0, x1 - x0, y1 - y0, src, map_stride, map_format);
touch_locked();
}
void frame_store_t::disable() {
std::lock_guard lock {mutex};
std::ranges::fill(pixels, 0);
if (frame_width > 0) {
touch_locked();
}
}
void frame_store_t::disconnected() {
std::lock_guard lock {mutex};
is_disconnected = true;
frame_ready.notify_all();
}
bool frame_store_t::wait_for_frame(std::chrono::milliseconds timeout) {
std::unique_lock lock {mutex};
frame_ready.wait_for(lock, timeout, [this]() {
return change_sequence > 0 || is_disconnected;
});
return change_sequence > 0 && !is_disconnected;
}
int frame_store_t::width() const {
std::lock_guard lock {mutex};
return frame_width;
}
int frame_store_t::height() const {
std::lock_guard lock {mutex};
return frame_height;
}
bool frame_store_t::connected() const {
std::lock_guard lock {mutex};
return !is_disconnected;
}
std::uint64_t frame_store_t::sequence() const {
std::lock_guard lock {mutex};
return change_sequence;
}
frame_status_e frame_store_t::copy_if_newer(std::uint64_t &last_sequence, int width, int height, std::uint8_t *dst, std::chrono::steady_clock::time_point &timestamp) {
std::lock_guard lock {mutex};
if (is_disconnected) {
return frame_status_e::disconnected;
}
if (width != frame_width || height != frame_height) {
return frame_status_e::size_changed;
}
if (change_sequence == last_sequence) {
return frame_status_e::unchanged;
}
std::memcpy(dst, pixels.data(), pixels.size());
last_sequence = change_sequence;
timestamp = change_time;
return frame_status_e::new_frame;
}
void frame_store_t::resize_locked(int new_width, int new_height) {
const bool mapped = map_addr != nullptr;
if (new_width != frame_width || new_height != frame_height || mapped != logged_mapped) {
BOOST_LOG(info) << "qemu: scanout "sv << new_width << 'x' << new_height << (mapped ? " via shared memory map"sv : " via D-Bus messages"sv);
logged_mapped = mapped;
}
frame_width = new_width;
frame_height = new_height;
pixels.assign((std::size_t) new_width * new_height * 4, 0);
}
void frame_store_t::blit_locked(int x, int y, int w, int h, const std::uint8_t *src, std::uint32_t src_stride, std::uint32_t format) {
const auto dst_stride = (std::size_t) frame_width * 4;
const bool swap = is_rgbx(format);
for (int row = 0; row < h; ++row) {
auto s = src + (std::size_t) row * src_stride;
auto d = pixels.data() + (std::size_t) (y + row) * dst_stride + (std::size_t) x * 4;
if (!swap) {
std::memcpy(d, s, (std::size_t) w * 4);
continue;
}
for (int col = 0; col < w; ++col, s += 4, d += 4) {
d[0] = s[2];
d[1] = s[1];
d[2] = s[0];
d[3] = s[3];
}
}
}
void frame_store_t::touch_locked() {
change_sequence += 1;
change_time = std::chrono::steady_clock::now();
frame_ready.notify_all();
}
void frame_store_t::unmap_locked() {
if (map_addr) {
munmap((void *) map_addr, map_size);
map_addr = nullptr;
map_size = 0;
}
map_fd = fd_t {};
}
} // namespace qemu
src/platform/linux/qemu/frame_store.h +148 −0
@@ -1,0 +1,148 @@
/**
* @file src/platform/linux/qemu/frame_store.h
* @brief Declarations for the shared-memory frame store of the QEMU capture backend.
*/
#pragma once
// standard includes
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <optional>
#include <span>
#include <vector>
// local includes
#include "session.h"
namespace qemu {
/**
* @brief Result of polling the frame store for a new frame.
*/
enum class frame_status_e {
new_frame, ///< A frame newer than the caller's sequence number was copied.
unchanged, ///< Nothing changed since the caller's sequence number.
size_changed, ///< The frame size differs from the caller's size.
disconnected, ///< QEMU closed the listener connection.
};
/**
* @brief Reconstructs the guest framebuffer from QEMU listener calls.
* @details Listener calls arrive on the session thread; the capture thread copies frames out. The
* stored frame is always 4 bytes per pixel in B, G, R, X order with a stride of `4 * width`,
* which is the layout Sunshine's system-memory images use.
*/
class frame_store_t: public display_listener_t {
public:
frame_store_t() = default;
~frame_store_t() override;
frame_store_t(const frame_store_t &) = delete;
frame_store_t &operator=(const frame_store_t &) = delete;
void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override;
void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override;
void scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) override;
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override;
void disable() override;
void disconnected() override;
/**
* @brief Wait until the first scanout arrived.
*
* @param timeout Maximum time to wait.
* @return True when a frame is available.
*/
bool wait_for_frame(std::chrono::milliseconds timeout);
/**
* @brief Current frame width.
*
* @return Width in pixels, 0 before the first scanout.
*/
[[nodiscard]] int width() const;
/**
* @brief Current frame height.
*
* @return Height in pixels, 0 before the first scanout.
*/
[[nodiscard]] int height() const;
/**
* @brief Report whether QEMU still has the listener connection open.
*
* @return False once QEMU closed the connection.
*/
[[nodiscard]] bool connected() const;
/**
* @brief Sequence number of the latest change.
*
* @return Monotonic counter, 0 before the first scanout.
*/
[[nodiscard]] std::uint64_t sequence() const;
/**
* @brief Copy the frame if it changed since the caller last copied it.
*
* @param last_sequence Sequence of the caller's last copy; updated on `new_frame`.
* @param width Width of the destination buffer.
* @param height Height of the destination buffer.
* @param dst Destination with `4 * width` bytes per row.
* @param timestamp Receipt time of the call that produced the copied frame; set on `new_frame`.
* @return Poll result.
*/
frame_status_e copy_if_newer(std::uint64_t &last_sequence, int width, int height, std::uint8_t *dst, std::chrono::steady_clock::time_point &timestamp);
private:
/**
* @brief Resize the frame, logging size or transport changes.
*
* @param new_width New width in pixels.
* @param new_height New height in pixels.
*/
void resize_locked(int new_width, int new_height);
/**
* @brief Convert and copy a rectangle of source pixels into the frame.
*
* @param x Destination left edge.
* @param y Destination top edge.
* @param w Rectangle width.
* @param h Rectangle height.
* @param src Pointer to the top-left source pixel of the rectangle.
* @param src_stride Source bytes per row.
* @param format Source pixman format; must be supported.
*/
void blit_locked(int x, int y, int w, int h, const std::uint8_t *src, std::uint32_t src_stride, std::uint32_t format);
/**
* @brief Mark the frame as changed now.
*/
void touch_locked();
/**
* @brief Unmap the current shared memory scanout.
*/
void unmap_locked();
mutable std::mutex mutex; ///< Guards all state below.
std::condition_variable frame_ready; ///< Signaled when a frame or disconnect arrives.
std::vector<std::uint8_t> pixels; ///< BGRX frame, `4 * frame_width` bytes per row.
int frame_width {0}; ///< Frame width in pixels.
int frame_height {0}; ///< Frame height in pixels.
std::uint64_t change_sequence {0}; ///< Incremented on every change.
std::chrono::steady_clock::time_point change_time; ///< Receipt time of the latest change.
bool is_disconnected {false}; ///< Whether QEMU closed the listener connection.
bool logged_mapped {false}; ///< Whether the last logged scanout used the shared memory map.
fd_t map_fd; ///< Shared memory descriptor of the current map scanout.
const std::uint8_t *map_addr {nullptr}; ///< Mapping of `map_fd`.
std::size_t map_size {0}; ///< Length of the mapping.
std::uint32_t map_offset {0}; ///< Offset of the first pixel in the mapping.
std::uint32_t map_stride {0}; ///< Bytes per row in the mapping.
std::uint32_t map_format {0}; ///< Pixman format of the mapping.
};
} // namespace qemu
src/platform/linux/qemu/session.cpp +744 −0
@@ -1,0 +1,744 @@
/**
* @file src/platform/linux/qemu/session.cpp
* @brief Definitions for the QEMU D-Bus display session.
*/
// class header include
#include "session.h"
// standard includes
#include <algorithm>
#include <atomic>
#include <charconv>
#include <condition_variable>
#include <cstring>
#include <functional>
#include <future>
#include <map>
#include <mutex>
#include <thread>
#include <utility>
// platform includes
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <sys/socket.h>
#include <unistd.h>
// local includes
#include "src/logging.h"
// generated includes
extern "C" {
#include "qemu/dbus-display1.h"
}
using namespace std::literals;
namespace qemu {
namespace {
constexpr auto bus_name = "org.qemu"; ///< Well-known name QEMU owns on the bus.
constexpr auto vm_path = "/org/qemu/Display1/VM"; ///< Object path of the VM interface.
constexpr auto listener_path = "/org/qemu/Display1/Listener"; ///< Object path QEMU calls on the listener connection.
constexpr auto unix_map_interface = "org.qemu.Display1.Listener.Unix.Map"; ///< Shared memory listener interface.
/**
* @brief Convert a NULL-terminated string vector to a vector of strings.
*
* @param strv String vector, may be null.
* @return Copied strings.
*/
std::vector<std::string> to_strings(const gchar *const *strv) {
std::vector<std::string> result;
for (auto it = strv; it && *it; ++it) {
result.emplace_back(*it);
}
return result;
}
/**
* @brief Read a string property that may be missing on old QEMU releases.
*
* @param value Property value, may be null.
* @return The value, or an empty string.
*/
std::string to_string(const gchar *value) {
return value ? value : "";
}
/**
* @brief Cancels a GCancellable when a deadline passes, for blocking GDBus calls without a timeout.
* @details Connection setup and authentication have no timeout of their own; a socket that
* accepts but never answers would block forever.
*/
class deadline_t {
public:
/**
* @brief Start the watchdog.
*
* @param timeout Time after which the cancellable is cancelled.
*/
explicit deadline_t(std::chrono::milliseconds timeout):
cancellable {g_cancellable_new()} {
watchdog = std::thread([this, timeout]() {
std::unique_lock lock {mutex};
if (!finished.wait_for(lock, timeout, [this]() {
return done;
})) {
g_cancellable_cancel(cancellable);
}
});
}
~deadline_t() {
{
std::lock_guard lock {mutex};
done = true;
}
finished.notify_all();
watchdog.join();
g_object_unref(cancellable);
}
deadline_t(const deadline_t &) = delete;
deadline_t &operator=(const deadline_t &) = delete;
GCancellable *cancellable; ///< Cancelled when the deadline passes.
private:
std::mutex mutex; ///< Guards `done`.
std::condition_variable finished; ///< Signaled when the guarded work finished.
bool done {false}; ///< Whether the guarded work finished.
std::thread watchdog; ///< Thread waiting for the deadline.
};
/**
* @brief Owns a GLib main context and the thread that runs it.
*/
class loop_thread_t {
public:
loop_thread_t():
context {g_main_context_new()},
loop {g_main_loop_new(context, FALSE)} {
thread = std::thread([this]() {
g_main_context_push_thread_default(context);
g_main_loop_run(loop);
g_main_context_pop_thread_default(context);
});
}
~loop_thread_t() {
post([this]() {
g_main_loop_quit(loop);
});
thread.join();
g_main_loop_unref(loop);
g_main_context_unref(context);
}
loop_thread_t(const loop_thread_t &) = delete;
loop_thread_t &operator=(const loop_thread_t &) = delete;
/**
* @brief Run a function on the loop thread and wait for it to finish.
* @details Runs the function inline when called from the loop thread.
*
* @param fn Function to run.
*/
void invoke(const std::function<void()> &fn) {
if (g_main_context_is_owner(context)) {
fn();
return;
}
std::packaged_task<void()> task {fn};
auto done = task.get_future();
post([&task]() {
task();
});
done.get();
}
private:
/**
* @brief Queue a function on the loop without waiting.
*
* @param fn Function to run; must outlive its execution.
*/
void post(std::function<void()> fn) {
auto source = g_idle_source_new();
g_source_set_priority(source, G_PRIORITY_DEFAULT);
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
(*(std::function<void()> *) data)();
return G_SOURCE_REMOVE;
},
new std::function<void()> {std::move(fn)},
[](gpointer data) {
delete (std::function<void()> *) data;
}
);
g_source_attach(source, context);
g_source_unref(source);
}
GMainContext *context; ///< Context owned by the loop thread.
GMainLoop *loop; ///< Loop running on the thread.
std::thread thread; ///< Thread running the loop.
};
class session_impl_t;
/**
* @brief Peer-to-peer listener connection registered on a console.
*/
class listener_impl_t: public listener_registration_t {
public:
/**
* @brief Create an unconnected registration.
*
* @param session Session that owns the loop thread.
* @param listener Receiver for display calls.
*/
listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<display_listener_t> listener);
~listener_impl_t() override;
/**
* @brief Complete the peer-to-peer handshake and export the listener objects.
* @details Must run on the listener thread, right after `RegisterListener` returned.
*
* @param socket_fd Our end of the socket pair.
* @param cancellable Cancels the handshake when the deadline passes.
* @return True when the listener is exported.
*/
bool start(fd_t socket_fd, GCancellable *cancellable);
private:
/**
* @brief Unexport the listener objects and close the connection.
* @details Must run on the listener thread.
*/
void stop();
static gboolean on_scanout(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, guint width, guint height, guint stride, guint format, GVariant *data, gpointer self);
static gboolean on_update(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, guint stride, guint format, GVariant *data, gpointer self);
static gboolean on_scanout_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint width, guint height, guint stride, guint fourcc, guint64 modifier, gboolean y0_top, gpointer self);
static gboolean on_update_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self);
static gboolean on_disable(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gpointer self);
static gboolean on_mouse_set(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint on, gpointer self);
static gboolean on_cursor_define(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint width, gint height, gint hot_x, gint hot_y, GVariant *data, gpointer self);
static gboolean on_scanout_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *handle, guint offset, guint width, guint height, guint stride, guint format, gpointer self);
static gboolean on_update_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self);
static void on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self);
std::shared_ptr<session_impl_t> session; ///< Keeps the loop thread alive while registered.
std::shared_ptr<display_listener_t> listener; ///< Receiver for display calls.
GDBusConnection *connection {nullptr}; ///< Peer-to-peer connection to QEMU.
QemuDBusDisplay1Listener *skeleton {nullptr}; ///< Exported Listener interface.
QemuDBusDisplay1ListenerUnixMap *map_skeleton {nullptr}; ///< Exported Listener.Unix.Map interface.
bool closed {false}; ///< Whether the peer closed the connection.
};
/**
* @brief GDBus implementation of the session.
*/
class session_impl_t: public session_t, public std::enable_shared_from_this<session_impl_t> {
public:
/**
* @brief Create a session with its loop thread; call connect_bus() next.
*
* @param timeout Timeout applied to each D-Bus call.
*/
explicit session_impl_t(std::chrono::milliseconds timeout):
timeout {timeout},
timeout_ms {(int) timeout.count()} {
}
~session_impl_t() override {
loop.invoke([this]() {
disconnect_bus();
});
}
/**
* @brief Connect to the bus and read the VM and console properties.
*
* @param address Bus address, or empty for the session bus.
* @return True when QEMU was found.
*/
bool connect_bus(const std::string &address) {
bool ok = false;
deadline_t deadline {timeout};
loop.invoke([&]() {
ok = connect_bus_on_loop(address, deadline.cancellable);
if (!ok) {
disconnect_bus();
}
});
return ok;
}
vm_info_t vm() const override {
vm_info_t info;
loop.invoke([&]() {
info.name = to_string(qemu_dbus_display1_vm_get_name(vm_proxy));
info.uuid = to_string(qemu_dbus_display1_vm_get_uuid(vm_proxy));
for (const auto &[id, proxy] : console_proxies) {
console_info_t console;
console.id = id;
console.label = to_string(qemu_dbus_display1_console_get_label(proxy));
console.type = to_string(qemu_dbus_display1_console_get_type_(proxy));
console.head = qemu_dbus_display1_console_get_head(proxy);
console.width = qemu_dbus_display1_console_get_width(proxy);
console.height = qemu_dbus_display1_console_get_height(proxy);
console.interfaces = to_strings(qemu_dbus_display1_console_get_interfaces(proxy));
info.consoles.emplace_back(std::move(console));
}
});
return info;
}
bool alive() const override {
return is_alive;
}
std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) override {
auto registration = std::make_unique<listener_impl_t>(shared_from_this(), std::move(listener));
bool ok = false;
deadline_t deadline {timeout};
loop.invoke([&]() {
ok = register_listener_on_loop(console_id, *registration, deadline.cancellable);
});
if (!ok) {
return nullptr;
}
return registration;
}
/**
* @brief Thread that owns the bus connection and makes blocking calls to QEMU.
*/
mutable loop_thread_t loop;
/**
* @brief Thread that dispatches listener connections and never blocks on QEMU.
* @details QEMU makes synchronous calls on a new listener (property fetch, ScanoutMap) right
* after replying to RegisterListener. Answering them from the thread that may be blocked in
* another RegisterListener would deadlock both processes until the call timeout.
*/
loop_thread_t listener_loop;
private:
/**
* @brief Connect and read properties; runs on the loop thread.
*
* @param address Bus address, or empty for the session bus.
* @param cancellable Cancels blocking calls when the deadline passes.
* @return True when QEMU was found.
*/
bool connect_bus_on_loop(const std::string &address, GCancellable *cancellable) {
GError *err = nullptr;
std::string resolved = address;
if (resolved.empty()) {
auto session_address = g_dbus_address_get_for_bus_sync(G_BUS_TYPE_SESSION, cancellable, &err);
if (!session_address) {
BOOST_LOG(error) << "qemu: no session bus address: "sv << err->message;
g_clear_error(&err);
return false;
}
resolved = session_address;
g_free(session_address);
}
connection = g_dbus_connection_new_for_address_sync(resolved.c_str(), (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), nullptr, cancellable, &err);
if (!connection) {
BOOST_LOG(error) << "qemu: couldn't connect to D-Bus address ["sv << resolved << "]: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
closed_handler = g_signal_connect(connection, "closed", G_CALLBACK(&session_impl_t::on_bus_closed), this);
vm_proxy = qemu_dbus_display1_vm_proxy_new_sync(connection, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, bus_name, vm_path, cancellable, &err);
if (!vm_proxy) {
BOOST_LOG(error) << "qemu: couldn't create VM proxy: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_proxy_set_default_timeout(G_DBUS_PROXY(vm_proxy), timeout_ms);
auto owner = g_dbus_proxy_get_name_owner(G_DBUS_PROXY(vm_proxy));
if (!owner) {
BOOST_LOG(error) << "qemu: no QEMU owns ["sv << bus_name << "] on ["sv << resolved << ']';
return false;
}
g_free(owner);
auto ids = qemu_dbus_display1_vm_get_console_ids(vm_proxy);
if (!ids) {
BOOST_LOG(error) << "qemu: VM has no ConsoleIDs property; is -display dbus enabled?"sv;
return false;
}
gsize count = 0;
auto id_values = (const guint32 *) g_variant_get_fixed_array(ids, &count, sizeof(guint32));
for (gsize i = 0; i < count; ++i) {
auto path = "/org/qemu/Display1/Console_" + std::to_string(id_values[i]);
auto proxy = qemu_dbus_display1_console_proxy_new_sync(connection, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, bus_name, path.c_str(), cancellable, &err);
if (!proxy) {
BOOST_LOG(warning) << "qemu: skipping console "sv << id_values[i] << ": "sv << err->message;
g_clear_error(&err);
continue;
}
g_dbus_proxy_set_default_timeout(G_DBUS_PROXY(proxy), timeout_ms);
console_proxies.emplace_back(id_values[i], proxy);
}
name_watch = g_bus_watch_name_on_connection(connection, bus_name, G_BUS_NAME_WATCHER_FLAGS_NONE, nullptr, &session_impl_t::on_name_vanished, this, nullptr);
BOOST_LOG(info) << "qemu: connected to VM ["sv << to_string(qemu_dbus_display1_vm_get_name(vm_proxy)) << "] with "sv << console_proxies.size() << " console(s)"sv;
is_alive = true;
return true;
}
/**
* @brief Release every GDBus object; runs on the loop thread.
*/
void disconnect_bus() {
is_alive = false;
if (name_watch) {
g_bus_unwatch_name(name_watch);
name_watch = 0;
}
for (auto &[id, proxy] : console_proxies) {
g_object_unref(proxy);
}
console_proxies.clear();
g_clear_object(&vm_proxy);
if (connection) {
g_signal_handler_disconnect(connection, closed_handler);
g_dbus_connection_close_sync(connection, nullptr, nullptr);
g_clear_object(&connection);
}
}
/**
* @brief Hand one end of a socket pair to `Console.RegisterListener`; runs on the loop thread.
*
* @param console_id Console to register on.
* @param registration Registration to start on success.
* @param cancellable Cancels blocking calls when the deadline passes.
* @return True when the listener is registered.
*/
bool register_listener_on_loop(std::uint32_t console_id, listener_impl_t &registration, GCancellable *cancellable) {
auto it = std::ranges::find_if(console_proxies, [&](const auto &entry) {
return entry.first == console_id;
});
if (it == console_proxies.end() || !is_alive) {
BOOST_LOG(error) << "qemu: console "sv << console_id << " is not available"sv;
return false;
}
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
BOOST_LOG(error) << "qemu: socketpair failed: "sv << std::strerror(errno);
return false;
}
fd_t ours {fds[0]};
fd_t theirs {fds[1]};
GError *err = nullptr;
auto fd_list = g_unix_fd_list_new();
auto index = g_unix_fd_list_append(fd_list, theirs.get(), &err);
theirs = fd_t {};
if (index < 0) {
BOOST_LOG(error) << "qemu: couldn't attach listener socket: "sv << err->message;
g_clear_error(&err);
g_object_unref(fd_list);
return false;
}
bool ok = qemu_dbus_display1_console_call_register_listener_sync(it->second, g_variant_new_handle(index), G_DBUS_CALL_FLAGS_NONE, timeout_ms, fd_list, nullptr, cancellable, &err);
g_object_unref(fd_list);
if (!ok) {
BOOST_LOG(error) << "qemu: RegisterListener on console "sv << console_id << " failed: "sv << err->message;
g_clear_error(&err);
return false;
}
bool started = false;
listener_loop.invoke([&]() {
started = registration.start(std::move(ours), cancellable);
});
return started;
}
static void on_bus_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
BOOST_LOG(warning) << "qemu: D-Bus connection closed"sv;
((session_impl_t *) self)->is_alive = false;
}
static void on_name_vanished(GDBusConnection *connection, const gchar *name, gpointer self) {
BOOST_LOG(warning) << "qemu: ["sv << name << "] vanished from the bus"sv;
((session_impl_t *) self)->is_alive = false;
}
std::chrono::milliseconds timeout; ///< Deadline for connection setup and registration.
int timeout_ms; ///< Timeout applied to each D-Bus call.
std::atomic<bool> is_alive {false}; ///< Whether QEMU is reachable.
GDBusConnection *connection {nullptr}; ///< Bus connection.
gulong closed_handler {0}; ///< Handler id of the connection's "closed" signal.
guint name_watch {0}; ///< Watch on `org.qemu`.
QemuDBusDisplay1VM *vm_proxy {nullptr}; ///< VM proxy.
std::vector<std::pair<std::uint32_t, QemuDBusDisplay1Console *>> console_proxies; ///< Console proxies in ConsoleIDs order.
};
listener_impl_t::listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<display_listener_t> listener):
session {std::move(session)},
listener {std::move(listener)} {
}
listener_impl_t::~listener_impl_t() {
session->listener_loop.invoke([this]() {
stop();
});
}
bool listener_impl_t::start(fd_t socket_fd, GCancellable *cancellable) {
GError *err = nullptr;
auto socket = g_socket_new_from_fd(socket_fd.get(), &err);
if (!socket) {
BOOST_LOG(error) << "qemu: couldn't wrap listener socket: "sv << err->message;
g_clear_error(&err);
return false;
}
socket_fd.release();
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
// QEMU is the authentication server on this socket. Delay message processing so QEMU's first
// calls (property fetch, ScanoutMap) queue until the listener objects are exported.
connection = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), nullptr, (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING), nullptr, cancellable, &err);
g_object_unref(socket_connection);
if (!connection) {
BOOST_LOG(error) << "qemu: listener handshake failed: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
skeleton = qemu_dbus_display1_listener_skeleton_new();
const gchar *interfaces[] = {unix_map_interface, nullptr};
qemu_dbus_display1_listener_set_interfaces(skeleton, interfaces);
g_signal_connect(skeleton, "handle-scanout", G_CALLBACK(&listener_impl_t::on_scanout), this);
g_signal_connect(skeleton, "handle-update", G_CALLBACK(&listener_impl_t::on_update), this);
g_signal_connect(skeleton, "handle-scanout-dmabuf", G_CALLBACK(&listener_impl_t::on_scanout_dmabuf), this);
g_signal_connect(skeleton, "handle-update-dmabuf", G_CALLBACK(&listener_impl_t::on_update_dmabuf), this);
g_signal_connect(skeleton, "handle-disable", G_CALLBACK(&listener_impl_t::on_disable), this);
g_signal_connect(skeleton, "handle-mouse-set", G_CALLBACK(&listener_impl_t::on_mouse_set), this);
g_signal_connect(skeleton, "handle-cursor-define", G_CALLBACK(&listener_impl_t::on_cursor_define), this);
map_skeleton = qemu_dbus_display1_listener_unix_map_skeleton_new();
g_signal_connect(map_skeleton, "handle-scanout-map", G_CALLBACK(&listener_impl_t::on_scanout_map), this);
g_signal_connect(map_skeleton, "handle-update-map", G_CALLBACK(&listener_impl_t::on_update_map), this);
if (!g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(skeleton), connection, listener_path, &err) || !g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(map_skeleton), connection, listener_path, &err)) {
BOOST_LOG(error) << "qemu: couldn't export listener: "sv << err->message;
g_clear_error(&err);
stop();
return false;
}
g_signal_connect(connection, "closed", G_CALLBACK(&listener_impl_t::on_closed), this);
g_dbus_connection_start_message_processing(connection);
return true;
}
void listener_impl_t::stop() {
if (skeleton) {
g_signal_handlers_disconnect_by_data(skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(skeleton));
g_clear_object(&skeleton);
}
if (map_skeleton) {
g_signal_handlers_disconnect_by_data(map_skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(map_skeleton));
g_clear_object(&map_skeleton);
}
if (connection) {
g_signal_handlers_disconnect_by_data(connection, this);
if (!closed) {
g_dbus_connection_close_sync(connection, nullptr, nullptr);
}
g_clear_object(&connection);
}
}
gboolean listener_impl_t::on_scanout(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, guint width, guint height, guint stride, guint format, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->scanout(width, height, stride, format, {bytes, size});
qemu_dbus_display1_listener_complete_scanout(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_update(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, guint stride, guint format, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->update(x, y, width, height, stride, format, {bytes, size});
qemu_dbus_display1_listener_complete_update(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_scanout_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint width, guint height, guint stride, guint fourcc, guint64 modifier, gboolean y0_top, gpointer self) {
// DMABUF scanouts need a GL display in QEMU and are handled by the vram capture path (REQ-CAP-003).
static std::once_flag logged;
std::call_once(logged, []() {
BOOST_LOG(warning) << "qemu: ignoring DMABUF scanout; start QEMU without gl=on for shared memory capture"sv;
});
qemu_dbus_display1_listener_complete_scanout_dmabuf(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_update_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self) {
qemu_dbus_display1_listener_complete_update_dmabuf(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_disable(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gpointer self) {
((listener_impl_t *) self)->listener->disable();
qemu_dbus_display1_listener_complete_disable(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_mouse_set(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint on, gpointer self) {
((listener_impl_t *) self)->listener->mouse_set(x, y, on != 0);
qemu_dbus_display1_listener_complete_mouse_set(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_cursor_define(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint width, gint height, gint hot_x, gint hot_y, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->cursor_define(width, height, hot_x, hot_y, {bytes, size});
qemu_dbus_display1_listener_complete_cursor_define(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_scanout_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *handle, guint offset, guint width, guint height, guint stride, guint format, gpointer self) {
GError *err = nullptr;
int fd = fd_list ? g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err) : -1;
if (fd < 0) {
BOOST_LOG(error) << "qemu: ScanoutMap without a valid descriptor"sv;
if (err) {
g_dbus_method_invocation_return_gerror(invocation, err);
g_clear_error(&err);
} else {
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "missing descriptor");
}
return TRUE;
}
((listener_impl_t *) self)->listener->scanout_map(fd_t {fd}, offset, width, height, stride, format);
qemu_dbus_display1_listener_unix_map_complete_scanout_map(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_update_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self) {
((listener_impl_t *) self)->listener->update_map(x, y, width, height);
qemu_dbus_display1_listener_unix_map_complete_update_map(object, invocation);
return TRUE;
}
void listener_impl_t::on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
auto registration = (listener_impl_t *) self;
BOOST_LOG(info) << "qemu: display listener connection closed"sv;
registration->closed = true;
registration->listener->disconnected();
}
} // namespace
fd_t::fd_t(int fd):
fd {fd} {
}
fd_t::fd_t(fd_t &&other) noexcept:
fd {std::exchange(other.fd, -1)} {
}
fd_t &fd_t::operator=(fd_t &&other) noexcept {
if (this != &other) {
if (fd >= 0) {
close(fd);
}
fd = std::exchange(other.fd, -1);
}
return *this;
}
fd_t::~fd_t() {
if (fd >= 0) {
close(fd);
}
}
int fd_t::release() {
return std::exchange(fd, -1);
}
std::shared_ptr<session_t> session_t::connect(const std::string &address, std::chrono::milliseconds timeout) {
auto session = std::make_shared<session_impl_t>(timeout);
if (!session->connect_bus(address)) {
return nullptr;
}
return session;
}
std::shared_ptr<session_t> shared_session(const std::string &address) {
static std::mutex mutex;
static std::weak_ptr<session_t> cached;
static std::string cached_address;
std::lock_guard lock {mutex};
if (auto session = cached.lock(); session && session->alive() && cached_address == address) {
return session;
}
auto session = session_t::connect(address);
cached = session;
cached_address = address;
return session;
}
std::optional<console_info_t> find_console(const vm_info_t &vm, std::string_view name) {
if (name.empty()) {
auto it = std::ranges::find_if(vm.consoles, &console_info_t::is_graphic);
if (it == vm.consoles.end()) {
return std::nullopt;
}
return *it;
}
std::uint32_t id = 0;
auto [end, ec] = std::from_chars(name.data(), name.data() + name.size(), id);
bool is_id = ec == std::errc {} && end == name.data() + name.size();
auto it = std::ranges::find_if(vm.consoles, [&](const console_info_t &console) {
return is_id ? console.id == id : console.label == name;
});
if (it == vm.consoles.end()) {
return std::nullopt;
}
return *it;
}
std::vector<std::string> graphic_console_names(const vm_info_t &vm) {
std::vector<std::string> names;
for (const auto &console : vm.consoles) {
if (console.is_graphic()) {
names.emplace_back(std::to_string(console.id));
}
}
return names;
}
} // namespace qemu
src/platform/linux/qemu/session.h +286 −0
@@ -1,0 +1,286 @@
/**
* @file src/platform/linux/qemu/session.h
* @brief Declarations for the QEMU D-Bus display session.
* @details The session connects to the D-Bus bus that a QEMU started with `-display dbus` owns
* `org.qemu` on, discovers the VM consoles, and registers peer-to-peer display listeners. It only
* depends on GLib/GIO and POSIX so it can be reused by other Unix hosts.
*/
#pragma once
// standard includes
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace qemu {
using namespace std::literals;
/**
* @brief Pixman format codes used by QEMU display scanouts.
* @details Values follow pixman's `PIXMAN_FORMAT(bpp, type, a, r, g, b)` encoding. They are
* defined here so the capture backend does not need to link against pixman.
*/
namespace pixman_format {
inline constexpr std::uint32_t x8r8g8b8 = 0x20020888; ///< 32 bpp, B G R X byte order on little-endian hosts.
inline constexpr std::uint32_t a8r8g8b8 = 0x20028888; ///< 32 bpp, B G R A byte order on little-endian hosts.
inline constexpr std::uint32_t x8b8g8r8 = 0x20030888; ///< 32 bpp, R G B X byte order on little-endian hosts.
inline constexpr std::uint32_t a8b8g8r8 = 0x20038888; ///< 32 bpp, R G B A byte order on little-endian hosts.
} // namespace pixman_format
/**
* @brief Move-only owner of a POSIX file descriptor.
*/
class fd_t {
public:
fd_t() = default;
/**
* @brief Take ownership of a file descriptor.
*
* @param fd File descriptor to own, or -1 for none.
*/
explicit fd_t(int fd);
/**
* @brief Transfer ownership from another descriptor owner.
*
* @param other Owner to take the descriptor from.
*/
fd_t(fd_t &&other) noexcept;
/**
* @brief Replace the owned descriptor with the one held by another owner.
*
* @param other Owner to take the descriptor from.
* @return This owner.
*/
fd_t &operator=(fd_t &&other) noexcept;
fd_t(const fd_t &) = delete;
fd_t &operator=(const fd_t &) = delete;
~fd_t();
/**
* @brief Access the owned descriptor without releasing it.
*
* @return The owned descriptor, or -1 when empty.
*/
[[nodiscard]] int get() const {
return fd;
}
/**
* @brief Release ownership of the descriptor.
*
* @return The descriptor, which the caller must close.
*/
int release();
private:
int fd {-1}; ///< Owned descriptor, or -1 when empty.
};
/**
* @brief Snapshot of one QEMU console.
*/
struct console_info_t {
std::uint32_t id {0}; ///< Console id, as used in `/org/qemu/Display1/Console_<id>`.
std::string label; ///< User-friendly console name, for example "VGA".
std::string type; ///< Console type, "Graphic" or "Text".
std::uint32_t head {0}; ///< Graphical device head number.
std::uint32_t width {0}; ///< Console width in pixels.
std::uint32_t height {0}; ///< Console height in pixels.
std::vector<std::string> interfaces; ///< Extra interfaces advertised by the console object.
/**
* @brief Report whether this console shows graphics.
*
* @return True when the console type is "Graphic".
*/
[[nodiscard]] bool is_graphic() const {
return type == "Graphic";
}
};
/**
* @brief Snapshot of the VM exported by QEMU.
*/
struct vm_info_t {
std::string name; ///< VM name (`-name`).
std::string uuid; ///< VM UUID.
std::vector<console_info_t> consoles; ///< Consoles listed by `ConsoleIDs`, in QEMU order.
};
/**
* @brief Receiver for the `org.qemu.Display1.Listener` calls QEMU makes.
* @details All methods are invoked on the session's listener thread. Implementations must return
* quickly and must not call back into the session synchronously from a different thread while
* holding locks that the caller of the session may also take.
*/
class display_listener_t {
public:
virtual ~display_listener_t() = default;
/**
* @brief Handle a full-frame scanout carried in the message.
*
* @param width Display width in pixels.
* @param height Display height in pixels.
* @param stride Bytes per row in `data`.
* @param format Pixman format code of `data`.
* @param data Pixel data, valid only for the duration of the call.
*/
virtual void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) = 0;
/**
* @brief Handle a partial update carried in the message.
*
* @param x Left edge of the updated rectangle.
* @param y Top edge of the updated rectangle.
* @param width Width of the updated rectangle.
* @param height Height of the updated rectangle.
* @param stride Bytes per row in `data`.
* @param format Pixman format code of `data`.
* @param data Pixel data for the rectangle, valid only for the duration of the call.
*/
virtual void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) = 0;
/**
* @brief Handle a scanout backed by shared memory.
*
* @param fd Shared memory descriptor; ownership moves to the listener.
* @param offset Offset of the first pixel in the mapping, in bytes.
* @param width Display width in pixels.
* @param height Display height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
*/
virtual void scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) = 0;
/**
* @brief Handle damage on the current shared memory scanout.
*
* @param x Left edge of the damaged rectangle.
* @param y Top edge of the damaged rectangle.
* @param width Width of the damaged rectangle.
* @param height Height of the damaged rectangle.
*/
virtual void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) = 0;
/**
* @brief Handle the display being turned off.
*/
virtual void disable() = 0;
/**
* @brief Handle a cursor position or visibility change.
*
* @param x Cursor X position in pixels.
* @param y Cursor Y position in pixels.
* @param visible Whether the cursor is visible.
*/
virtual void mouse_set(std::int32_t x, std::int32_t y, bool visible) {
}
/**
* @brief Handle a new cursor shape.
*
* @param width Cursor width in pixels.
* @param height Cursor height in pixels.
* @param hot_x Hot-spot X position.
* @param hot_y Hot-spot Y position.
* @param data ARGB32 cursor pixels, valid only for the duration of the call.
*/
virtual void cursor_define(std::int32_t width, std::int32_t height, std::int32_t hot_x, std::int32_t hot_y, std::span<const std::uint8_t> data) {
}
/**
* @brief Handle the peer-to-peer connection to QEMU closing.
* @details Called at most once. No other method is called afterwards.
*/
virtual void disconnected() = 0;
};
/**
* @brief Registration of a display listener; destroying it unregisters the listener.
* @details After the destructor returns, no method of the listener is invoked again.
*/
class listener_registration_t {
public:
virtual ~listener_registration_t() = default;
};
/**
* @brief Connection to the D-Bus display of one QEMU VM.
* @details All public methods are thread-safe.
*/
class session_t {
public:
/**
* @brief Connect to a bus where QEMU owns `org.qemu` and discover its consoles.
*
* @param address D-Bus address of the bus, or empty for the session bus.
* @param timeout Timeout applied to each D-Bus call.
* @return Connected session, or nullptr when the bus or QEMU is unreachable.
*/
static std::shared_ptr<session_t> connect(const std::string &address, std::chrono::milliseconds timeout = 5s);
virtual ~session_t() = default;
/**
* @brief Snapshot the VM properties, including the current console sizes.
*
* @return VM information.
*/
[[nodiscard]] virtual vm_info_t vm() const = 0;
/**
* @brief Report whether QEMU is still reachable.
*
* @return False once the bus connection closed or `org.qemu` lost its owner.
*/
[[nodiscard]] virtual bool alive() const = 0;
/**
* @brief Register a display listener on a console.
*
* @param console_id Id of the console to listen to.
* @param listener Receiver for the display calls; kept alive by the registration.
* @return Registration handle, or nullptr when QEMU refused the listener.
*/
virtual std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) = 0;
};
/**
* @brief Get the live session for an address, connecting when there is none.
* @details Capture, input, and audio share one session while any of them holds it. A session
* whose QEMU went away is replaced by a new connection attempt.
*
* @param address D-Bus address, or empty for the session bus.
* @return Live session, or nullptr when QEMU is unreachable.
*/
std::shared_ptr<session_t> shared_session(const std::string &address);
/**
* @brief Find a console by id or label.
*
* @param vm VM information to search.
* @param name Console id, label, or empty for the first graphical console.
* @return Matching console, or nothing when no console matches.
*/
std::optional<console_info_t> find_console(const vm_info_t &vm, std::string_view name);
/**
* @brief List the names Sunshine uses for the graphical consoles.
*
* @param vm VM information to list.
* @return Console ids as strings, for graphical consoles only.
*/
std::vector<std::string> graphic_console_names(const vm_info_t &vm);
} // namespace qemu
src_assets/common/assets/web/config.html +1 −0
@@ -304,6 +304,7 @@
"hevc_mode": 0,
"av1_mode": 0,
"capture": "",
"qemu_dbus_address": "",
"encoder": "",
},
},
src_assets/common/assets/web/configs/tabs/Advanced.vue +9 −0
@@ -76,6 +76,7 @@
<option value="x11">X11</option>
<option value="kwin">KWin Screencast</option>
<option value="portal">XDG Portal</option>
<option value="qemu">QEMU D-Bus Display</option>
</template>
<template #windows>
<option value="ddx">Desktop Duplication API</option>
@@ -84,6 +85,14 @@
</PlatformLayout>
</select>
<div class="form-text">{{ $t('config.capture_desc') }}</div>
</div>
<!-- QEMU D-Bus address -->
<div class="mb-3" v-if="platform === 'linux' && config.capture === 'qemu'">
<label for="qemu_dbus_address" class="form-label">{{ $t('config.qemu_dbus_address') }}</label>
<input type="text" class="form-control" id="qemu_dbus_address" placeholder="unix:path=/run/user/1000/qemu-vm0.sock"
v-model="config.qemu_dbus_address" />
<div class="form-text">{{ $t('config.qemu_dbus_address_desc') }}</div>
</div>
<!-- Encoder -->
src_assets/common/assets/web/public/assets/locale/en.json +2 −0
@@ -376,6 +376,8 @@
"port_udp": "UDP",
"port_warning": "Exposing the Web UI to the internet is a security risk! Proceed at your own risk!",
"port_web_ui": "Web UI",
"qemu_dbus_address": "QEMU D-Bus Address",
"qemu_dbus_address_desc": "D-Bus address of the bus the VM's display is on, as given to QEMU with -display dbus,addr=. Leave blank to use the session bus.",
"qp": "Quantization Parameter",
"qp_desc": "Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality.",
"qsv_coder": "QuickSync Coder (H264)",
tests/unit/platform/linux/qemu/fake_qemu.h +629 −0
@@ -1,0 +1,629 @@
/**
* @file tests/unit/platform/linux/qemu/fake_qemu.h
* @brief In-process fake of QEMU's D-Bus display, running on a private dbus-daemon.
* @details The fake exports `org.qemu.Display1.VM` and `org.qemu.Display1.Console` the same way
* QEMU's `ui/dbus-display.c` and `ui/dbus-console.c` do: it replies to `RegisterListener`, then
* acts as the authentication server on the peer-to-peer socket and drives the client's
* `org.qemu.Display1.Listener` object through proxies.
*/
#pragma once
#ifdef SUNSHINE_BUILD_QEMU
// standard includes
#include <chrono>
#include <condition_variable>
#include <csignal>
#include <cstdint>
#include <functional>
#include <future>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
// platform includes
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
// generated includes
extern "C" {
#include "qemu/dbus-display1.h"
}
namespace qemu_test {
using namespace std::literals;
/**
* @brief Poll a condition until it holds or a timeout expires.
*
* @param predicate Condition to wait for.
* @param timeout Maximum time to wait.
* @return True when the condition held before the timeout.
*/
inline bool wait_until(const std::function<bool()> &predicate, std::chrono::milliseconds timeout = 5s) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) {
return true;
}
std::this_thread::sleep_for(5ms);
}
return predicate();
}
/**
* @brief A dbus-daemon spawned for one test.
*/
class private_bus_t {
public:
private_bus_t() {
const gchar *argv[] = {"dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1", nullptr};
gint out_fd = -1;
GError *error = nullptr;
if (!g_spawn_async_with_pipes(nullptr, (gchar **) argv, nullptr, (GSpawnFlags) (G_SPAWN_SEARCH_PATH | G_SPAWN_DO_NOT_REAP_CHILD), nullptr, nullptr, &pid, nullptr, &out_fd, nullptr, &error)) {
g_clear_error(&error);
return;
}
char c;
while (read(out_fd, &c, 1) == 1 && c != '\n') {
bus_address.push_back(c);
}
close(out_fd);
}
~private_bus_t() {
if (pid > 0) {
kill(pid, SIGTERM);
waitpid(pid, nullptr, 0);
g_spawn_close_pid(pid);
}
}
private_bus_t(const private_bus_t &) = delete;
private_bus_t &operator=(const private_bus_t &) = delete;
/**
* @brief Report whether the daemon started.
*
* @return True when the bus address is known.
*/
[[nodiscard]] bool ok() const {
return !bus_address.empty();
}
/**
* @brief Access the bus address.
*
* @return D-Bus address clients connect to.
*/
[[nodiscard]] const std::string &address() const {
return bus_address;
}
private:
GPid pid {0}; ///< Daemon process id.
std::string bus_address; ///< Address printed by the daemon.
};
/**
* @brief Description of a console the fake exports.
*/
struct fake_console_t {
std::uint32_t id; ///< Console id.
std::string label; ///< Console label.
std::string type; ///< "Graphic" or "Text".
std::uint32_t width; ///< Width in pixels.
std::uint32_t height; ///< Height in pixels.
};
/**
* @brief Fake QEMU D-Bus display.
*/
class fake_qemu_t {
public:
/**
* @brief Start the fake and own `org.qemu` on the given bus.
*
* @param address Bus address.
* @param vm_name VM name property.
* @param vm_uuid VM UUID property.
* @param consoles Consoles to export.
*/
fake_qemu_t(const std::string &address, std::string vm_name, std::string vm_uuid, std::vector<fake_console_t> consoles):
context {g_main_context_new()},
loop {g_main_loop_new(context, FALSE)} {
thread = std::thread([this]() {
g_main_context_push_thread_default(context);
g_main_loop_run(loop);
g_main_context_pop_thread_default(context);
});
invoke([&]() {
GError *error = nullptr;
connection = g_dbus_connection_new_for_address_sync(address.c_str(), (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), nullptr, nullptr, &error);
if (!connection) {
g_clear_error(&error);
return;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
vm = qemu_dbus_display1_vm_skeleton_new();
std::vector<guint32> ids;
for (const auto &c : consoles) {
ids.push_back(c.id);
}
qemu_dbus_display1_vm_set_name(vm, vm_name.c_str());
qemu_dbus_display1_vm_set_uuid(vm, vm_uuid.c_str());
qemu_dbus_display1_vm_set_console_ids(vm, g_variant_new_fixed_array(G_VARIANT_TYPE_UINT32, ids.data(), ids.size(), sizeof(guint32)));
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(vm), connection, "/org/qemu/Display1/VM", nullptr);
for (const auto &c : consoles) {
auto &state = console_states[c.id];
state.owner = this;
state.id = c.id;
state.skeleton = qemu_dbus_display1_console_skeleton_new();
qemu_dbus_display1_console_set_label(state.skeleton, c.label.c_str());
qemu_dbus_display1_console_set_type_(state.skeleton, c.type.c_str());
qemu_dbus_display1_console_set_width(state.skeleton, c.width);
qemu_dbus_display1_console_set_height(state.skeleton, c.height);
const gchar *interfaces[] = {"org.qemu.Display1.Keyboard", "org.qemu.Display1.Mouse", nullptr};
qemu_dbus_display1_console_set_interfaces(state.skeleton, interfaces);
g_signal_connect(state.skeleton, "handle-register-listener", G_CALLBACK(&fake_qemu_t::on_register_listener), &state);
auto path = "/org/qemu/Display1/Console_" + std::to_string(c.id);
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(state.skeleton), connection, path.c_str(), nullptr);
}
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", g_variant_new("(su)", "org.qemu", 4u), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error);
if (!reply) {
g_clear_error(&error);
return;
}
g_variant_unref(reply);
started = true;
});
}
~fake_qemu_t() {
invoke([this]() {
for (auto &[id, state] : console_states) {
drop_listener_locked(state);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(state.skeleton));
g_object_unref(state.skeleton);
}
if (vm) {
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(vm));
g_object_unref(vm);
}
if (connection) {
g_dbus_connection_close_sync(connection, nullptr, nullptr);
g_object_unref(connection);
}
});
auto source = g_idle_source_new();
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
g_main_loop_quit((GMainLoop *) data);
return G_SOURCE_REMOVE;
},
loop,
nullptr
);
g_source_attach(source, context);
g_source_unref(source);
thread.join();
g_main_loop_unref(loop);
g_main_context_unref(context);
}
fake_qemu_t(const fake_qemu_t &) = delete;
fake_qemu_t &operator=(const fake_qemu_t &) = delete;
/**
* @brief Report whether the fake owns `org.qemu`.
*
* @return True when startup succeeded.
*/
[[nodiscard]] bool ok() const {
return started;
}
/**
* @brief Wait for a client to register a listener on a console.
*
* @param console_id Console to check.
* @param timeout Maximum time to wait.
* @return True when a listener is registered.
*/
bool wait_for_listener(std::uint32_t console_id, std::chrono::milliseconds timeout = 5s) {
return wait_until(
[&]() {
std::lock_guard lock {mutex};
return console_states[console_id].proxy != nullptr;
},
timeout
);
}
/**
* @brief Read the listener's advertised `Interfaces` property.
*
* @param console_id Console whose listener to query.
* @return Interface names.
*/
std::vector<std::string> listener_interfaces(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].listener_interfaces;
}
/**
* @brief Count how many listeners registered on a console so far.
*
* @param console_id Console to check.
* @return Number of successful registrations.
*/
int registrations(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].registrations;
}
/**
* @brief Report whether the client closed the listener connection.
*
* @param console_id Console to check.
* @return True when the peer connection closed from the client side.
*/
bool listener_closed_by_peer(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].closed_by_peer;
}
/**
* @brief Send a `Scanout` call and wait for the reply.
*
* @param console_id Target console.
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
* @param data Pixel data.
* @return True when the client acknowledged the call.
*/
bool scanout(std::uint32_t console_id, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, const std::vector<std::uint8_t> &data) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, data.data(), data.size(), 1);
bool ok = qemu_dbus_display1_listener_call_scanout_sync(proxy, width, height, stride, format, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send an `Update` call and wait for the reply.
*
* @param console_id Target console.
* @param x Left edge.
* @param y Top edge.
* @param width Rectangle width.
* @param height Rectangle height.
* @param stride Bytes per row.
* @param format Pixman format code.
* @param data Pixel data for the rectangle.
* @return True when the client acknowledged the call.
*/
bool update(std::uint32_t console_id, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, const std::vector<std::uint8_t> &data) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, data.data(), data.size(), 1);
bool ok = qemu_dbus_display1_listener_call_update_sync(proxy, x, y, width, height, stride, format, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `ScanoutMap` call with a shared memory descriptor and wait for the reply.
*
* @param console_id Target console.
* @param fd Descriptor to share; the caller keeps ownership.
* @param offset Offset of the first pixel.
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
* @return True when the client acknowledged the call.
*/
bool scanout_map(std::uint32_t console_id, int fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) {
auto proxy = map_proxy(console_id);
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
g_unix_fd_list_append(fd_list, fd, nullptr);
bool ok = qemu_dbus_display1_listener_unix_map_call_scanout_map_sync(proxy, g_variant_new_handle(0), offset, width, height, stride, format, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send an `UpdateMap` call and wait for the reply.
*
* @param console_id Target console.
* @param x Left edge.
* @param y Top edge.
* @param width Rectangle width.
* @param height Rectangle height.
* @return True when the client acknowledged the call.
*/
bool update_map(std::uint32_t console_id, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
auto proxy = map_proxy(console_id);
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_listener_unix_map_call_update_map_sync(proxy, x, y, width, height, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `Disable` call and wait for the reply.
*
* @param console_id Target console.
* @return True when the client acknowledged the call.
*/
bool disable(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_listener_call_disable_sync(proxy, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send `MouseSet` and `CursorDefine` calls and wait for the replies.
*
* @param console_id Target console.
* @return True when the client acknowledged both calls.
*/
bool cursor(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
std::vector<std::uint8_t> pixels(4 * 2 * 2, 0xff);
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, pixels.data(), pixels.size(), 1);
bool ok = qemu_dbus_display1_listener_call_mouse_set_sync(proxy, 10, 20, 1, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr) &&
qemu_dbus_display1_listener_call_cursor_define_sync(proxy, 2, 2, 1, 1, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `ScanoutDMABUF` call, which the client does not support yet.
*
* @param console_id Target console.
* @return True when the client acknowledged the call without an error.
*/
bool scanout_dmabuf(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
int fd = memfd_create("fake-dmabuf", MFD_CLOEXEC);
g_unix_fd_list_append(fd_list, fd, nullptr);
close(fd);
bool ok = qemu_dbus_display1_listener_call_scanout_dmabuf_sync(proxy, g_variant_new_handle(0), 16, 16, 64, 0x34325258, 0, TRUE, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Close the listener connection from the QEMU side, as when QEMU exits.
*
* @param console_id Target console.
*/
void drop_listener(std::uint32_t console_id) {
invoke([&]() {
std::lock_guard lock {mutex};
drop_listener_locked(console_states[console_id]);
});
}
/**
* @brief Give up ownership of `org.qemu`.
*/
void release_name() {
invoke([&]() {
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReleaseName", g_variant_new("(s)", "org.qemu"), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, nullptr);
if (reply) {
g_variant_unref(reply);
}
});
}
private:
/**
* @brief Per-console fake state.
*/
struct console_state_t {
fake_qemu_t *owner {nullptr}; ///< Owning fake.
std::uint32_t id {0}; ///< Console id.
QemuDBusDisplay1Console *skeleton {nullptr}; ///< Exported console object.
GDBusConnection *peer {nullptr}; ///< Listener peer-to-peer connection.
QemuDBusDisplay1Listener *proxy {nullptr}; ///< Listener proxy.
QemuDBusDisplay1ListenerUnixMap *map {nullptr}; ///< Unix.Map proxy, when advertised.
std::vector<std::string> listener_interfaces; ///< Interfaces the listener advertised.
int registrations {0}; ///< Number of successful registrations.
bool closed_by_peer {false}; ///< Whether the client closed the connection.
};
/**
* @brief Run a function on the fake's thread and wait for it.
*
* @param fn Function to run.
*/
void invoke(const std::function<void()> &fn) {
std::packaged_task<void()> task {fn};
auto done = task.get_future();
auto source = g_idle_source_new();
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
(*(std::packaged_task<void()> *) data)();
return G_SOURCE_REMOVE;
},
&task,
nullptr
);
g_source_attach(source, context);
g_source_unref(source);
done.get();
}
/**
* @brief Get a new reference to a console's listener proxy.
*
* @param console_id Console to look up.
* @return Proxy reference, or nullptr.
*/
QemuDBusDisplay1Listener *listener_proxy(std::uint32_t console_id) {
std::lock_guard lock {mutex};
auto proxy = console_states[console_id].proxy;
return proxy ? (QemuDBusDisplay1Listener *) g_object_ref(proxy) : nullptr;
}
/**
* @brief Get a new reference to a console's Unix.Map proxy.
*
* @param console_id Console to look up.
* @return Proxy reference, or nullptr.
*/
QemuDBusDisplay1ListenerUnixMap *map_proxy(std::uint32_t console_id) {
std::lock_guard lock {mutex};
auto proxy = console_states[console_id].map;
return proxy ? (QemuDBusDisplay1ListenerUnixMap *) g_object_ref(proxy) : nullptr;
}
/**
* @brief Close and forget a console's listener connection.
*
* @param state Console state; the caller holds the mutex or runs on the fake thread.
*/
void drop_listener_locked(console_state_t &state) {
if (!state.peer) {
return;
}
g_signal_handlers_disconnect_by_data(state.peer, &state);
g_clear_object(&state.proxy);
g_clear_object(&state.map);
g_dbus_connection_close_sync(state.peer, nullptr, nullptr);
g_clear_object(&state.peer);
}
/**
* @brief Record that the client side closed a listener connection.
*
* @param connection Closed connection.
* @param remote_peer_vanished Whether the peer closed it.
* @param error Close reason.
* @param data Console state.
*/
static void on_peer_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *error, gpointer data) {
auto state = (console_state_t *) data;
std::lock_guard lock {state->owner->mutex};
state->closed_by_peer = true;
}
/**
* @brief Handle `RegisterListener` like QEMU's `dbus_console_register_listener`.
*
* @param skeleton Console object.
* @param invocation Method invocation.
* @param fd_list Descriptors attached to the call.
* @param arg_listener Handle of the listener socket.
* @param data Console state.
* @return Always TRUE.
*/
static gboolean on_register_listener(QemuDBusDisplay1Console *skeleton, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *arg_listener, gpointer data) {
auto state = (console_state_t *) data;
GError *error = nullptr;
int fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(arg_listener), &error);
if (fd < 0) {
g_dbus_method_invocation_return_gerror(invocation, error);
g_clear_error(&error);
return TRUE;
}
auto socket = g_socket_new_from_fd(fd, nullptr);
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
qemu_dbus_display1_console_complete_register_listener(skeleton, invocation, nullptr);
auto guid = g_dbus_generate_guid();
auto peer = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), guid, G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_SERVER, nullptr, nullptr, &error);
g_free(guid);
g_object_unref(socket_connection);
if (!peer) {
g_clear_error(&error);
return TRUE;
}
auto proxy = qemu_dbus_display1_listener_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, &error);
if (!proxy) {
g_clear_error(&error);
g_object_unref(peer);
return TRUE;
}
std::vector<std::string> interfaces;
QemuDBusDisplay1ListenerUnixMap *map = nullptr;
if (auto names = qemu_dbus_display1_listener_get_interfaces(proxy)) {
for (auto name = names; *name; ++name) {
interfaces.emplace_back(*name);
}
if (g_strv_contains(names, "org.qemu.Display1.Listener.Unix.Map")) {
map = qemu_dbus_display1_listener_unix_map_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, nullptr);
}
}
g_signal_connect(peer, "closed", G_CALLBACK(&fake_qemu_t::on_peer_closed), state);
std::lock_guard lock {state->owner->mutex};
state->owner->drop_listener_locked(*state);
state->peer = peer;
state->proxy = proxy;
state->map = map;
state->listener_interfaces = std::move(interfaces);
state->registrations += 1;
state->closed_by_peer = false;
return TRUE;
}
GMainContext *context; ///< Fake's main context.
GMainLoop *loop; ///< Fake's main loop.
std::thread thread; ///< Thread running the loop.
GDBusConnection *connection {nullptr}; ///< Bus connection.
QemuDBusDisplay1VM *vm {nullptr}; ///< Exported VM object.
std::map<std::uint32_t, console_state_t> console_states; ///< Consoles by id.
std::mutex mutex; ///< Guards listener state read by the test thread.
bool started {false}; ///< Whether startup succeeded.
};
} // namespace qemu_test
#endif
tests/unit/platform/linux/qemu/test_capture.cpp +377 −0
@@ -1,0 +1,377 @@
/**
* @file tests/unit/platform/linux/qemu/test_capture.cpp
* @brief Test the QEMU capture backend against a fake QEMU.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "fake_qemu.h"
// standard includes
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <thread>
// local includes
#include <src/config.h>
#include <src/platform/common.h>
#include <src/platform/linux/qemu/session.h>
#include <src/video.h>
using namespace std::literals;
namespace platf {
bool verify_qemu();
std::vector<std::string> qemu_display_names();
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config);
} // namespace platf
namespace {
/**
* @brief A 60 fps stream configuration.
*
* @return Video configuration.
*/
video::config_t stream_config() {
video::config_t config {};
config.width = 1280;
config.height = 720;
config.framerate = 60;
return config;
}
/**
* @brief Build a solid BGRX frame.
*
* @param width Width in pixels.
* @param height Height in pixels.
* @param b Blue.
* @param g Green.
* @param r Red.
* @return Pixel bytes with stride `4 * width`.
*/
std::vector<std::uint8_t> solid(int width, int height, std::uint8_t b, std::uint8_t g, std::uint8_t r) {
std::vector<std::uint8_t> data(width * height * 4);
for (int i = 0; i < width * height; ++i) {
data[i * 4] = b;
data[i * 4 + 1] = g;
data[i * 4 + 2] = r;
data[i * 4 + 3] = 0xff;
}
return data;
}
/**
* @brief Fixture with a fake QEMU and `capture = qemu` pointing at it.
*/
class QemuCaptureTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
GTEST_SKIP() << "dbus-daemon is not available; REQ-CAP-001/REQ-CAP-002 capture tests need it";
}
fake = std::make_unique<qemu_test::fake_qemu_t>(
bus->address(),
"capture-vm",
"00000000-0000-0000-0000-000000000007",
std::vector<qemu_test::fake_console_t> {
{0, "serial0", "Text", 640, 480},
{1, "VGA", "Graphic", 64, 48},
{2, "virtio-gpu-pci.1", "Graphic", 32, 24},
}
);
ASSERT_TRUE(fake->ok());
config::video.capture = "qemu";
config::video.qemu_dbus_address = bus->address();
}
void TearDown() override {
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
fake.reset();
bus.reset();
BaseTest::TearDown();
}
/**
* @brief Create a display while the fake sends the initial scanout, like QEMU does.
*
* @param display_name Console to open.
* @param console_id Console the fake scans out on.
* @param width Scanout width.
* @param height Scanout height.
* @return Display, or nullptr.
*/
std::shared_ptr<platf::display_t> open_display(const std::string &display_name, std::uint32_t console_id, int width, int height) {
std::thread sender {[&, console_id, width, height]() {
if (fake->wait_for_listener(console_id)) {
fake->scanout(console_id, width, height, width * 4, qemu::pixman_format::x8r8g8b8, solid(width, height, 0x10, 0x20, 0x30));
}
}};
auto display = platf::qemu_display(platf::mem_type_e::system, display_name, stream_config());
sender.join();
return display;
}
/**
* @brief Run capture until `on_frame` returns false, or about 10 seconds of 60 fps ticks pass.
*
* @param display Display to capture from.
* @param on_frame Called for each pushed image; return false to stop.
* @return Capture status; `ok` also when the tick limit stopped the capture.
*/
platf::capture_e run_capture(platf::display_t &display, const std::function<bool(std::shared_ptr<platf::img_t> &&, bool)> &on_frame) {
int ticks = 0;
auto bounded = [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
return ++ticks < 600 && on_frame(std::move(img), frame_captured);
};
std::vector<std::shared_ptr<platf::img_t>> pool;
auto pull = [&](std::shared_ptr<platf::img_t> &img_out) -> bool {
for (auto &img : pool) {
if (img.use_count() == 1) {
img_out = img;
return true;
}
}
pool.push_back(display.alloc_img());
img_out = pool.back();
return true;
};
bool cursor = false;
return display.capture(bounded, pull, &cursor);
}
std::unique_ptr<qemu_test::private_bus_t> bus;
std::unique_ptr<qemu_test::fake_qemu_t> fake;
std::string saved_capture;
std::string saved_address;
};
} // namespace
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, VerifyAndListGraphicConsoles) {
EXPECT_TRUE(platf::verify_qemu());
EXPECT_EQ(platf::qemu_display_names(), (std::vector<std::string> {"1", "2"}));
}
// @tag requirements: [REQ-CAP-002, REQ-E2E-001]
TEST_F(QemuCaptureTest, CapturesScanoutFrames) {
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 8);
EXPECT_EQ(display->height, 4);
EXPECT_EQ(display->env_width, 8);
EXPECT_EQ(display->env_height, 4);
std::vector<std::uint8_t> captured;
std::optional<std::chrono::steady_clock::time_point> timestamp;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
captured.assign(img->data, img->data + img->height * img->row_pitch);
timestamp = img->frame_timestamp;
return false;
});
EXPECT_EQ(status, platf::capture_e::ok);
ASSERT_EQ(captured.size(), 8 * 4 * 4);
EXPECT_EQ(captured[0], 0x10);
EXPECT_EQ(captured[1], 0x20);
EXPECT_EQ(captured[2], 0x30);
EXPECT_TRUE(timestamp.has_value());
}
// @tag requirements: [REQ-CAP-002]
TEST_F(QemuCaptureTest, PushesOnlyWhenTheGuestChangesTheFrame) {
auto display = open_display("", 1, 4, 4);
ASSERT_NE(display, nullptr);
int captured = 0;
int idle = 0;
std::thread painter;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (frame_captured) {
captured += 1;
if (captured == 1) {
painter = std::thread {[&]() {
std::this_thread::sleep_for(100ms);
fake->update(1, 0, 0, 1, 1, 4, qemu::pixman_format::x8r8g8b8, {0xaa, 0xbb, 0xcc, 0xff});
}};
} else {
EXPECT_EQ(img->data[0], 0xaa);
return false;
}
} else {
idle += 1;
}
return idle < 600;
});
if (painter.joinable()) {
painter.join();
}
EXPECT_EQ(status, platf::capture_e::ok);
EXPECT_EQ(captured, 2);
EXPECT_GT(idle, 0);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, ResizeRequestsReinit) {
auto display = open_display("1", 1, 4, 4);
ASSERT_NE(display, nullptr);
std::thread resizer {[&]() {
std::this_thread::sleep_for(50ms);
fake->scanout(1, 8, 8, 32, qemu::pixman_format::x8r8g8b8, solid(8, 8, 0, 0, 0));
}};
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&, bool) {
return true;
});
resizer.join();
EXPECT_EQ(status, platf::capture_e::reinit);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, VmGoneRequestsReinit) {
auto display = open_display("1", 1, 4, 4);
ASSERT_NE(display, nullptr);
std::thread dropper {[&]() {
std::this_thread::sleep_for(50ms);
fake->drop_listener(1);
}};
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&, bool) {
return true;
});
dropper.join();
EXPECT_EQ(status, platf::capture_e::reinit);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, UnknownConsoleFallsBackToFirstGraphicConsole) {
auto display = open_display("HDMI-1", 1, 6, 2);
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 6);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, UsesConsoleSizeWhenNoScanoutArrives) {
auto display = platf::qemu_display(platf::mem_type_e::system, "2", stream_config());
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 32);
EXPECT_EQ(display->height, 24);
}
// @tag requirements: [REQ-CAP-002]
TEST_F(QemuCaptureTest, AllocatesBgrxImagesAndBlackDummy) {
auto display = open_display("1", 1, 4, 2);
ASSERT_NE(display, nullptr);
auto img = display->alloc_img();
ASSERT_NE(img, nullptr);
EXPECT_EQ(img->width, 4);
EXPECT_EQ(img->height, 2);
EXPECT_EQ(img->pixel_pitch, 4);
EXPECT_EQ(img->row_pitch, 16);
std::memset(img->data, 0x7f, 16 * 2);
EXPECT_EQ(display->dummy_img(img.get()), 0);
EXPECT_EQ(img->data[5], 0);
EXPECT_EQ(display->dummy_img(nullptr), -1);
EXPECT_NE(display->make_avcodec_encode_device(platf::pix_fmt_e::nv12), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, RejectsUnsupportedMemoryType) {
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::vulkan, "1", stream_config()), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, FailsWhenQemuIsUnreachable) {
config::video.qemu_dbus_address = "unix:path=/nonexistent/sunshine-qemu-capture.sock";
EXPECT_FALSE(platf::verify_qemu());
EXPECT_TRUE(platf::qemu_display_names().empty());
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::system, "1", stream_config()), nullptr);
}
/**
* @brief Fixture that listens on a Unix socket to detect connection attempts.
*/
class QemuNotSelectedTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
// Unix socket paths are limited to 108 bytes, so don't use the (long) build directory
char dir_template[] = "/tmp/sunshine-qemu-cmp-XXXXXX";
ASSERT_NE(mkdtemp(dir_template), nullptr);
dir = dir_template;
path = dir + "/bus.sock";
listen_fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
ASSERT_GE(listen_fd, 0);
sockaddr_un addr {};
addr.sun_family = AF_UNIX;
ASSERT_LT(path.size(), sizeof(addr.sun_path));
std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
ASSERT_EQ(bind(listen_fd, (sockaddr *) &addr, sizeof(addr)), 0);
ASSERT_EQ(listen(listen_fd, 4), 0);
config::video.qemu_dbus_address = "unix:path=" + path;
}
void TearDown() override {
if (listen_fd >= 0) {
close(listen_fd);
}
unlink(path.c_str());
rmdir(dir.c_str());
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
BaseTest::TearDown();
}
/**
* @brief Report whether anything connected to the socket.
*
* @return True when a pending connection was accepted.
*/
bool connection_attempted() const {
int fd = accept(listen_fd, nullptr, nullptr);
if (fd >= 0) {
close(fd);
return true;
}
return false;
}
int listen_fd {-1};
std::string dir;
std::string path;
std::string saved_capture;
std::string saved_address;
};
// @tag requirements: [REQ-CMP-001]
TEST_F(QemuNotSelectedTest, NeverConnectsUnlessCaptureIsQemu) {
for (auto capture : {"", "kms", "x11", "wlr", "portal", "kwin", "nvfbc", "QEMU"}) {
config::video.capture = capture;
EXPECT_FALSE(platf::verify_qemu()) << "capture = " << capture;
EXPECT_FALSE(connection_attempted()) << "capture = " << capture;
}
}
// @tag requirements: [REQ-CMP-001]
TEST_F(QemuNotSelectedTest, SelectingQemuDoesConnect) {
config::video.capture = "qemu";
EXPECT_FALSE(platf::verify_qemu()); // the socket is not a D-Bus server
EXPECT_TRUE(connection_attempted());
}
#endif
tests/unit/platform/linux/qemu/test_frame_store.cpp +284 −0
@@ -1,0 +1,284 @@
/**
* @file tests/unit/platform/linux/qemu/test_frame_store.cpp
* @brief Test reconstruction of the guest framebuffer from QEMU listener calls.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
// standard includes
#include <array>
#include <thread>
// platform includes
#include <sys/mman.h>
#include <unistd.h>
// local includes
#include <src/platform/linux/qemu/frame_store.h>
using namespace std::literals;
namespace {
/**
* @brief Build a pixel buffer with a given stride where each pixel encodes its position.
*
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row, at least `4 * width`.
* @param order Byte order of the channels, as indices of B, G, R, X within a pixel.
* @return Pixel bytes with padding bytes set to 0xee.
*/
std::vector<std::uint8_t> make_pixels(int width, int height, int stride, std::array<int, 4> order = {0, 1, 2, 3}) {
std::vector<std::uint8_t> data(stride * height, 0xee);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
auto p = &data[y * stride + x * 4];
p[order[0]] = (std::uint8_t) (x * 16); // B
p[order[1]] = (std::uint8_t) (y * 16); // G
p[order[2]] = (std::uint8_t) (0x80 | x | (y << 4)); // R
p[order[3]] = 0xff; // X
}
}
return data;
}
/**
* @brief Read one BGR pixel from a tight BGRX buffer.
*
* @param frame Frame bytes.
* @param width Frame width.
* @param x Pixel X.
* @param y Pixel Y.
* @return B, G, R values.
*/
std::array<std::uint8_t, 3> bgr_at(const std::vector<std::uint8_t> &frame, int width, int x, int y) {
auto p = &frame[(y * width + x) * 4];
return {p[0], p[1], p[2]};
}
std::array<std::uint8_t, 3> expected_bgr(int x, int y) {
return {(std::uint8_t) (x * 16), (std::uint8_t) (y * 16), (std::uint8_t) (0x80 | x | (y << 4))};
}
} // namespace
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ScanoutCopiesX8R8G8B8WithStridePadding) {
qemu::frame_store_t store;
EXPECT_FALSE(store.wait_for_frame(0ms));
auto data = make_pixels(3, 2, 16);
store.scanout(3, 2, 16, qemu::pixman_format::x8r8g8b8, data);
ASSERT_TRUE(store.wait_for_frame(0ms));
EXPECT_EQ(store.width(), 3);
EXPECT_EQ(store.height(), 2);
std::vector<std::uint8_t> frame(3 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 3, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(seq, store.sequence());
EXPECT_LE(timestamp, std::chrono::steady_clock::now());
for (int y = 0; y < 2; ++y) {
for (int x = 0; x < 3; ++x) {
EXPECT_EQ(bgr_at(frame, 3, x, y), expected_bgr(x, y)) << x << "," << y;
}
}
EXPECT_EQ(store.copy_if_newer(seq, 3, 2, frame.data(), timestamp), qemu::frame_status_e::unchanged);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ScanoutConvertsAllSupportedFormats) {
struct format_case {
std::uint32_t format;
std::array<int, 4> order;
};
for (auto [format, order] : {
format_case {qemu::pixman_format::x8r8g8b8, {0, 1, 2, 3}},
format_case {qemu::pixman_format::a8r8g8b8, {0, 1, 2, 3}},
format_case {qemu::pixman_format::x8b8g8r8, {2, 1, 0, 3}},
format_case {qemu::pixman_format::a8b8g8r8, {2, 1, 0, 3}},
}) {
qemu::frame_store_t store;
store.scanout(2, 2, 8, format, make_pixels(2, 2, 8, order));
std::vector<std::uint8_t> frame(2 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame) << std::hex << format;
EXPECT_EQ(bgr_at(frame, 2, 1, 1), expected_bgr(1, 1)) << std::hex << format;
}
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, IgnoresUnsupportedFormatAndShortData) {
qemu::frame_store_t store;
store.scanout(2, 2, 4, 0x10020565, std::vector<std::uint8_t>(8)); // r5g6b5
EXPECT_FALSE(store.wait_for_frame(0ms));
store.scanout(2, 2, 8, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(12)); // too short
EXPECT_FALSE(store.wait_for_frame(0ms));
store.scanout(2, 2, 4, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(16)); // stride too small
EXPECT_FALSE(store.wait_for_frame(0ms));
store.update(0, 0, 1, 1, 4, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(4)); // update before scanout
EXPECT_EQ(store.sequence(), 0);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, UpdateBlitsDamageRectangleAndClips) {
qemu::frame_store_t store;
store.scanout(4, 3, 16, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(4 * 3 * 4, 0));
auto after_scanout = store.sequence();
std::vector<std::uint8_t> rect(2 * 1 * 4);
for (int i = 0; i < 2; ++i) {
rect[i * 4] = 0x11;
rect[i * 4 + 1] = 0x22;
rect[i * 4 + 2] = 0x33;
}
store.update(1, 2, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_GT(store.sequence(), after_scanout);
// entirely outside of the frame: ignored
auto before_outside = store.sequence();
store.update(10, 10, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_EQ(store.sequence(), before_outside);
// partially outside: clipped to the right edge
store.update(3, 0, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
std::vector<std::uint8_t> frame(4 * 3 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 4, 3, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 4, 0, 2), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 1, 2), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
EXPECT_EQ(bgr_at(frame, 4, 2, 2), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
EXPECT_EQ(bgr_at(frame, 4, 3, 2), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 2, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 3, 0), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
// an update with insufficient data is rejected
auto before_short = store.sequence();
store.update(0, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_EQ(store.sequence(), before_short);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, SharedMapScanoutAndDamage) {
const int width = 3;
const int height = 2;
const int stride = 12;
const std::uint32_t offset = 4096;
const std::size_t size = offset + stride * height;
int fd = memfd_create("frame-store-test", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, size), 0);
auto map = (std::uint8_t *) mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
ASSERT_NE(map, MAP_FAILED);
auto pixels = make_pixels(width, height, stride);
std::copy(pixels.begin(), pixels.end(), map + offset);
qemu::frame_store_t store;
store.scanout_map(qemu::fd_t {dup(fd)}, offset, width, height, stride, qemu::pixman_format::x8r8g8b8);
std::vector<std::uint8_t> frame(width * height * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, width, 2, 1), expected_bgr(2, 1));
// the guest draws into the shared memory, then QEMU reports the damage
map[offset + stride * 1 + 4 * 1] = 0x42;
EXPECT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::unchanged);
store.update_map(1, 1, 1, 1);
ASSERT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, width, 1, 1)[0], 0x42);
// damage outside the frame is ignored
auto before = store.sequence();
store.update_map(5, 5, 1, 1);
EXPECT_EQ(store.sequence(), before);
// a message scanout replaces the map; later UpdateMap calls are ignored
store.scanout(width, height, stride, qemu::pixman_format::x8r8g8b8, pixels);
before = store.sequence();
store.update_map(0, 0, 1, 1);
EXPECT_EQ(store.sequence(), before);
munmap(map, size);
close(fd);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, SharedMapRejectsBadDescriptorOrGeometry) {
qemu::frame_store_t store;
store.scanout_map(qemu::fd_t {}, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8);
EXPECT_FALSE(store.wait_for_frame(0ms));
// file smaller than the advertised geometry: mapping it would fault on read
int small_fd = memfd_create("frame-store-small", MFD_CLOEXEC);
ASSERT_GE(small_fd, 0);
ASSERT_EQ(ftruncate(small_fd, 4), 0);
store.scanout_map(qemu::fd_t {small_fd}, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8);
EXPECT_FALSE(store.wait_for_frame(0ms));
int fd = memfd_create("frame-store-format", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, 64), 0);
store.scanout_map(qemu::fd_t {fd}, 0, 2, 2, 8, 0x10020565);
EXPECT_FALSE(store.wait_for_frame(0ms));
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ReportsSizeChange) {
qemu::frame_store_t store;
store.scanout(2, 2, 8, qemu::pixman_format::x8r8g8b8, make_pixels(2, 2, 8));
store.scanout(4, 2, 16, qemu::pixman_format::x8r8g8b8, make_pixels(4, 2, 16));
std::vector<std::uint8_t> frame(2 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
EXPECT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::size_changed);
EXPECT_EQ(seq, 0);
EXPECT_EQ(store.width(), 4);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, DisableBlanksFrame) {
qemu::frame_store_t store;
store.scanout(2, 1, 8, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(8, 0x7f));
store.disable();
std::vector<std::uint8_t> frame(2 * 1 * 4, 0x55);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 2, 1, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 2, 0, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 2, 1, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
}
// @tag requirements: [REQ-CAP-001]
TEST(QemuFrameStoreTest, DisconnectWakesWaitersAndIsReported) {
qemu::frame_store_t store;
EXPECT_TRUE(store.connected());
std::thread disconnector {[&]() {
std::this_thread::sleep_for(20ms);
store.disconnected();
}};
EXPECT_FALSE(store.wait_for_frame(5s));
disconnector.join();
EXPECT_FALSE(store.connected());
std::vector<std::uint8_t> frame(4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
EXPECT_EQ(store.copy_if_newer(seq, 1, 1, frame.data(), timestamp), qemu::frame_status_e::disconnected);
}
#endif
tests/unit/platform/linux/qemu/test_session.cpp +448 −0
@@ -1,0 +1,448 @@
/**
* @file tests/unit/platform/linux/qemu/test_session.cpp
* @brief Test the QEMU D-Bus display session against a fake QEMU.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "fake_qemu.h"
// standard includes
#include <atomic>
#include <cstring>
#include <fcntl.h>
#include <mutex>
// local includes
#include <src/platform/linux/qemu/session.h>
using namespace std::literals;
namespace {
/**
* @brief Listener that records what the session delivers.
*/
struct recording_listener_t: qemu::display_listener_t {
std::mutex mutex;
std::vector<std::string> calls;
std::vector<std::uint8_t> last_data;
std::uint32_t last_width {0};
std::uint32_t last_height {0};
std::uint32_t last_stride {0};
std::uint32_t last_format {0};
std::int32_t last_x {0};
std::int32_t last_y {0};
std::uint32_t last_offset {0};
std::vector<std::uint8_t> mapped_bytes;
std::atomic<int> disconnects {0};
void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("scanout");
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
last_data.assign(data.begin(), data.end());
}
void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("update");
last_x = x;
last_y = y;
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
last_data.assign(data.begin(), data.end());
}
void scanout_map(qemu::fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) override {
std::lock_guard lock {mutex};
calls.emplace_back("scanout_map");
last_offset = offset;
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
auto size = offset + stride * height;
auto addr = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd.get(), 0);
if (addr != MAP_FAILED) {
auto bytes = (const std::uint8_t *) addr;
mapped_bytes.assign(bytes + offset, bytes + size);
munmap(addr, size);
}
}
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override {
std::lock_guard lock {mutex};
calls.emplace_back("update_map");
last_x = x;
last_y = y;
last_width = width;
last_height = height;
}
void disable() override {
std::lock_guard lock {mutex};
calls.emplace_back("disable");
}
void mouse_set(std::int32_t x, std::int32_t y, bool visible) override {
std::lock_guard lock {mutex};
calls.emplace_back("mouse_set");
}
void cursor_define(std::int32_t width, std::int32_t height, std::int32_t hot_x, std::int32_t hot_y, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("cursor_define");
}
void disconnected() override {
disconnects += 1;
}
std::vector<std::string> snapshot_calls() {
std::lock_guard lock {mutex};
return calls;
}
};
/**
* @brief Fixture that provides a private bus and a fake QEMU with two consoles.
*/
class QemuSessionTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
GTEST_SKIP() << "dbus-daemon is not available; REQ-CAP-001 session tests need it";
}
}
void TearDown() override {
fake.reset();
bus.reset();
BaseTest::TearDown();
}
void start_fake() {
fake = std::make_unique<qemu_test::fake_qemu_t>(
bus->address(),
"test-vm",
"00000000-0000-0000-0000-000000000042",
std::vector<qemu_test::fake_console_t> {
{0, "serial0", "Text", 640, 480},
{1, "VGA", "Graphic", 1024, 768},
{2, "virtio-gpu-pci.1", "Graphic", 800, 600},
}
);
ASSERT_TRUE(fake->ok());
}
std::unique_ptr<qemu_test::private_bus_t> bus;
std::unique_ptr<qemu_test::fake_qemu_t> fake;
};
} // namespace
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectDiscoversVmAndConsoles) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
EXPECT_TRUE(session->alive());
auto vm = session->vm();
EXPECT_EQ(vm.name, "test-vm");
EXPECT_EQ(vm.uuid, "00000000-0000-0000-0000-000000000042");
ASSERT_EQ(vm.consoles.size(), 3);
EXPECT_EQ(vm.consoles[0].id, 0);
EXPECT_EQ(vm.consoles[0].label, "serial0");
EXPECT_FALSE(vm.consoles[0].is_graphic());
EXPECT_EQ(vm.consoles[1].id, 1);
EXPECT_EQ(vm.consoles[1].label, "VGA");
EXPECT_TRUE(vm.consoles[1].is_graphic());
EXPECT_EQ(vm.consoles[1].width, 1024);
EXPECT_EQ(vm.consoles[1].height, 768);
EXPECT_NE(std::ranges::find(vm.consoles[1].interfaces, "org.qemu.Display1.Keyboard"), vm.consoles[1].interfaces.end());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectFailsWhenQemuDoesNotOwnTheName) {
auto start = std::chrono::steady_clock::now();
auto session = qemu::session_t::connect(bus->address(), 2s);
EXPECT_EQ(session, nullptr);
EXPECT_LT(std::chrono::steady_clock::now() - start, 2s);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectFailsForUnreachableAddress) {
EXPECT_EQ(qemu::session_t::connect("unix:path=/nonexistent/sunshine-qemu-test.sock", 1s), nullptr);
EXPECT_EQ(qemu::session_t::connect("not a dbus address", 1s), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, FindConsoleByIdLabelOrDefault) {
qemu::vm_info_t vm;
vm.consoles = {
{0, "serial0", "Text"},
{1, "VGA", "Graphic"},
{2, "virtio-gpu-pci.1", "Graphic"},
};
EXPECT_EQ(qemu::find_console(vm, "")->id, 1);
EXPECT_EQ(qemu::find_console(vm, "2")->id, 2);
EXPECT_EQ(qemu::find_console(vm, "VGA")->id, 1);
EXPECT_EQ(qemu::find_console(vm, "virtio-gpu-pci.1")->id, 2);
EXPECT_FALSE(qemu::find_console(vm, "7").has_value());
EXPECT_FALSE(qemu::find_console(vm, "HDMI").has_value());
EXPECT_FALSE(qemu::find_console(qemu::vm_info_t {}, "").has_value());
EXPECT_EQ(qemu::graphic_console_names(vm), (std::vector<std::string> {"1", "2"}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, RegisterListenerAdvertisesUnixMap) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
auto interfaces = fake->listener_interfaces(1);
EXPECT_NE(std::ranges::find(interfaces, "org.qemu.Display1.Listener.Unix.Map"), interfaces.end());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, RegisterListenerFailsForUnknownConsole) {
start_fake();
auto session = qemu::session_t::connect(bus->address(), 1s);
ASSERT_NE(session, nullptr);
EXPECT_EQ(session->register_listener(9, std::make_shared<recording_listener_t>()), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversScanoutAndUpdate) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
std::vector<std::uint8_t> frame(4 * 4 * 2);
for (std::size_t i = 0; i < frame.size(); ++i) {
frame[i] = (std::uint8_t) i;
}
ASSERT_TRUE(fake->scanout(1, 4, 2, 16, qemu::pixman_format::x8r8g8b8, frame));
{
std::lock_guard lock {listener->mutex};
EXPECT_EQ(listener->calls.back(), "scanout");
EXPECT_EQ(listener->last_width, 4);
EXPECT_EQ(listener->last_height, 2);
EXPECT_EQ(listener->last_stride, 16);
EXPECT_EQ(listener->last_format, qemu::pixman_format::x8r8g8b8);
EXPECT_EQ(listener->last_data, frame);
}
std::vector<std::uint8_t> rect(4, 0xab);
ASSERT_TRUE(fake->update(1, 3, 1, 1, 1, 4, qemu::pixman_format::a8r8g8b8, rect));
std::lock_guard lock {listener->mutex};
EXPECT_EQ(listener->calls.back(), "update");
EXPECT_EQ(listener->last_x, 3);
EXPECT_EQ(listener->last_y, 1);
EXPECT_EQ(listener->last_width, 1);
EXPECT_EQ(listener->last_height, 1);
EXPECT_EQ(listener->last_format, qemu::pixman_format::a8r8g8b8);
EXPECT_EQ(listener->last_data, rect);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversSharedMapScanoutWithDescriptor) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
const std::uint32_t offset = 8;
const std::uint32_t width = 2;
const std::uint32_t height = 2;
const std::uint32_t stride = 8;
int fd = memfd_create("fake-scanout", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, offset + stride * height), 0);
std::vector<std::uint8_t> pixels(stride * height);
for (std::size_t i = 0; i < pixels.size(); ++i) {
pixels[i] = (std::uint8_t) (0x10 + i);
}
ASSERT_EQ(pwrite(fd, pixels.data(), pixels.size(), offset), (ssize_t) pixels.size());
ASSERT_TRUE(fake->scanout_map(1, fd, offset, width, height, stride, qemu::pixman_format::x8r8g8b8));
close(fd);
ASSERT_TRUE(fake->update_map(1, 1, 0, 1, 2));
std::lock_guard lock {listener->mutex};
ASSERT_EQ(listener->calls, (std::vector<std::string> {"scanout_map", "update_map"}));
EXPECT_EQ(listener->last_offset, offset);
EXPECT_EQ(listener->mapped_bytes, pixels);
EXPECT_EQ(listener->last_x, 1);
EXPECT_EQ(listener->last_y, 0);
EXPECT_EQ(listener->last_width, 1);
EXPECT_EQ(listener->last_height, 2);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversDisableAndCursor) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
ASSERT_TRUE(fake->disable(1));
ASSERT_TRUE(fake->cursor(1));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"disable", "mouse_set", "cursor_define"}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, AcknowledgesUnsupportedDmabufScanout) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
EXPECT_TRUE(fake->scanout_dmabuf(1));
EXPECT_TRUE(listener->snapshot_calls().empty());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ReportsListenerDisconnectWhenQemuDropsIt) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
fake->drop_listener(1);
EXPECT_TRUE(qemu_test::wait_until([&]() {
return listener->disconnects == 1;
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SessionNotAliveWhenQemuReleasesName) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
ASSERT_TRUE(session->alive());
fake->release_name();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return !session->alive();
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SessionNotAliveWhenBusGoesAway) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
fake.reset();
bus.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return !session->alive();
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DestroyingRegistrationClosesListenerAndStopsCallbacks) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
registration.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return fake->listener_closed_by_peer(1);
}));
EXPECT_FALSE(fake->scanout(1, 1, 1, 4, qemu::pixman_format::x8r8g8b8, {0, 0, 0, 0}));
EXPECT_TRUE(listener->snapshot_calls().empty());
EXPECT_EQ(listener->disconnects, 0);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SupportsMultipleListenersAndReregistration) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto first = std::make_shared<recording_listener_t>();
auto second = std::make_shared<recording_listener_t>();
auto first_registration = session->register_listener(1, first);
auto second_registration = session->register_listener(2, second);
ASSERT_NE(first_registration, nullptr);
ASSERT_NE(second_registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
ASSERT_TRUE(fake->wait_for_listener(2));
ASSERT_TRUE(fake->disable(2));
EXPECT_TRUE(first->snapshot_calls().empty());
EXPECT_EQ(second->snapshot_calls(), (std::vector<std::string> {"disable"}));
first_registration.reset();
auto again = std::make_shared<recording_listener_t>();
auto again_registration = session->register_listener(1, again);
ASSERT_NE(again_registration, nullptr);
EXPECT_TRUE(qemu_test::wait_until([&]() {
return fake->registrations(1) == 2;
}));
ASSERT_TRUE(fake->disable(1));
EXPECT_EQ(again->snapshot_calls(), (std::vector<std::string> {"disable"}));
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFdTest, OwnsAndReleasesDescriptor) {
int fds[2];
ASSERT_EQ(pipe(fds), 0);
close(fds[1]);
{
qemu::fd_t owner {fds[0]};
EXPECT_EQ(owner.get(), fds[0]);
qemu::fd_t moved {std::move(owner)};
EXPECT_EQ(owner.get(), -1);
EXPECT_EQ(moved.get(), fds[0]);
qemu::fd_t assigned;
assigned = std::move(moved);
EXPECT_EQ(assigned.get(), fds[0]);
int raw = assigned.release();
EXPECT_EQ(raw, fds[0]);
EXPECT_EQ(assigned.get(), -1);
qemu::fd_t reowned {raw};
}
EXPECT_EQ(fcntl(fds[0], F_GETFD), -1);
}
#endif