ref:a1becb05c9f96f5cdd09c351c18a8be551ad88f0

fix(linux): address review findings in the qemu capture path

- Vulkan zero-copy: crop bottom-up DMABUFs in buffer memory order and flip inside the display rectangle, like QEMU and the GL path, and hand the Vulkan encoder the cursor in buffer coordinates (it subtracts the crop offset). Adds a skip-gated Vulkan pixel test. - Frame store: don't allocate or clear a system-memory frame for every DMABUF scanout when GPU encoders take the descriptors, and never report a new frame when there are no pixels to copy. - Software encoders re-create the display (which reports the error) when QEMU switches mid-stream to a DMABUF that can't be read back, instead of streaming black frames. - ScanoutMap replies with an error when the store can't use the map, so QEMU falls back to Scanout/Update messages; the old map is dropped. - ScanoutDMABUF2 accepts fewer descriptors than planes, as QEMU sends them, and gives planes without one a copy of the previous descriptor. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: a1becb05c9f96f5cdd09c351c18a8be551ad88f0
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 02:04
Parents: cfee508
11 files changed +397 -37
Type
src/platform/linux/qemu/capture.cpp +16 −3
@@ -311,9 +311,16 @@
protected:
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out, bool draw_cursor) override {
if (conn.store->kind() == scanout_kind_e::dmabuf) {
if (mem_type != platf::mem_type_e::system) {
BOOST_LOG(info) << "qemu: QEMU switched to DMABUF scanouts; re-creating the display for zero-copy encoding"sv;
return platf::capture_e::reinit;
}
if (!conn.store->dmabuf_readable()) {
// re-creating the display reports the error instead of streaming black frames
BOOST_LOG(warning) << "qemu: QEMU switched to DMABUF scanouts that can't be read from system memory; re-creating the display"sv;
return platf::capture_e::reinit;
}
if (mem_type != platf::mem_type_e::system && conn.store->kind() == scanout_kind_e::dmabuf) {
BOOST_LOG(info) << "qemu: QEMU switched to DMABUF scanouts; re-creating the display for zero-copy encoding"sv;
return platf::capture_e::reinit;
}
if (conn.store->sequence() == copied_sequence) {
return platf::capture_e::timeout;
@@ -462,6 +469,12 @@
}
img->x = cursor.x - cursor.hot_x;
img->y = cursor.y - cursor.hot_y;
if (mem_type == platf::mem_type_e::vulkan) {
// the Vulkan encoder takes the cursor in buffer coordinates and subtracts the crop offset;
// the GL path (VAAPI, CUDA) takes it in display coordinates
img->x += offset_x;
img->y += offset_y;
}
img->src_w = cursor.width;
img->src_h = cursor.height;
img->width = cursor.width;
src/platform/linux/qemu/frame_store.cpp +27 −11
@@ -164,20 +164,26 @@
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) {
bool 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) {
// QEMU falls back to Scanout/Update messages after a rejection; stop applying damage to the old map
auto reject = [this]() {
std::lock_guard lock {mutex};
unmap_locked();
return false;
};
if (fd.get() < 0) {
BOOST_LOG(warning) << "qemu: ScanoutMap without a descriptor"sv;
return;
return reject();
}
auto layout = layout_or_log(format);
if (!layout) {
return;
return reject();
}
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, layout->bytes_per_pixel)) {
BOOST_LOG(warning) << "qemu: ScanoutMap descriptor doesn't cover "sv << width << 'x' << height << " at offset "sv << offset;
return reject();
return;
}
std::size_t size = (std::size_t) offset + (std::size_t) stride * height;
@@ -185,7 +191,7 @@
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 reject();
return;
}
std::lock_guard lock {mutex};
@@ -203,6 +209,7 @@
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, map_addr + map_offset, map_stride, map_layout);
touch_locked();
return true;
}
void frame_store_t::update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
@@ -244,7 +251,8 @@
dmabuf_generation += 1;
scanout_kind = scanout_kind_e::dmabuf;
is_disabled = false;
resize_locked((int) dmabuf.width, (int) dmabuf.height);
// GPU encoders take the descriptors; don't keep a system memory copy of the frame
resize_locked((int) dmabuf.width, (int) dmabuf.height, readback);
if (readback) {
map_dmabuf_locked();
if (dmabuf_addr) {
@@ -252,9 +260,6 @@
} else {
std::ranges::fill(pixels, 0);
}
} else {
// GPU encoders take the descriptors; don't keep a system memory copy of the frame
pixels.clear();
}
touch_locked();
}
@@ -394,12 +399,18 @@
return change_sequence;
}
std::size_t frame_store_t::frame_memory() const {
std::lock_guard lock {mutex};
return pixels.capacity();
}
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, bool draw_cursor) {
std::lock_guard lock {mutex};
if (is_disconnected) {
return frame_status_e::disconnected;
}
// without pixels (a DMABUF handed to GPU encoders) there is nothing to copy: the caller needs a new display
if (width != frame_width || height != frame_height) {
if (width != frame_width || height != frame_height || pixels.size() != (std::size_t) width * height * 4) {
return frame_status_e::size_changed;
}
if (change_sequence == last_sequence) {
@@ -524,7 +535,7 @@
dmabuf = dmabuf_scanout_t {};
}
void frame_store_t::resize_locked(int new_width, int new_height, bool allocate) {
void frame_store_t::resize_locked(int new_width, int new_height) {
int transport = 0;
if (scanout_kind == scanout_kind_e::dmabuf) {
transport = 2;
@@ -538,6 +549,11 @@
}
frame_width = new_width;
frame_height = new_height;
if (!allocate) {
pixels.clear();
pixels.shrink_to_fit();
return;
}
// every caller overwrites the whole frame, so only a size change needs new memory
const auto size = (std::size_t) new_width * new_height * 4;
if (pixels.size() != size) {
src/platform/linux/qemu/frame_store.h +14 −3
@@ -115,7 +115,7 @@
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;
bool 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 scanout_dmabuf(dmabuf_scanout_t buffer) override;
void update_dmabuf(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override;
@@ -186,6 +186,14 @@
[[nodiscard]] std::uint64_t sequence() const;
/**
* @brief System memory reserved for the frame's pixels.
* @details Zero while a DMABUF scanout is handed to GPU encoders without readback.
*
* @return Capacity of the pixel buffer in bytes.
*/
[[nodiscard]] std::size_t frame_memory() 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`.
@@ -194,6 +202,7 @@
* @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`.
* @param draw_cursor Whether to blend the guest cursor into the copy.
* @return Poll result.
* @return Poll result; `size_changed` also when the frame has no pixels in system memory (a
* DMABUF scanout without readback).
*/
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, bool draw_cursor = false);
@@ -230,8 +239,10 @@
*
* @param new_width New width in pixels.
* @param new_height New height in pixels.
* @param allocate Whether the frame needs pixels in system memory; without them the pixel
* buffer is released.
*/
void resize_locked(int new_width, int new_height);
void resize_locked(int new_width, int new_height, bool allocate = true);
/**
* @brief Convert and copy a rectangle of source pixels into the frame.
src/platform/linux/qemu/session.cpp +18 −5
@@ -19,6 +19,7 @@
#include <utility>
// platform includes
#include <fcntl.h>
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <sys/socket.h>
@@ -1270,7 +1271,9 @@
auto offsets = (const guint32 *) g_variant_get_fixed_array(offset, &offset_count, sizeof(guint32));
auto strides = (const guint32 *) g_variant_get_fixed_array(stride, &stride_count, sizeof(guint32));
const gsize fd_count = g_variant_n_children(dmabuf);
// QEMU stops adding descriptors at the first plane without its own (-1, sharing the previous
if (num_planes < 1 || num_planes > 4 || fd_count < num_planes || offset_count < num_planes || stride_count < num_planes || !fd_list) {
// plane's buffer) but still sends num_planes
if (num_planes < 1 || num_planes > 4 || fd_count < 1 || fd_count > num_planes || offset_count < num_planes || stride_count < num_planes || !fd_list) {
BOOST_LOG(error) << "qemu: ScanoutDMABUF2 with "sv << num_planes << " planes, "sv << fd_count << " descriptors, "sv << offset_count << " offsets and "sv << stride_count << " strides"sv;
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "inconsistent planes");
return TRUE;
@@ -1279,9 +1282,15 @@
dmabuf_scanout_t buffer;
for (guint plane = 0; plane < num_planes; ++plane) {
GError *err = nullptr;
int fd = -1;
if (plane < fd_count) {
auto handle = g_variant_get_child_value(dmabuf, plane);
fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err);
g_variant_unref(handle);
} else {
// EGL_MESA_image_dma_buf_export: a plane without a descriptor is in the previous plane's buffer
auto handle = g_variant_get_child_value(dmabuf, plane);
int fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err);
g_variant_unref(handle);
fd = fcntl(buffer.fds[plane - 1].get(), F_DUPFD_CLOEXEC, 3);
}
if (fd < 0) {
return_invalid_descriptor(invocation, err, "ScanoutDMABUF2");
return TRUE;
@@ -1338,7 +1347,11 @@
return_invalid_descriptor(invocation, err, "ScanoutMap");
return TRUE;
}
if (!((listener_impl_t *) self)->listener->scanout_map(fd_t {fd}, offset, width, height, stride, format)) {
((listener_impl_t *) self)->listener->scanout_map(fd_t {fd}, offset, width, height, stride, format);
// QEMU stops sharing memory with this listener only when the call fails
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_NOT_SUPPORTED, "scanout map not usable");
return TRUE;
}
qemu_dbus_display1_listener_unix_map_complete_scanout_map(object, invocation, nullptr);
return TRUE;
}
src/platform/linux/qemu/session.h +3 −1
@@ -175,8 +175,10 @@
* @param height Display height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
* @return False when the listener can't use the map; QEMU then gets an error and falls back to
* `Scanout` and `Update` messages.
*/
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;
virtual bool 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.
src_assets/linux/assets/shaders/vulkan/rgb2yuv.comp +3 −1
@@ -55,8 +55,10 @@
vec2 scale = vec2(pc.src_size) / vec2(pc.dst_size);
vec2 uv = (vec2(pc.src_offset) + (vec2(d) + 0.5) * scale) * inv_tex;
// Bottom-up buffers: crop the source rectangle in buffer memory order, then flip inside it
// (QEMU's rule, also used by the GL path); for a full-size buffer this is 1.0 - uv.y.
if (pc.y_invert != 0)
uv.y = 1.0 - uv.y;
uv.y = (float(pc.src_offset.y + pc.src_size.y) - (float(d.y) + 0.5) * scale.y) * inv_tex.y;
vec3 rgb = texture(rgb_in, uv).rgb;
if (pc.cursor_size.x > 0)
tests/unit/platform/linux/qemu/test_capture.cpp +98 −3
@@ -801,11 +801,12 @@
// new buffer: new sequence
EXPECT_EQ(seen[2].sequence, 2);
EXPECT_EQ(seen[2].inode, second.inode());
// cursor: drawn by the encoder's cursor shader at the pointer minus the hot spot
// cursor: drawn by the encoder's cursor shader at the pointer minus the hot spot, in buffer
// coordinates for Vulkan (which subtracts the display rectangle's offset (2, 1) again)
EXPECT_EQ(seen[3].sequence, 2);
EXPECT_TRUE(seen[3].has_cursor);
EXPECT_EQ(seen[3].cursor_x, 4);
EXPECT_EQ(seen[3].cursor_x, 2);
EXPECT_EQ(seen[3].cursor_y, 2);
EXPECT_EQ(seen[3].cursor_y, 3);
EXPECT_EQ(seen[3].cursor_w, 2);
// Disable: black frame (sequence 0, no buffer)
EXPECT_EQ(seen[4].sequence, 0);
@@ -815,6 +816,68 @@
EXPECT_EQ(seen[5].inode, second.inode());
}
// @tag requirements: [REQ-CAP-003, REQ-CAP-005]
TEST_F(QemuCaptureTest, GlEncodersTakeTheCursorInDisplayCoordinates) {
memfd_buffer_t buffer {8, 6, 0x10, 0x20, 0x30};
ASSERT_NE(buffer.map, nullptr);
qemu_test::fake_qemu_t::dmabuf2_t layout;
layout.fds = {buffer.fd};
layout.offsets = {0};
layout.strides = {32};
layout.x = 2;
layout.y = 1;
layout.width = 4;
layout.height = 3;
layout.backing_width = 8;
layout.backing_height = 6;
layout.fourcc = qemu::drm_fourcc::xrgb8888;
layout.modifier = qemu::drm_fourcc::mod_linear;
layout.y0_top = false;
std::thread sender {[&]() {
if (fake->wait_for_listener(1)) {
fake->scanout_dmabuf2(1, layout);
}
}};
auto display = platf::qemu_display(platf::mem_type_e::vaapi, "1", stream_config());
sender.join();
if (!display && qemu::default_render_node()) {
GTEST_SKIP() << "QEMU's render node doesn't match VAAPI's on this host";
}
ASSERT_NE(display, nullptr);
std::thread actor;
std::optional<std::pair<int, int>> cursor;
auto status = run_capture(
*display,
[&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
auto d = std::dynamic_pointer_cast<egl::img_descriptor_t>(img);
EXPECT_NE(d, nullptr);
if (d && d->data) {
cursor = std::make_pair(d->x, d->y);
return false;
}
if (!actor.joinable()) {
actor = std::thread {[&]() {
fake->cursor_define(1, 2, 2, 1, 0, std::vector<std::uint8_t>(16, 0xff));
fake->mouse_set(1, 3, 2, true);
}};
}
return d != nullptr;
},
true
);
if (actor.joinable()) {
actor.join();
}
EXPECT_EQ(status, platf::capture_e::ok);
// sws_t::load_vram draws the cursor into the already cropped frame: no crop offset
EXPECT_EQ(cursor, (std::optional<std::pair<int, int>> {{2, 2}}));
}
// @tag requirements: [REQ-CAP-003, REQ-CAP-004]
TEST_F(QemuCaptureTest, GpuEncoderUploadsSharedMemoryAndSwitchesToDmabuf) {
auto display = [&]() {
@@ -881,6 +944,38 @@
// a tiled GPU buffer can't be read from system memory
EXPECT_EQ(open(0x0100000000000001ULL), nullptr);
}
// @tag requirements: [REQ-CAP-003, REQ-CAP-004]
TEST_F(QemuCaptureTest, SoftwareEncoderReinitsWhenQemuSwitchesToAnUnreadableDmabuf) {
auto display = open_display("1", 1, 4, 4);
ASSERT_NE(display, nullptr);
// same size, but a tiled GPU buffer: streaming on would show black forever
memfd_buffer_t buffer {4, 4, 9, 9, 9};
ASSERT_NE(buffer.map, nullptr);
std::atomic_bool switched {false};
int frames_after_switch = 0;
std::thread switcher;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
if (switched) {
frames_after_switch += 1;
} else if (!switcher.joinable()) {
switcher = std::thread {[&]() {
fake->scanout_dmabuf(1, buffer.fd, 4, 4, 16, qemu::drm_fourcc::xrgb8888, 0x0100000000000001ULL, true);
switched = true;
}};
}
return true;
});
if (switcher.joinable()) {
switcher.join();
}
EXPECT_EQ(status, platf::capture_e::reinit);
EXPECT_LE(frames_after_switch, 1) << "black frames of an unreadable DMABUF were streamed";
}
// @tag requirements: [REQ-CAP-003]
tests/unit/platform/linux/qemu/test_dmabuf_gpu.cpp +109 −3
@@ -1,8 +1,9 @@
/**
* @file tests/unit/platform/linux/qemu/test_dmabuf_gpu.cpp
* @brief Test importing QEMU-style DMABUF scanouts through EGL on a real GPU.
* @details Needs `/dev/udmabuf` and a DRM render node with GBM. Hosts without them (containers,
* WSL2) skip these tests, so REQ-CAP-003's EGL import is only verified on native Linux GPU hosts.
* @brief Test importing QEMU-style DMABUF scanouts through EGL and Vulkan on a real GPU.
* @details Needs `/dev/udmabuf` and a DRM render node with GBM (EGL) or a Vulkan device that
* imports DMABUFs. Hosts without them (containers, WSL2) skip these tests, so REQ-CAP-003's GPU
* import is only verified on native Linux GPU hosts.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
@@ -23,7 +24,17 @@
#include <src/platform/linux/graphics.h>
#include <src/platform/linux/qemu/pixel_format.h>
#include <src/platform/linux/qemu/render_node.h>
#include <src/video_colorspace.h>
#ifdef SUNSHINE_BUILD_VULKAN
#include <src/platform/linux/vulkan_encode.h>
extern "C" {
#include <libavutil/frame.h>
#include <libavutil/hwcontext.h>
}
#endif
namespace {
/**
* @brief GPU resources for one test: render node, GBM device, EGL display and context.
@@ -175,4 +186,99 @@
EXPECT_EQ(pixel(0, 1), (std::array<std::uint8_t, 2> {40, 40}));
EXPECT_EQ(pixel(1, 1), (std::array<std::uint8_t, 2> {80, 40}));
}
#ifdef SUNSHINE_BUILD_VULKAN
// @tag requirements: [REQ-CAP-003]
TEST(QemuDmabufGpuTest, VulkanCropsAndFlipsBottomUpScanoutLikeQemu) {
// an 8x6 bottom-up buffer whose 4x2 display rectangle starts at (2, 1): QEMU shows memory rows 2
// and 1, in that order; flipping the whole buffer would show rows 4 and 3
constexpr int backing_width = 8;
constexpr int backing_height = 6;
constexpr int offset_x = 2;
constexpr int offset_y = 1;
constexpr int width = 4;
constexpr int height = 2;
const auto skip_reason = "; REQ-CAP-003 Vulkan import is verified only on native Linux GPU hosts";
int dmabuf = make_udmabuf(backing_width, backing_height);
if (dmabuf < 0) {
GTEST_SKIP() << "/dev/udmabuf is not available" << skip_reason;
}
auto device = vk::make_avcodec_encode_device_vram(width, height, offset_x, offset_y);
ASSERT_NE(device, nullptr);
AVBufferRef *hw_device = nullptr;
if (vk::vulkan_init_avcodec_hardware_input_buffer(device.get(), &hw_device) < 0) {
close(dmabuf);
GTEST_SKIP() << "no Vulkan device" << skip_reason;
}
AVBufferRef *frames_ref = av_hwframe_ctx_alloc(hw_device);
ASSERT_NE(frames_ref, nullptr);
auto frames = (AVHWFramesContext *) frames_ref->data;
frames->format = AV_PIX_FMT_VULKAN;
frames->sw_format = AV_PIX_FMT_NV12;
frames->width = width;
frames->height = height;
device->init_hwframes(frames);
if (av_hwframe_ctx_init(frames_ref) < 0) {
av_buffer_unref(&frames_ref);
av_buffer_unref(&hw_device);
close(dmabuf);
GTEST_SKIP() << "the Vulkan device can't create NV12 encoder frames" << skip_reason;
}
AVFrame *frame = av_frame_alloc();
frame->format = AV_PIX_FMT_VULKAN;
frame->width = width;
frame->height = height;
ASSERT_EQ(device->set_frame(frame, frames_ref), 0); // the device owns the frame now
device->colorspace = video::sunshine_colorspace_t {video::colorspace_e::rec709, true, 8};
device->apply_colorspace();
egl::img_descriptor_t img;
img.sequence = 1;
img.y_invert = true;
img.data = nullptr;
img.sd.width = backing_width;
img.sd.height = backing_height;
img.sd.fds[0] = dmabuf;
img.sd.fds[1] = img.sd.fds[2] = img.sd.fds[3] = -1;
img.sd.fourcc = qemu::drm_fourcc::xrgb8888;
img.sd.modifier = qemu::drm_fourcc::mod_linear;
img.sd.pitches[0] = backing_width * 4;
img.sd.offsets[0] = 0;
const int converted = device->convert(img);
close(dmabuf);
img.sd.fds[0] = -1;
if (converted != 0) {
device.reset();
av_buffer_unref(&frames_ref);
av_buffer_unref(&hw_device);
GTEST_SKIP() << "the Vulkan device couldn't import the DMABUF" << skip_reason;
}
AVFrame *download = av_frame_alloc();
download->format = AV_PIX_FMT_NV12;
ASSERT_EQ(av_hwframe_transfer_data(download, frame, 0), 0);
// make_udmabuf fills B = x * 40, G = y * 40, R = 0x80
const auto colors = video::color_vectors_from_colorspace(device->colorspace, true);
auto expected_y = [&](int buffer_x, int buffer_y) {
const float rgb[3] {0x80 / 255.0f, (float) (buffer_y * 40) / 255.0f, (float) (buffer_x * 40) / 255.0f};
const float y = colors->color_vec_y[0] * rgb[0] + colors->color_vec_y[1] * rgb[1] + colors->color_vec_y[2] * rgb[2] + colors->color_vec_y[3];
return (int) std::lround((y * colors->range_y[0] + colors->range_y[1]) * 255.0f);
};
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const int actual = download->data[0][y * download->linesize[0] + x];
EXPECT_NEAR(actual, expected_y(offset_x + x, offset_y + height - 1 - y), 2) << "display pixel " << x << ',' << y;
}
}
av_frame_free(&download);
device.reset();
av_buffer_unref(&frames_ref);
av_buffer_unref(&hw_device);
}
#endif
#endif
tests/unit/platform/linux/qemu/test_frame_store.cpp +47 −4
@@ -225,7 +225,7 @@
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);
EXPECT_TRUE(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;
@@ -258,21 +258,31 @@
// @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.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.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);
EXPECT_FALSE(store.scanout_map(qemu::fd_t {dup(fd)}, 0, 2, 2, 8, qemu::pixman_format::a8));
store.scanout_map(qemu::fd_t {fd}, 0, 2, 2, 8, qemu::pixman_format::a8);
EXPECT_FALSE(store.wait_for_frame(0ms));
// after a rejected map QEMU sends Update messages for the new surface: the previous map must not
// keep receiving UpdateMap damage or block those updates
ASSERT_TRUE(store.scanout_map(qemu::fd_t {dup(fd)}, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8));
EXPECT_FALSE(store.scanout_map(qemu::fd_t {fd}, 0, 2, 2, 8, qemu::pixman_format::a8));
auto before = store.sequence();
store.update_map(0, 0, 1, 1);
EXPECT_EQ(store.sequence(), before);
store.update(0, 0, 1, 1, 4, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(4, 0x42));
EXPECT_GT(store.sequence(), before);
}
// @tag requirements: [REQ-CAP-002]
@@ -586,6 +596,39 @@
store.disconnected();
EXPECT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::disconnected);
}
// @tag requirements: [REQ-CAP-003, REQ-NFR-001]
TEST(QemuFrameStoreTest, DmabufWithoutReadbackKeepsNoFrameInSystemMemory) {
fake_dmabuf_t dmabuf {8, 6, 32};
ASSERT_NE(dmabuf.map, nullptr);
qemu::frame_store_t store {false};
store.scanout(4, 3, 16, qemu::pixman_format::x8r8g8b8, make_pixels(4, 3, 16));
EXPECT_GE(store.frame_memory(), 4u * 3 * 4);
// guests that flip buffers send a scanout per frame: none of them may allocate or clear a frame
for (int i = 0; i < 3; ++i) {
store.scanout_dmabuf(dmabuf.scanout(2, 1, 4, 3, 8, 6, 32, false));
EXPECT_EQ(store.frame_memory(), 0u) << "scanout " << i;
}
store.update_dmabuf(0, 0, 4, 3);
store.disable();
EXPECT_EQ(store.frame_memory(), 0u);
// a copy of a frame that has no pixels must not report a new frame
std::vector<std::uint8_t> frame(4 * 3 * 4, 0x55);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
EXPECT_EQ(store.copy_if_newer(seq, 4, 3, frame.data(), timestamp), qemu::frame_status_e::size_changed);
EXPECT_EQ(seq, 0u);
EXPECT_EQ(frame, std::vector<std::uint8_t>(4 * 3 * 4, 0x55));
// back to memory scanouts: the frame is allocated again
store.scanout(4, 3, 16, qemu::pixman_format::x8r8g8b8, make_pixels(4, 3, 16));
EXPECT_GE(store.frame_memory(), 4u * 3 * 4);
ASSERT_EQ(store.copy_if_newer(seq, 4, 3, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 4, 3, 2), expected_bgr(3, 2));
}
// @tag requirements: [REQ-CAP-003]
tests/unit/platform/linux/qemu/test_p2p.cpp +2 −1
@@ -43,7 +43,8 @@
void update(std::int32_t, std::int32_t, std::int32_t, std::int32_t, std::uint32_t, std::uint32_t, std::span<const std::uint8_t>) override {
}
void scanout_map(qemu::fd_t, std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t) override {
bool scanout_map(qemu::fd_t, std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t) override {
return true;
}
void update_map(std::int32_t, std::int32_t, std::int32_t, std::int32_t) override {
tests/unit/platform/linux/qemu/test_session.cpp +60 −2
@@ -15,6 +15,7 @@
#include <sys/stat.h>
// local includes
#include <src/platform/linux/qemu/frame_store.h>
#include <src/platform/linux/qemu/session.h>
using namespace std::literals;
@@ -38,6 +39,7 @@
qemu::dmabuf_scanout_t last_dmabuf;
std::vector<ino_t> dmabuf_inodes;
std::atomic<int> disconnects {0};
bool accept_map {true};
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};
@@ -61,9 +63,12 @@
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 {
bool 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");
if (!accept_map) {
return false;
}
last_offset = offset;
last_width = width;
last_height = height;
@@ -76,6 +81,7 @@
mapped_bytes.assign(bytes + offset, bytes + size);
munmap(addr, size);
}
return true;
}
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override {
@@ -346,6 +352,38 @@
EXPECT_EQ(listener->last_height, 2);
}
// @tag requirements: [REQ-CAP-002]
TEST_F(QemuSessionTest, RejectedSharedMapScanoutReturnsAnError) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
listener->accept_map = false;
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
int fd = memfd_create("fake-scanout-rejected", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, 16), 0);
// QEMU stops sharing memory and falls back to Scanout/Update messages only when ScanoutMap fails
EXPECT_FALSE(fake->scanout_map(1, fd, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8));
close(fd);
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"scanout_map"}));
// the frame store refuses maps it can't show
auto store = std::make_shared<qemu::frame_store_t>();
auto store_registration = session->register_listener(2, store);
ASSERT_NE(store_registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(2));
fd = memfd_create("fake-scanout-format", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, 16), 0);
EXPECT_FALSE(fake->scanout_map(2, fd, 0, 2, 2, 8, qemu::pixman_format::a8));
EXPECT_TRUE(fake->scanout_map(2, fd, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8));
close(fd);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversDisableAndCursor) {
start_fake();
@@ -465,12 +503,32 @@
EXPECT_TRUE(got.y0_top);
}
// QEMU stops sending descriptors at the first plane that shares the previous plane's buffer (fd -1)
// but keeps num_planes: those planes get a copy of the last descriptor
buffer.fds = {y_fd};
ASSERT_TRUE(fake->scanout_dmabuf2(1, buffer));
{
std::lock_guard lock {listener->mutex};
const auto &got = listener->last_dmabuf;
ASSERT_EQ(got.num_planes, 2);
EXPECT_EQ(listener->dmabuf_inodes, (std::vector<ino_t> {inode_of(y_fd), inode_of(y_fd)}));
EXPECT_NE(got.fds[0].get(), got.fds[1].get()) << "each plane owns its descriptor";
EXPECT_EQ(got.fds[2].get(), -1);
EXPECT_EQ(got.offsets[1], 16);
}
// inconsistent plane counts are rejected with an error, and not delivered
buffer.fds = {y_fd, uv_fd};
buffer.num_planes = 3;
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
buffer.num_planes = 0;
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"scanout_dmabuf"}));
buffer.num_planes = 2;
buffer.fds = {};
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
buffer.fds = {y_fd, uv_fd, y_fd};
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"scanout_dmabuf", "scanout_dmabuf"}));
close(y_fd);
close(uv_fd);