Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/platform/linux/pipewire.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ namespace pipewire {
std::atomic<int> color_primaries {0}; ///< PipeWire color-primaries metadata for the stream.
std::atomic<int> transfer_function {0}; ///< PipeWire transfer-function metadata for the stream.
std::atomic<bool> stream_dead {false}; ///< Whether the PipeWire stream has been destroyed.
std::atomic<bool> has_streamed {false}; ///< Whether this stream has ever reached PW_STREAM_STATE_STREAMING.
pw_stream_state previous_state; ///< Previous PipeWire stream state reported by callbacks.
pw_stream_state current_state; ///< Current PipeWire stream state reported by callbacks.
std::string err_msg; ///< Last PipeWire error message reported by the stream.
Expand Down Expand Up @@ -271,6 +272,7 @@ namespace pipewire {
node = stream_node;
object_serial = stream_object_serial;
stream_data.shared = std::move(shared_state);
stream_data.shared->has_streamed.store(false);

pw_thread_loop_lock(loop);
BOOST_LOG(debug) << "[pipewire] Setup PW context"sv;
Expand Down Expand Up @@ -781,6 +783,15 @@ namespace pipewire {
.error = on_core_error_cb,
};

public:
/**
* @brief Record stream transitions, retaining startup success after subsequent pauses or errors.
*
* @param user_data Stream data registered with the PipeWire listener.
* @param old Previous PipeWire stream state.
* @param state New PipeWire stream state.
* @param err_msg Error description, or nullptr for a normal transition.
*/
static void on_stream_state_changed(void *user_data, enum pw_stream_state old, enum pw_stream_state state, const char *err_msg) { // NOSONAR(cpp:S5008): PipeWire event callbacks require a void pointer for user data.
if (err_msg != nullptr) {
BOOST_LOG(info) << "[pipewire] PipeWire stream error '" << err_msg << "' on state: " << pw_stream_state_as_string(old)
Expand All @@ -793,6 +804,11 @@ namespace pipewire {
auto *d = static_cast<stream_data_t *>(user_data);

switch (state) {
case PW_STREAM_STATE_STREAMING:
if (d->shared) {
d->shared->has_streamed.store(true);
}
break;
case PW_STREAM_STATE_PAUSED:
if (d->shared && old == PW_STREAM_STATE_STREAMING) {
{
Expand Down Expand Up @@ -826,6 +842,7 @@ namespace pipewire {
}
}

private:
static void on_process(void *user_data) { // NOSONAR(cpp:S5008): PipeWire event callbacks require a void pointer for user data.
const auto d = static_cast<struct stream_data_t *>(user_data);
struct pw_buffer *b = nullptr;
Expand Down
44 changes: 40 additions & 4 deletions src/platform/linux/portalgrab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1095,10 +1095,10 @@ namespace portal {
}

/**
* @brief Check stream dead.
* @brief Handle portal closure or retry failed startup negotiation without maxFramerate.
*
* @param out_status Out status.
* @return True when the PipeWire stream can no longer produce frames.
* @param out_status Capture status selected when portal-specific handling is needed.
* @return True when portal-specific handling selected a capture status; false to use the default stream reset.
*/
bool check_stream_dead(platf::capture_e &out_status) override {
// If the pipewire stream stopped due to closed portal session stop the capture with an error
Expand All @@ -1109,7 +1109,7 @@ namespace portal {
return true; // Stop capture with error (due to out_status)
}
// Disable maxframerate negotiation if the stream died without having ever started (e.g. GNOME mutter does not support it)
if (shared_state->previous_state != PW_STREAM_STATE_STREAMING && negotiate_maxframerate.load()) {
if (!shared_state->has_streamed.load() && negotiate_maxframerate.load()) {
BOOST_LOG(warning) << "[portalgrab] Negotiation failed, will retry without maxFramerate"sv;
negotiate_maxframerate.store(false);
pipewire.set_negotiate_maxframerate(false);
Expand All @@ -1125,6 +1125,42 @@ namespace portal {
// Class variable to store runtime state of maxFramerate negotiation
static inline std::atomic negotiate_maxframerate {true}; ///< Whether portal negotiation should request the maximum frame rate.
};

#ifdef SUNSHINE_TESTS
/**
* @brief Exercise the portal's dead-stream handling without opening a real portal session.
*
* @param shared_state Stream state populated by PipeWire callbacks.
* @param negotiate_maxframerate Negotiation setting before and after handling the dead stream.
* @param out_status Capture status selected by the handler.
* @return True when portal-specific handling selected a capture status.
*/
bool check_stream_dead_for_testing(std::shared_ptr<pipewire::shared_state_t> shared_state, bool &negotiate_maxframerate, platf::capture_e &out_status) {
/**
* @brief Portal display with injected callback state and no D-Bus session.
*/
class test_portal_t: public portal_t {
public:
/**
* @brief Install the stream state to be examined by the portal handler.
* @param state Stream state populated by PipeWire callbacks.
*/
explicit test_portal_t(std::shared_ptr<pipewire::shared_state_t> state):
portal_t {} {
this->shared_state = std::move(state);
dbus.pipewire_fd = -1;
}
} display {std::move(shared_state)};

const bool saved = portal_t::negotiate_maxframerate.exchange(negotiate_maxframerate);
auto restore = util::fail_guard([saved]() {
portal_t::negotiate_maxframerate.store(saved);
});
const bool handled = display.check_stream_dead(out_status);
negotiate_maxframerate = portal_t::negotiate_maxframerate.load();
return handled;
}
#endif
} // namespace portal

namespace platf {
Expand Down
108 changes: 105 additions & 3 deletions tests/unit/platform/linux/test_pipewire.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @file tests/unit/platform/linux/test_pipewire.cpp
* @brief Test the teardown of the shared PipeWire capture display.
* @brief Test PipeWire stream lifecycle handling and capture display teardown.
*/
#if defined(SUNSHINE_BUILD_KWIN) || defined(SUNSHINE_BUILD_PORTAL)
// test includes
Expand All @@ -18,6 +18,7 @@
#include <optional>
#include <string>
#include <thread>
#include <utility>

// lib includes
#include <fcntl.h>
Expand All @@ -31,6 +32,12 @@

using namespace std::literals;

#ifdef SUNSHINE_BUILD_PORTAL
namespace portal {
bool check_stream_dead_for_testing(std::shared_ptr<pipewire::shared_state_t> shared_state, bool &negotiate_maxframerate, platf::capture_e &out_status);
} // namespace portal
#endif

namespace {
/**
* @brief PipeWire display without a capture backend behind it.
Expand All @@ -47,10 +54,11 @@ namespace {
* @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.
* @param state Callback state for the new connection.
* @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>());
int connect(int fd, std::shared_ptr<pipewire::shared_state_t> state = std::make_shared<pipewire::shared_state_t>()) {
return pipewire.init(fd, PW_ID_ANY, SPA_ID_INVALID, std::move(state));
}
};

Expand Down Expand Up @@ -207,6 +215,100 @@ namespace {
};
} // namespace

TEST(PipeWireStreamStateTest, RecordsStartupUntilTheStreamIsReplaced) {
pipewire::stream_data_t data {};
data.shared = std::make_shared<pipewire::shared_state_t>();
EXPECT_FALSE(data.shared->has_streamed.load());

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_CONNECTING, PW_STREAM_STATE_PAUSED, nullptr);
EXPECT_FALSE(data.shared->has_streamed.load());
EXPECT_FALSE(data.shared->stream_dead.load());

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_PAUSED, PW_STREAM_STATE_STREAMING, nullptr);
EXPECT_TRUE(data.shared->has_streamed.load());
EXPECT_FALSE(data.shared->stream_dead.load());

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_STREAMING, PW_STREAM_STATE_ERROR, "no more input formats");
EXPECT_TRUE(data.shared->has_streamed.load());
EXPECT_TRUE(data.shared->stream_dead.exchange(false));

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_ERROR, PW_STREAM_STATE_UNCONNECTED, nullptr);
EXPECT_TRUE(data.shared->has_streamed.load());
EXPECT_TRUE(data.shared->stream_dead.load());
}

TEST(PipeWireStreamStateTest, StreamingWithoutSharedStateIsIgnored) {
pipewire::stream_data_t data {};
pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_PAUSED, PW_STREAM_STATE_STREAMING, nullptr);
EXPECT_FALSE(data.shared);
}

#ifdef SUNSHINE_BUILD_PORTAL
TEST_F(PipeWireDisplayTest, PortalRetainsMaxFramerateAfterAnEstablishedStreamFails) {
for (const bool handle_pause_first : {false, true}) {
SCOPED_TRACE(handle_pause_first);
pipewire::stream_data_t data {};
data.shared = std::make_shared<pipewire::shared_state_t>();
bool negotiate_maxframerate = true;
auto status = platf::capture_e::ok;

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_PAUSED, PW_STREAM_STATE_STREAMING, nullptr);
pw_buffer buffer {};
data.current_buffer = &buffer;
data.frame_ready = true;
pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_STREAMING, PW_STREAM_STATE_PAUSED, nullptr);
EXPECT_FALSE(data.frame_ready);
EXPECT_EQ(data.current_buffer, nullptr);
ASSERT_TRUE(data.shared->stream_dead.load());

// The capture thread may handle the pause before or after PipeWire reports the later error.
if (handle_pause_first) {
EXPECT_TRUE(data.shared->stream_dead.exchange(false));
EXPECT_FALSE(portal::check_stream_dead_for_testing(data.shared, negotiate_maxframerate, status));
EXPECT_TRUE(negotiate_maxframerate);
EXPECT_EQ(status, platf::capture_e::ok);
}

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_PAUSED, PW_STREAM_STATE_ERROR, "no more input formats");
ASSERT_TRUE(data.shared->stream_dead.exchange(false));
EXPECT_FALSE(portal::check_stream_dead_for_testing(data.shared, negotiate_maxframerate, status));
EXPECT_TRUE(negotiate_maxframerate);
EXPECT_EQ(status, platf::capture_e::ok);
}
}

TEST_F(PipeWireDisplayTest, PortalRetriesWithoutMaxFramerateOnlyIfTheStreamNeverStarted) {
pipewire::stream_data_t data {};
data.shared = std::make_shared<pipewire::shared_state_t>();
bool negotiate_maxframerate = true;
auto status = platf::capture_e::ok;

pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_CONNECTING, PW_STREAM_STATE_PAUSED, nullptr);
pipewire::pipewire_t::on_stream_state_changed(&data, PW_STREAM_STATE_PAUSED, PW_STREAM_STATE_ERROR, "no more input formats");
ASSERT_TRUE(data.shared->stream_dead.exchange(false));
EXPECT_TRUE(portal::check_stream_dead_for_testing(data.shared, negotiate_maxframerate, status));
EXPECT_FALSE(negotiate_maxframerate);
EXPECT_EQ(status, platf::capture_e::reinit);

status = platf::capture_e::ok;
EXPECT_FALSE(portal::check_stream_dead_for_testing(data.shared, negotiate_maxframerate, status));
EXPECT_FALSE(negotiate_maxframerate);
EXPECT_EQ(status, platf::capture_e::ok);
}
#endif

TEST_F(PipeWireDisplayTest, NewConnectionResetsStartupHistory) {
std::array<int, 2> sockets {};
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets.data()), 0);
auto state = std::make_shared<pipewire::shared_state_t>();
state->has_streamed.store(true);
auto display = std::make_shared<test_display_t>();
display->connect(sockets[0], state);
EXPECT_FALSE(state->has_streamed.load());
display.reset();
close(sockets[1]);
}

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;
Expand Down
Loading