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