mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-11-05 03:36:59 +09:00
Co-authored-by: Kishi85 <41839133+Kishi85@users.noreply.github.com> Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
287 lines
10 KiB
C++
287 lines
10 KiB
C++
/**
|
|
* @file tests/unit/platform/linux/test_pipewire.cpp
|
|
* @brief Test the teardown of the shared PipeWire capture display.
|
|
*/
|
|
#if defined(SUNSHINE_BUILD_KWIN) || defined(SUNSHINE_BUILD_PORTAL)
|
|
// test includes
|
|
#include "../../../tests_common.h"
|
|
|
|
// standard includes
|
|
#include <array>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
// lib includes
|
|
#include <fcntl.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
// local includes
|
|
#include "src/logging.h"
|
|
#include "src/platform/linux/pipewire.cpp"
|
|
|
|
using namespace std::literals;
|
|
|
|
namespace {
|
|
/**
|
|
* @brief PipeWire display without a capture backend behind it.
|
|
*
|
|
* The tests create it with std::make_shared, as every backend creates its display.
|
|
*/
|
|
class test_display_t: public pipewire::pipewire_display_t {
|
|
public:
|
|
int configure_stream([[maybe_unused]] const std::string &display_name, [[maybe_unused]] int &out_pipewire_fd, [[maybe_unused]] uint32_t &out_pipewire_node, [[maybe_unused]] uint64_t &out_pipewire_objectserial) override {
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* @brief Connect the display's PipeWire core to a socket, as for a stream from the portal.
|
|
*
|
|
* @param fd Connected socket. The display owns it afterwards.
|
|
* @return Result of pipewire_t::init(): 0 on success.
|
|
*/
|
|
int connect(int fd) {
|
|
return pipewire.init(fd, PW_ID_ANY, SPA_ID_INVALID, std::make_shared<pipewire::shared_state_t>());
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @brief Check whether a file descriptor is open.
|
|
*
|
|
* @param fd File descriptor to check.
|
|
* @return True when fd refers to an open file.
|
|
*/
|
|
bool is_open(int fd) {
|
|
return fcntl(fd, F_GETFD) != -1 || errno != EBADF;
|
|
}
|
|
|
|
/**
|
|
* @brief Check whether PipeWire can create a client context with the installed configuration.
|
|
*
|
|
* pw_context_new() fails when client.conf is not installed, for example where only the PipeWire library is.
|
|
*
|
|
* @return True when a context can be created.
|
|
*/
|
|
bool can_create_pipewire_context() {
|
|
pw_loop *loop = pw_loop_new(nullptr);
|
|
if (!loop) {
|
|
return false;
|
|
}
|
|
pw_context *context = pw_context_new(loop, nullptr, 0);
|
|
const bool created = context != nullptr;
|
|
if (context) {
|
|
pw_context_destroy(context);
|
|
}
|
|
pw_loop_destroy(loop);
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* @brief Set an environment variable while the object lives, then restore the previous value.
|
|
*
|
|
* The display logs right before and inside the scope. Draining those records first prevents the logging formatter
|
|
* from reading environment-dependent state concurrently with `setenv()` or `unsetenv()` on older glibc releases.
|
|
*/
|
|
class scoped_env_t {
|
|
public:
|
|
/**
|
|
* @brief Save the previous environment value and install a temporary replacement.
|
|
*
|
|
* @param name Environment variable to override.
|
|
* @param value Temporary value used while this guard exists.
|
|
*/
|
|
scoped_env_t(const char *name, const std::string &value):
|
|
name_ {name} {
|
|
logging::log_flush();
|
|
if (const char *previous = std::getenv(name)) { // NOSONAR(cpp:S1874): `_dupenv_s` not available
|
|
previous_ = previous;
|
|
}
|
|
setenv(name, value.c_str(), 1);
|
|
}
|
|
|
|
/**
|
|
* @brief Restore the saved value or remove a variable that was originally absent.
|
|
*/
|
|
~scoped_env_t() {
|
|
logging::log_flush();
|
|
if (previous_) {
|
|
setenv(name_, previous_->c_str(), 1);
|
|
} else {
|
|
unsetenv(name_);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Disable copying of an active environment override.
|
|
*/
|
|
scoped_env_t(const scoped_env_t &) = delete;
|
|
|
|
/**
|
|
* @brief Disable assignment of an active environment override.
|
|
*/
|
|
scoped_env_t &operator=(const scoped_env_t &) = delete;
|
|
|
|
private:
|
|
const char *name_; ///< Environment variable restored when the guard is destroyed.
|
|
std::optional<std::string> previous_; ///< Previous value, or no value when the variable was absent.
|
|
};
|
|
|
|
/**
|
|
* @brief Destroy a display on another thread and wait for the teardown a limited time.
|
|
*
|
|
* A teardown that deadlocks then fails the test instead of hanging the test run.
|
|
*
|
|
* @param display Display to destroy. It is empty afterwards.
|
|
* @param limit How long to wait.
|
|
* @return True when the teardown finished in time.
|
|
*/
|
|
bool reset_within(std::shared_ptr<test_display_t> &display, std::chrono::milliseconds limit) {
|
|
auto finished = std::make_shared<std::promise<void>>();
|
|
auto done = finished->get_future();
|
|
std::jthread teardown {[display = std::move(display), finished]() mutable {
|
|
display.reset();
|
|
finished->set_value();
|
|
}};
|
|
teardown.detach(); // NOSONAR(cpp:S5962): a hung teardown must fail the test, not hang the test run
|
|
return done.wait_for(limit) == std::future_status::ready;
|
|
}
|
|
|
|
/**
|
|
* @brief Let init() fail with the given PipeWire client configuration, then check that teardown finishes and closes the stream fd.
|
|
*
|
|
* @param client_conf Content of client.conf, or no value to leave client.conf out.
|
|
*/
|
|
void expect_failed_init_closes_stream_fd(const std::optional<std::string> &client_conf) {
|
|
auto directory_template = (std::filesystem::temp_directory_path() / "sunshine-test-pipewire-XXXXXX").string(); // NOSONAR(cpp:S5443): mkdtemp creates a private unique directory.
|
|
const auto *directory = mkdtemp(directory_template.data());
|
|
ASSERT_NE(directory, nullptr);
|
|
const std::filesystem::path config_dir {directory};
|
|
if (client_conf) {
|
|
std::ofstream config {config_dir / "client.conf"};
|
|
config << *client_conf;
|
|
}
|
|
|
|
std::array<int, 2> sockets {};
|
|
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets.data()), 0);
|
|
const int stream_fd = sockets[0];
|
|
struct stat stream {};
|
|
ASSERT_EQ(fstat(stream_fd, &stream), 0);
|
|
|
|
auto display = std::make_shared<test_display_t>();
|
|
{
|
|
scoped_env_t config_env {"PIPEWIRE_CONFIG_DIR", config_dir.string()};
|
|
EXPECT_NE(display->connect(stream_fd), 0);
|
|
}
|
|
std::filesystem::remove_all(config_dir);
|
|
|
|
ASSERT_TRUE(reset_within(display, 5s)) << "the display teardown after a failed init() did not finish";
|
|
|
|
// PipeWire did not take the stream fd over, so the display must have closed it.
|
|
struct stat after {};
|
|
const bool leaked = fstat(stream_fd, &after) == 0 && after.st_dev == stream.st_dev && after.st_ino == stream.st_ino;
|
|
EXPECT_FALSE(leaked) << "the display left open the stream fd that PipeWire did not take over";
|
|
if (leaked) {
|
|
close(stream_fd);
|
|
}
|
|
close(sockets[1]);
|
|
}
|
|
|
|
/**
|
|
* @brief Initialize PipeWire as the capture backends do before they create a display.
|
|
*/
|
|
class PipeWireDisplayTest: public BaseTest {
|
|
protected:
|
|
void SetUp() override {
|
|
BaseTest::SetUp();
|
|
ASSERT_TRUE(pipewire::pipewire_display_t::init_pipewire_and_check_hwdevice_type(platf::mem_type_e::system));
|
|
}
|
|
};
|
|
} // namespace
|
|
|
|
TEST_F(PipeWireDisplayTest, DisplayDestroyedBeforeInitKeepsStandardInput) {
|
|
// Make sure fd 0 is open, so that closing it is visible even when the runner closed standard input.
|
|
int opened_stdin = -1;
|
|
if (!is_open(STDIN_FILENO)) {
|
|
opened_stdin = open("/dev/null", O_RDONLY); // NOSONAR(cpp:S1874): `_sopen_s` not available
|
|
ASSERT_EQ(opened_stdin, STDIN_FILENO);
|
|
}
|
|
const int saved_stdin = fcntl(STDIN_FILENO, F_DUPFD_CLOEXEC, 3);
|
|
ASSERT_GE(saved_stdin, 0);
|
|
struct stat before {};
|
|
ASSERT_EQ(fstat(STDIN_FILENO, &before), 0);
|
|
|
|
// configure_stream() failed, so init() never ran: the display owns no descriptor.
|
|
auto display = std::make_shared<test_display_t>();
|
|
display.reset();
|
|
|
|
struct stat after {};
|
|
const bool stdin_open = fstat(STDIN_FILENO, &after) == 0;
|
|
EXPECT_TRUE(stdin_open) << "the display closed fd 0";
|
|
if (stdin_open) {
|
|
EXPECT_EQ(after.st_dev, before.st_dev);
|
|
EXPECT_EQ(after.st_ino, before.st_ino);
|
|
}
|
|
|
|
// Leave fd 0 as the test found it, also after a failure.
|
|
dup2(saved_stdin, STDIN_FILENO);
|
|
close(saved_stdin);
|
|
if (opened_stdin >= 0) {
|
|
close(opened_stdin);
|
|
}
|
|
}
|
|
|
|
TEST_F(PipeWireDisplayTest, TeardownDoesNotCloseFdThatPipeWireClosed) {
|
|
if (!can_create_pipewire_context()) {
|
|
GTEST_SKIP() << "PipeWire cannot create a client context; client.conf is probably not installed";
|
|
}
|
|
|
|
// Opened first, so that it cannot take the number of the stream fd later.
|
|
const int placeholder = open("/dev/null", O_RDONLY | O_CLOEXEC);
|
|
ASSERT_GE(placeholder, 0);
|
|
std::array<int, 2> sockets {};
|
|
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets.data()), 0);
|
|
const int stream_fd = sockets[0];
|
|
|
|
auto display = std::make_shared<test_display_t>();
|
|
ASSERT_EQ(display->connect(stream_fd), 0);
|
|
|
|
// The remote end goes away. PipeWire then closes the stream fd on its own thread.
|
|
close(sockets[1]);
|
|
const auto deadline = std::chrono::steady_clock::now() + 5s;
|
|
while (is_open(stream_fd) && std::chrono::steady_clock::now() < deadline) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
ASSERT_FALSE(is_open(stream_fd)) << "PipeWire did not close the stream fd after the remote end went away";
|
|
|
|
// Another part of the process opens a file and gets the free number.
|
|
const int reused = fcntl(placeholder, F_DUPFD_CLOEXEC, stream_fd);
|
|
close(placeholder);
|
|
ASSERT_EQ(reused, stream_fd);
|
|
|
|
display.reset();
|
|
|
|
EXPECT_TRUE(is_open(reused)) << "the display closed a descriptor that it no longer owned";
|
|
close(reused);
|
|
}
|
|
|
|
TEST_F(PipeWireDisplayTest, TeardownAfterFailedContextClosesStreamFd) {
|
|
// Without client.conf, pw_context_new() fails.
|
|
expect_failed_init_closes_stream_fd(std::nullopt);
|
|
}
|
|
|
|
TEST_F(PipeWireDisplayTest, TeardownAfterFailedConnectionClosesStreamFd) {
|
|
// With a client.conf that loads no modules, PipeWire creates the context, but without the native protocol
|
|
// pw_context_connect_fd() fails and does not take the stream fd over.
|
|
expect_failed_init_closes_stream_fd("context.properties = { }\ncontext.modules = [ ]\n");
|
|
}
|
|
#endif
|