/** * @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 #include #include #include #include #include #include #include #include #include #include // lib includes #include #include #include #include // 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()); } }; /** * @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 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 &display, std::chrono::milliseconds limit) { auto finished = std::make_shared>(); 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 &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 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(); { 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(); 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 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(); 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