@@ -1,0 +1,608 @@
/**
* @file tests/unit/platform/linux/qemu/test_p2p.cpp
* @brief Test reaching QEMU's D-Bus display peer to peer: QMP add_client and libvirt OpenGraphicsFD.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "fake_qemu.h"
// standard includes
#include <atomic>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// platform includes
#include <dlfcn.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/un.h>
// local includes
#include <src/platform/linux/qemu/p2p.h>
#include <src/platform/linux/qemu/session.h>
using namespace std::literals;
namespace {
/**
* @brief Listener that counts scanouts.
*/
struct counting_listener_t: qemu::display_listener_t {
std::atomic<int> scanouts {0};
std::atomic<int> disconnects {0};
void scanout(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t, std::span<const std::uint8_t>) override {
scanouts += 1;
}
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 {
}
void update_map(std::int32_t, std::int32_t, std::int32_t, std::int32_t) override {
}
void disable() override {
}
void disconnected() override {
disconnects += 1;
}
};
/**
* @brief Create a connected socket pair.
*
* @return Both ends.
*/
std::pair<qemu::fd_t, qemu::fd_t> socket_pair() {
int fds[2] = {-1, -1};
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
return {};
}
return {qemu::fd_t {fds[0]}, qemu::fd_t {fds[1]}};
}
/**
* @brief Minimal QMP server on a Unix socket, like QEMU's `-qmp unix:<path>,server=on,wait=off`.
* @details Serves one client at a time. `getfd` keeps the descriptor sent with SCM_RIGHTS;
* `add_client protocol=@dbus-display` hands it to the fake QEMU.
*/
class fake_qmp_t {
public:
fake_qmp_t(std::string path, qemu_test::fake_qemu_t *qemu):
path {std::move(path)},
qemu {qemu} {
listen_fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
sockaddr_un addr {};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, this->path.c_str(), sizeof(addr.sun_path) - 1);
unlink(this->path.c_str());
if (bind(listen_fd, (sockaddr *) &addr, sizeof(addr)) != 0 || listen(listen_fd, 4) != 0) {
return;
}
thread = std::thread([this]() {
serve();
});
}
~fake_qmp_t() {
stop = true;
if (thread.joinable()) {
thread.join();
}
close(listen_fd);
unlink(path.c_str());
}
fake_qmp_t(const fake_qmp_t &) = delete;
fake_qmp_t &operator=(const fake_qmp_t &) = delete;
std::vector<std::string> commands() {
std::lock_guard lock {mutex};
return received;
}
std::string path; ///< Socket path.
std::atomic<bool> greet {true}; ///< Whether to send the greeting (false: like a monitor busy with another client).
std::string add_client_error; ///< Error to answer add_client with, empty for success.
private:
void serve() {
while (!stop) {
pollfd p {listen_fd, POLLIN, 0};
if (poll(&p, 1, 50) <= 0) {
continue;
}
int client = accept4(listen_fd, nullptr, nullptr, SOCK_CLOEXEC);
if (client < 0) {
continue;
}
handle(client);
close(client);
}
}
void send_line(int client, const std::string &line) {
auto text = line + "\r\n";
(void) ::send(client, text.data(), text.size(), MSG_NOSIGNAL);
}
void handle(int client) {
if (!greet) {
// hold the connection without a greeting until the client gives up
while (!stop) {
pollfd p {client, POLLIN, 0};
if (poll(&p, 1, 50) > 0) {
char buffer[256];
if (recv(client, buffer, sizeof(buffer), 0) <= 0) {
return;
}
}
}
return;
}
send_line(client, R"({"QMP": {"version": {"qemu": {"micro": 1, "minor": 1, "major": 11}, "package": ""}, "capabilities": ["oob"]}})");
std::string buffer;
int pending_fd = -1;
bool negotiated = false;
while (!stop) {
pollfd p {client, POLLIN, 0};
if (poll(&p, 1, 50) <= 0) {
continue;
}
char data[4096];
iovec iov {data, sizeof(data)};
char control[CMSG_SPACE(sizeof(int))];
msghdr msg {};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
auto n = recvmsg(client, &msg, MSG_CMSG_CLOEXEC);
if (n <= 0) {
break;
}
for (auto cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
if (pending_fd >= 0) {
close(pending_fd);
}
std::memcpy(&pending_fd, CMSG_DATA(cmsg), sizeof(int));
}
}
buffer.append(data, (std::size_t) n);
for (auto end = buffer.find('\n'); end != std::string::npos; end = buffer.find('\n')) {
auto line = buffer.substr(0, end);
buffer.erase(0, end + 1);
{
std::lock_guard lock {mutex};
received.push_back(line);
}
// an event before a reply, as QEMU may send, must be skipped by the client
send_line(client, R"({"timestamp": {"seconds": 1, "microseconds": 2}, "event": "NIC_RX_FILTER_CHANGED"})");
if (line.find("qmp_capabilities") != std::string::npos) {
negotiated = true;
send_line(client, R"({"return": {}})");
} else if (!negotiated) {
send_line(client, R"({"error": {"class": "CommandNotFound", "desc": "Expecting capabilities negotiation with 'qmp_capabilities'"}})");
} else if (line.find("\"getfd\"") != std::string::npos) {
send_line(client, pending_fd >= 0 ? R"({"return": {}})" : R"({"error": {"class": "GenericError", "desc": "No file descriptor supplied via SCM_RIGHTS"}})");
} else if (line.find("\"add_client\"") != std::string::npos && line.find("@dbus-display") != std::string::npos) {
if (!add_client_error.empty()) {
send_line(client, R"({"error": {"class": "GenericError", "desc": ")" + add_client_error + R"("}})");
} else if (pending_fd < 0) {
send_line(client, R"({"error": {"class": "GenericError", "desc": "File descriptor named 'x' not found"}})");
} else {
qemu->add_client(std::exchange(pending_fd, -1));
send_line(client, R"({"return": {}})");
}
} else {
send_line(client, R"({"error": {"class": "GenericError", "desc": "unexpected command"}})");
}
}
}
if (pending_fd >= 0) {
close(pending_fd);
}
}
qemu_test::fake_qemu_t *qemu; ///< Receives added clients.
int listen_fd {-1}; ///< Listening socket.
std::thread thread; ///< Server thread.
std::atomic<bool> stop {false}; ///< Stops the server.
std::mutex mutex; ///< Guards `received`.
std::vector<std::string> received; ///< Command lines received.
};
/**
* @brief Fixture with a fake QEMU in peer-to-peer mode (no bus) with two consoles.
*/
class QemuP2pTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
fake = std::make_unique<qemu_test::fake_qemu_t>(
"",
"p2p-vm",
"00000000-0000-0000-0000-0000000000a2",
std::vector<qemu_test::fake_console_t> {
{0, "VGA", "Graphic", 640, 480},
{1, "virtio-gpu-pci.1", "Graphic", 800, 600},
}
);
ASSERT_TRUE(fake->ok());
dir = std::filesystem::temp_directory_path() / ("sunshine-qemu-p2p-" + std::to_string(getpid()));
std::filesystem::create_directories(dir);
}
void TearDown() override {
fake.reset();
std::error_code ec;
std::filesystem::remove_all(dir, ec);
BaseTest::TearDown();
}
public:
/**
* @brief Hand a new socket pair to the fake, as `add_client` does, and return our end.
*
* @return Our end.
*/
qemu::fd_t add_client() {
auto [ours, theirs] = socket_pair();
fake->add_client(theirs.release());
return std::move(ours);
}
protected:
std::unique_ptr<qemu_test::fake_qemu_t> fake;
std::filesystem::path dir;
};
/**
* @brief Path of the fake libvirt module built next to test_sunshine.
*
* @return Module path.
*/
std::string fake_libvirt_path() {
return FAKE_LIBVIRT_PATH;
}
/**
* @brief Access the control functions of the fake libvirt module.
*/
struct fake_libvirt_t {
fake_libvirt_t() {
handle = dlopen(fake_libvirt_path().c_str(), RTLD_NOW | RTLD_LOCAL);
if (handle) {
setup = (decltype(setup)) dlsym(handle, "fake_libvirt_setup");
last_uri = (decltype(last_uri)) dlsym(handle, "fake_libvirt_last_uri");
last_index = (decltype(last_index)) dlsym(handle, "fake_libvirt_last_index");
open_handles = (decltype(open_handles)) dlsym(handle, "fake_libvirt_open_handles");
}
}
~fake_libvirt_t() {
if (handle) {
dlclose(handle);
}
}
void *handle {nullptr};
void (*setup)(const char *, const char *, int (*)(unsigned int, void *), void *) {nullptr};
const char *(*last_uri)() {nullptr};
int (*last_index)() {nullptr};
int (*open_handles)() {nullptr};
};
constexpr auto p2p_domain_xml = R"(<domain type='kvm' id='7'>
<name>win11</name>
<devices>
<graphics type='spice' autoport='yes'>
<listen type='address'/>
</graphics>
<graphics type='dbus' p2p='yes'>
<audio id='1'/>
</graphics>
<audio id='1' type='dbus'/>
</devices>
</domain>)"; ///< Live XML of a domain with a SPICE display and a p2p D-Bus display.
} // namespace
// @tag requirements: [REQ-DEP-002]
TEST(QemuDisplayAddressTest, ParsesBusQmpAndLibvirtAddresses) {
std::string error;
auto bus = qemu::parse_display_address("unix:path=/run/vm/bus.sock", error);
ASSERT_TRUE(bus);
EXPECT_EQ(bus->kind, qemu::display_address_t::kind_e::bus);
EXPECT_EQ(bus->bus, "unix:path=/run/vm/bus.sock");
auto session_bus = qemu::parse_display_address("", error);
ASSERT_TRUE(session_bus);
EXPECT_EQ(session_bus->kind, qemu::display_address_t::kind_e::bus);
EXPECT_TRUE(session_bus->bus.empty());
auto qmp = qemu::parse_display_address("qmp:/run/sunshine-qemu/win11.qmp", error);
ASSERT_TRUE(qmp);
EXPECT_EQ(qmp->kind, qemu::display_address_t::kind_e::qmp);
EXPECT_EQ(qmp->qmp_socket, "/run/sunshine-qemu/win11.qmp");
auto libvirt = qemu::parse_display_address("libvirt:win11", error);
ASSERT_TRUE(libvirt);
EXPECT_EQ(libvirt->kind, qemu::display_address_t::kind_e::libvirt);
EXPECT_EQ(libvirt->domain, "win11");
EXPECT_TRUE(libvirt->uri.empty());
auto with_uri = qemu::parse_display_address("libvirt:win11?uri=qemu:///system", error);
ASSERT_TRUE(with_uri);
EXPECT_EQ(with_uri->domain, "win11");
EXPECT_EQ(with_uri->uri, "qemu:///system");
EXPECT_EQ(qemu::describe(*with_uri), "libvirt domain [win11] on [qemu:///system]");
}
// @tag requirements: [REQ-DEP-002]
TEST(QemuDisplayAddressTest, RejectsAddressesWithoutSocketOrDomain) {
std::string error;
EXPECT_FALSE(qemu::parse_display_address("qmp:", error));
EXPECT_NE(error.find("qmp:"), std::string::npos);
error.clear();
EXPECT_FALSE(qemu::parse_display_address("libvirt:", error));
EXPECT_NE(error.find("domain"), std::string::npos);
error.clear();
EXPECT_FALSE(qemu::parse_display_address("libvirt:?uri=qemu:///system", error));
EXPECT_FALSE(qemu::parse_display_address("libvirt:win11?url=qemu:///system", error));
EXPECT_NE(error.find("uri="), std::string::npos);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, PeerSessionDiscoversConsolesAndStreams) {
auto session = qemu::session_t::connect_peer(add_client(), 5s, 4242u);
ASSERT_NE(session, nullptr);
EXPECT_TRUE(session->alive());
EXPECT_EQ(session->qemu_pid(), 4242u);
auto vm = session->vm();
EXPECT_EQ(vm.name, "p2p-vm");
ASSERT_EQ(vm.consoles.size(), 2u);
EXPECT_EQ(vm.consoles[1].label, "virtio-gpu-pci.1");
auto listener = std::make_shared<counting_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
ASSERT_TRUE(fake->scanout(1, 2, 2, 8, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(16, 0xff)));
EXPECT_TRUE(qemu_test::wait_until([&]() {
return listener->scanouts == 1;
}));
auto input = session->open_input(0);
ASSERT_NE(input, nullptr);
input->key(30, true);
input->key(30, false);
EXPECT_TRUE(input->flush(2s));
EXPECT_EQ(fake->input_calls(0), (std::vector<std::string> {"key press 30", "key release 30"}));
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, PeerSessionRegistersAudioListener) {
struct silent_audio_t: qemu::audio_out_listener_t {
void init(std::uint64_t, const qemu::pcm_format_t &) override {
}
void fini(std::uint64_t) override {
}
void set_enabled(std::uint64_t, bool) override {
}
void set_volume(std::uint64_t, bool, std::span<const std::uint8_t>) override {
}
void write(std::uint64_t, std::span<const std::uint8_t>) override {
}
void disconnected() override {
}
};
auto session = qemu::session_t::connect_peer(add_client());
ASSERT_NE(session, nullptr);
auto registration = session->register_audio_out_listener(std::make_shared<silent_audio_t>());
EXPECT_NE(registration, nullptr);
EXPECT_EQ(fake->audio_registrations(), 1);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, PeerSessionNotAliveWhenQemuClosesTheConnection) {
auto session = qemu::session_t::connect_peer(add_client());
ASSERT_NE(session, nullptr);
ASSERT_TRUE(qemu_test::wait_until([&]() {
return fake->clients() == 1;
}));
fake->close_client();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return !session->alive();
}));
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, PeerSessionFailsWhenNoQemuAnswers) {
auto [ours, theirs] = socket_pair();
const auto start = std::chrono::steady_clock::now();
auto session = qemu::session_t::connect_peer(std::move(ours), 300ms);
EXPECT_EQ(session, nullptr);
EXPECT_LT(std::chrono::steady_clock::now() - start, 3s);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, QmpAddClientConnectsThroughTheMonitor) {
fake_qmp_t qmp {(dir / "qmp.sock").string(), fake.get()};
auto session = qemu::session_t::connect("qmp:" + qmp.path, 5s);
ASSERT_NE(session, nullptr);
EXPECT_EQ(session->vm().name, "p2p-vm");
// the monitor socket belongs to QEMU, here the test process
EXPECT_EQ(session->qemu_pid(), (std::uint32_t) getpid());
auto commands = qmp.commands();
ASSERT_EQ(commands.size(), 3u);
EXPECT_NE(commands[0].find("qmp_capabilities"), std::string::npos);
EXPECT_NE(commands[1].find("\"getfd\""), std::string::npos);
EXPECT_NE(commands[2].find("\"protocol\":\"@dbus-display\""), std::string::npos);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, SharedSessionReconnectsThroughQmpAfterQemuDropsTheClient) {
fake_qmp_t qmp {(dir / "qmp.sock").string(), fake.get()};
const auto address = "qmp:" + qmp.path;
auto first = qemu::shared_session(address);
ASSERT_NE(first, nullptr);
EXPECT_EQ(qemu::shared_session(address), first);
EXPECT_EQ(fake->clients(), 1);
fake->close_client();
ASSERT_TRUE(qemu_test::wait_until([&]() {
return !first->alive();
}));
auto second = qemu::shared_session(address);
ASSERT_NE(second, nullptr);
EXPECT_NE(second, first);
EXPECT_TRUE(second->alive());
EXPECT_EQ(fake->clients(), 2);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, QmpAddClientReportsQemuErrors) {
fake_qmp_t qmp {(dir / "qmp.sock").string(), fake.get()};
qmp.add_client_error = "p2p connections not accepted in bus mode";
std::string error;
auto peer = qemu::qmp_add_client(qmp.path, 2s, error);
EXPECT_FALSE(peer);
EXPECT_NE(error.find("p2p connections not accepted in bus mode"), std::string::npos) << error;
EXPECT_EQ(qemu::session_t::connect("qmp:" + qmp.path, 2s), nullptr);
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, QmpAddClientFailsWithoutMonitorOrGreeting) {
std::string error;
EXPECT_FALSE(qemu::qmp_add_client((dir / "missing.sock").string(), 1s, error));
EXPECT_NE(error.find("missing.sock"), std::string::npos) << error;
fake_qmp_t busy {(dir / "busy.sock").string(), fake.get()};
busy.greet = false;
error.clear();
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(qemu::qmp_add_client(busy.path, 300ms, error));
EXPECT_LT(std::chrono::steady_clock::now() - start, 3s);
EXPECT_NE(error.find("greeting"), std::string::npos) << error;
}
// @tag requirements: [REQ-DEP-002]
TEST(QemuLibvirtTest, FindsTheDbusGraphicsDeviceInDomainXml) {
auto p2p = qemu::find_dbus_graphics(p2p_domain_xml);
ASSERT_TRUE(p2p);
EXPECT_EQ(p2p->index, 1u);
EXPECT_TRUE(p2p->p2p);
auto bus = qemu::find_dbus_graphics(R"(<domain><devices><graphics type="dbus" address="unix:path=/run/libvirt/qemu/dbus/3-win&11-dbus.sock"><gl enable="no"/></graphics></devices></domain>)");
ASSERT_TRUE(bus);
EXPECT_EQ(bus->index, 0u);
EXPECT_FALSE(bus->p2p);
EXPECT_EQ(bus->address, "unix:path=/run/libvirt/qemu/dbus/3-win&11-dbus.sock");
EXPECT_FALSE(qemu::find_dbus_graphics("<domain><devices><graphics type='vnc'/><graphicsx type='dbus'/></devices></domain>"));
}
// @tag requirements: [REQ-DEP-002]
TEST_F(QemuP2pTest, LibvirtOpensP2pDisplayWithOpenGraphicsFD) {
fake_libvirt_t libvirt;
ASSERT_NE(libvirt.setup, nullptr) << "couldn't load " << fake_libvirt_path();
libvirt.setup(
"win11",
p2p_domain_xml,
[](unsigned int, void *user) -> int {
auto self = (QemuP2pTest *) user;
return self->add_client().release();
},
this
);
std::string error;
auto display = qemu::libvirt_open_display("win11", "qemu:///system", error, fake_libvirt_path());
ASSERT_TRUE(display) << error;
EXPECT_EQ(std::string {libvirt.last_uri()}, "qemu:///system");
EXPECT_EQ(libvirt.last_index(), 1);
EXPECT_EQ(libvirt.open_handles(), 0);
ASSERT_TRUE(display->peer);
EXPECT_TRUE(display->bus_address.empty());
auto session = qemu::session_t::connect_peer(std::move(display->peer->fd));
ASSERT_NE(session, nullptr);
EXPECT_EQ(session->vm().name, "p2p-vm");
}
// @tag requirements: [REQ-DEP-002]
TEST(QemuLibvirtTest, UsesThePrivateBusOfABusModeDomain) {
fake_libvirt_t libvirt;
ASSERT_NE(libvirt.setup, nullptr) << "couldn't load " << fake_libvirt_path();
libvirt.setup("desktop", "<domain><devices><graphics type='dbus' address='unix:path=/run/user/1000/libvirt/qemu/run/dbus/4-desktop-dbus.sock'/></devices></domain>", nullptr, nullptr);
std::string error;
auto display = qemu::libvirt_open_display("desktop", "", error, fake_libvirt_path());
ASSERT_TRUE(display) << error;
EXPECT_FALSE(display->peer);
EXPECT_EQ(display->bus_address, "unix:path=/run/user/1000/libvirt/qemu/run/dbus/4-desktop-dbus.sock");
EXPECT_EQ(std::string {libvirt.last_uri()}, "(null)");
EXPECT_EQ(libvirt.last_index(), -1);
EXPECT_EQ(libvirt.open_handles(), 0);
}
// @tag requirements: [REQ-DEP-002]
TEST(QemuLibvirtTest, ReportsLibvirtAndDomainErrors) {
fake_libvirt_t libvirt;
ASSERT_NE(libvirt.setup, nullptr) << "couldn't load " << fake_libvirt_path();
libvirt.setup("win11", "<domain><devices><graphics type='spice'/></devices></domain>", nullptr, nullptr);
std::string error;
EXPECT_FALSE(qemu::libvirt_open_display("win11", "fake:///unreachable", error, fake_libvirt_path()));
EXPECT_NE(error.find("virtqemud-sock"), std::string::npos) << error;
error.clear();
EXPECT_FALSE(qemu::libvirt_open_display("win10", "", error, fake_libvirt_path()));
EXPECT_NE(error.find("win10"), std::string::npos) << error;
EXPECT_NE(error.find("Domain not found"), std::string::npos) << error;
error.clear();
EXPECT_FALSE(qemu::libvirt_open_display("win11", "", error, fake_libvirt_path()));
EXPECT_NE(error.find("<graphics type='dbus'"), std::string::npos) << error;
EXPECT_EQ(libvirt.open_handles(), 0);
error.clear();
EXPECT_FALSE(qemu::libvirt_open_display("win11", "", error, "libvirt-does-not-exist.so.0"));
EXPECT_NE(error.find("libvirt-does-not-exist.so.0"), std::string::npos) << error;
}
// @tag requirements: [REQ-DEP-002]
TEST(QemuLibvirtTest, ReportsAFailedOpenGraphicsFD) {
fake_libvirt_t libvirt;
ASSERT_NE(libvirt.setup, nullptr) << "couldn't load " << fake_libvirt_path();
libvirt.setup("win11", p2p_domain_xml, nullptr, nullptr);
std::string error;
EXPECT_FALSE(qemu::libvirt_open_display("win11", "", error, fake_libvirt_path()));
EXPECT_NE(error.find("can't open graphics"), std::string::npos) << error;
EXPECT_EQ(libvirt.open_handles(), 0);
}
#endif