diff --git a/src/platform/linux/pipewire.cpp b/src/platform/linux/pipewire.cpp index 69632cb245d..4430c24197a 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -83,6 +83,7 @@ namespace pipewire { std::atomic color_primaries {0}; ///< PipeWire color-primaries metadata for the stream. std::atomic transfer_function {0}; ///< PipeWire transfer-function metadata for the stream. std::atomic stream_dead {false}; ///< Whether the PipeWire stream has been destroyed. + std::atomic 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. @@ -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; @@ -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) @@ -793,6 +804,11 @@ namespace pipewire { auto *d = static_cast(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) { { @@ -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(user_data); struct pw_buffer *b = nullptr; diff --git a/src/platform/linux/portalgrab.cpp b/src/platform/linux/portalgrab.cpp index 0e76f4c3bf2..2b0edac5957 100644 --- a/src/platform/linux/portalgrab.cpp +++ b/src/platform/linux/portalgrab.cpp @@ -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 @@ -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); @@ -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 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 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 { diff --git a/tests/unit/platform/linux/test_pipewire.cpp b/tests/unit/platform/linux/test_pipewire.cpp index e4d0e537177..1f121276686 100644 --- a/tests/unit/platform/linux/test_pipewire.cpp +++ b/tests/unit/platform/linux/test_pipewire.cpp @@ -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 @@ -18,6 +18,7 @@ #include #include #include + #include // lib includes #include @@ -31,6 +32,12 @@ using namespace std::literals; + #ifdef SUNSHINE_BUILD_PORTAL +namespace portal { + bool check_stream_dead_for_testing(std::shared_ptr shared_state, bool &negotiate_maxframerate, platf::capture_e &out_status); +} // namespace portal + #endif + namespace { /** * @brief PipeWire display without a capture backend behind it. @@ -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()); + int connect(int fd, std::shared_ptr state = std::make_shared()) { + return pipewire.init(fd, PW_ID_ANY, SPA_ID_INVALID, std::move(state)); } }; @@ -207,6 +215,100 @@ namespace { }; } // namespace +TEST(PipeWireStreamStateTest, RecordsStartupUntilTheStreamIsReplaced) { + pipewire::stream_data_t data {}; + data.shared = std::make_shared(); + 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(); + 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(); + 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 sockets {}; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets.data()), 0); + auto state = std::make_shared(); + state->has_streamed.store(true); + auto display = std::make_shared(); + 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;