ref:main
/**
* @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