From 5649ffba720af573038f58f69876fbf63f2d36d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 11 Jul 2026 17:43:54 -0400 Subject: [PATCH 1/2] pipewire: harden the shared thread-loop context Fix thread-loop lock and iteration bugs in the shared PipeWire context, surfaced by concurrent audio + MIDI use and by video round-trip testing: - synchronize(): a foreign-thread sync no longer iterates the pw_loop itself (two threads driving one loop corrupt its per-source dispatch state and crash in the core-socket callback) nor holds pipewire's recursive lock across the wait. It issues pw_core_sync under a brief lock and waits on a private condition variable that the core done/error callbacks notify. - reconnect(): runs on the calling thread instead of via invoke_sync on the worker. tear_down() destroys the very loop the worker runs, so reconnecting from it self-destructs the loop and hangs. - invoke_sync(): queues a non-blocking pw_loop_invoke and waits on its own condition variable, bounded by sync_deadline. The blocking-invoke lock contract is unstable across PipeWire versions; the non-blocking path is correct on every release. - format negotiation advertises buffer size/stride as ranges so producers with padded rows (GPU-allocated strides) can link. Co-Authored-By: Olivier Gauthier Co-Authored-By: Claude Opus 4.8 --- .../backends/linux/pipewire/context.hpp | 211 +++++++++++++----- .../backends/linux/pipewire/format.hpp | 7 +- 2 files changed, 165 insertions(+), 53 deletions(-) diff --git a/include/libremidi/backends/linux/pipewire/context.hpp b/include/libremidi/backends/linux/pipewire/context.hpp index 51382683..f2ea5e22 100644 --- a/include/libremidi/backends/linux/pipewire/context.hpp +++ b/include/libremidi/backends/linux/pipewire/context.hpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -197,31 +198,37 @@ class context : public std::enable_shared_from_this using clk = std::chrono::steady_clock; const auto t_end = clk::now() + deadline; - if (m_cfg.kind == loop_kind::thread) + if (m_cfg.kind == loop_kind::thread && !is_in_loop_thread()) { - pw.thread_loop_lock(m_thread_loop); - struct unlock_guard - { - const api& pw; - pw_thread_loop* loop; - ~unlock_guard() { pw.thread_loop_unlock(loop); } - } guard{pw, m_thread_loop}; - return do_sync_locked(t_end); + return do_sync_thread(t_end); } else { - return do_sync_locked(t_end); + // Main-loop mode, or already on the thread-loop worker: we own the loop + // here, so drive iteration directly. Blocking from the worker would + // starve the loop, and re-locking pipewire's recursive mutex across a + // condition wait corrupts its accounting ('recurse > 0' in do_unlock). + return do_sync_iterate(t_end); } } bool reconnect() { - invoke_sync([this] { - tear_down(/*final=*/false); - m_state.store(connection_state::connecting, std::memory_order_release); - if (!build_connection()) - m_state.store(connection_state::broken, std::memory_order_release); - }); + // tear_down() stops and destroys the thread loop, so reconnect MUST run + // on a thread other than the worker — never via invoke_sync. Doing it on + // the loop thread self-destructs the loop: pthread_join(self) is a silent + // no-op and pw_thread_loop_destroy then corrupts the lock it runs under + // ('recurse > 0' failure) and the invoke never completes (hang). + if (is_in_loop_thread()) + return false; + + tear_down(/*final=*/false); + m_state.store(connection_state::connecting, std::memory_order_release); + if (!build_connection()) + { + m_state.store(connection_state::broken, std::memory_order_release); + return false; + } if (!synchronize()) return false; m_state.store(connection_state::connected, std::memory_order_release); @@ -285,54 +292,94 @@ class context : public std::enable_shared_from_this pw_loop_invoke(m_loop, trampoline, 0, nullptr, 0, /*block=*/false, held); } + // Runs fn on the loop thread and waits for the result. We queue a + // NON-blocking pw_loop_invoke rather than block=true because the blocking + // path's locking contract is not stable across PipeWire versions: some + // releases fire the thread-loop control hooks around the wait (caller must + // hold the lock exactly once), the 1.3.0-1.3.80 and 1.5.0-1.5.80 dev series + // don't (holding the lock deadlocks). A non-blocking invoke fires no hooks + // and needs no lock on any version; we wait on our own condition variable, + // bounded by sync_deadline so a teardown race returns instead of hanging. + // + // The payload is shared_ptr-owned by both sides. On timeout the caller sets + // `abandoned` under the payload mutex; the trampoline checks it under the + // same mutex, so fn (which captures the caller's frame by reference) either + // completes before the timeout or is skipped — never run against a dead frame. template auto invoke_sync(F&& fn) -> std::invoke_result_t&> { using FR = std::remove_cvref_t; using R = std::invoke_result_t; - if (!m_loop || is_in_loop_thread() || m_cfg.kind != loop_kind::thread) + if (!m_loop || !m_thread_loop || is_in_loop_thread() + || m_cfg.kind != loop_kind::thread) { return std::forward(fn)(); } - if constexpr (std::is_void_v) + + struct payload { - FR f{std::forward(fn)}; - constexpr auto trampoline = +[](spa_loop*, bool, std::uint32_t, const void*, size_t, - void* user_data) noexcept -> int { - try - { - (*static_cast(user_data))(); - } - catch (...) + FR fn; + std::mutex m; + std::condition_variable cv; + bool done{false}; + bool abandoned{false}; + std::conditional_t, char, std::optional> result{}; + + explicit payload(FR&& f) + : fn{std::move(f)} + { + } + }; + + auto sp = std::make_shared(FR{std::forward(fn)}); + constexpr auto trampoline = +[](spa_loop*, bool, std::uint32_t, const void*, + size_t, void* user_data) noexcept -> int { + auto* spp = static_cast*>(user_data); + auto& pl = **spp; + { + std::lock_guard lk{pl.m}; + if (!pl.abandoned) { + try + { + if constexpr (std::is_void_v) + pl.fn(); + else + pl.result.emplace(pl.fn()); + } + catch (...) + { + } + pl.done = true; } - return 0; - }; - pw_loop_invoke(m_loop, trampoline, 0, nullptr, 0, /*block=*/true, &f); + } + pl.cv.notify_all(); + delete spp; + return 0; + }; + + auto* held = new std::shared_ptr(sp); + if (pw_loop_invoke(m_loop, trampoline, 0, nullptr, 0, /*block=*/false, held) < 0) + { + // Loop rejected the item (teardown/OOM) and will never run it: safe inline. + delete held; + return sp->fn(); } - else + + std::unique_lock lk{sp->m}; + if (!sp->cv.wait_for(lk, m_cfg.sync_deadline, [&] { return sp->done; })) { - struct payload - { - FR fn; - std::optional result; - }; - payload p{std::forward(fn), std::nullopt}; - constexpr auto trampoline = +[](spa_loop*, bool, std::uint32_t, const void*, size_t, - void* user_data) noexcept -> int { - auto& pl = *static_cast(user_data); - try - { - pl.result.emplace(pl.fn()); - } - catch (...) - { - } - return 0; - }; - pw_loop_invoke(m_loop, trampoline, 0, nullptr, 0, /*block=*/true, &p); - return std::move(*p.result); + // Timed out: abandon the item; the trampoline skips fn if it ever runs. + sp->abandoned = true; + if constexpr (std::is_void_v) + return; + else if constexpr (std::is_default_constructible_v) + return R{}; + else + throw std::runtime_error("libremidi: pipewire invoke_sync timed out"); } + if constexpr (!std::is_void_v) + return std::move(*sp->result); } graph_snapshot snapshot() const @@ -432,6 +479,11 @@ class context : public std::enable_shared_from_this std::atomic m_sync_done{0}; std::atomic m_sync_error{0}; + // do_sync_thread() waits here; wake_sync_waiter() (worker thread) notifies. + // A private CV keeps application waiting off pipewire's recursive loop lock. + std::mutex m_sync_mtx; + std::condition_variable m_sync_cv; + // FIXME lock-free? mutable std::mutex m_graph_mtx; @@ -788,7 +840,10 @@ class context : public std::enable_shared_from_this { auto* self = static_cast(data); if (id == PW_ID_CORE && seq == self->m_sync_seq.load(std::memory_order_acquire)) + { self->m_sync_done.store(1, std::memory_order_release); + self->wake_sync_waiter(); + } } static void on_core_error( @@ -800,6 +855,19 @@ class context : public std::enable_shared_from_this { self->m_state.store(connection_state::broken, std::memory_order_release); } + self->wake_sync_waiter(); + } + + // Wakes do_sync_thread(), which waits on m_sync_cv. Runs from the worker + // thread during dispatch; the brief m_sync_mtx lock orders the flag store + // with the waiter without nesting an application wait inside pipewire's + // recursive loop lock. + void wake_sync_waiter() noexcept + { + { + std::lock_guard lk{m_sync_mtx}; + } + m_sync_cv.notify_all(); } void install_core_listener() noexcept @@ -822,8 +890,43 @@ class context : public std::enable_shared_from_this pw_core_add_listener(m_core, &m_core_listener, &events, this); } - // Lock must be held in thread-loop mode (drives pw_loop_iterate). - bool do_sync_locked(std::chrono::steady_clock::time_point deadline) + // Thread-loop sync from a foreign thread: the worker owns iteration, so take + // the loop lock only briefly to issue the sync, then wait on a private + // condition variable. We must neither iterate the loop ourselves (two + // threads iterating one pw_loop corrupt its per-source dispatch state) nor + // hold pipewire's recursive loop lock across the wait (pthread_cond_wait + // cannot fully release a recursively-held mutex, so it starves the worker + // and corrupts the lock: 'recurse > 0' in do_unlock). + bool do_sync_thread(std::chrono::steady_clock::time_point deadline) + { + auto& pw = load(); + m_sync_done.store(0, std::memory_order_release); + m_sync_error.store(0, std::memory_order_release); + + { + pw.thread_loop_lock(m_thread_loop); + int seq = pw_core_sync(m_core, PW_ID_CORE, 0); + m_sync_seq.store(seq, std::memory_order_release); + pw.thread_loop_unlock(m_thread_loop); + } + + { + std::unique_lock lk{m_sync_mtx}; + const bool ready = m_sync_cv.wait_until(lk, deadline, [this] { + return m_sync_done.load(std::memory_order_acquire) != 0 + || m_sync_error.load(std::memory_order_acquire) != 0; + }); + if (!ready) + { + m_state.store(connection_state::broken, std::memory_order_release); + return false; + } + } + return finalize_sync(); + } + + // Main-loop mode: no worker thread exists, so the caller owns iteration. + bool do_sync_iterate(std::chrono::steady_clock::time_point deadline) { m_sync_done.store(0, std::memory_order_release); m_sync_error.store(0, std::memory_order_release); @@ -851,7 +954,11 @@ class context : public std::enable_shared_from_this return false; } } + return finalize_sync(); + } + bool finalize_sync() noexcept + { if (m_sync_error.load(std::memory_order_acquire) != 0) { m_state.store(connection_state::broken, std::memory_order_release); diff --git a/include/libremidi/backends/linux/pipewire/format.hpp b/include/libremidi/backends/linux/pipewire/format.hpp index d83c7eb3..6d11780c 100644 --- a/include/libremidi/backends/linux/pipewire/format.hpp +++ b/include/libremidi/backends/linux/pipewire/format.hpp @@ -320,10 +320,15 @@ inline const spa_pod* format_negotiation::build_buffers_param( data_type = (1u << SPA_DATA_MemPtr); break; } + // size/stride are RANGES with our tight computation as the minimum: + // producers commonly deliver row-padded buffers (GPU-allocated strides), + // and a fixed Int here fails the param intersection against any producer + // whose size differs — killing the link instead of accepting padding. return reinterpret_cast(spa_pod_builder_add_object( &builder, SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers, SPA_PARAM_BUFFERS_buffers, SPA_POD_CHOICE_RANGE_Int(8, 2, 16), SPA_PARAM_BUFFERS_blocks, SPA_POD_Int(blocks), - SPA_PARAM_BUFFERS_size, SPA_POD_Int(size), SPA_PARAM_BUFFERS_stride, SPA_POD_Int(stride), + SPA_PARAM_BUFFERS_size, SPA_POD_CHOICE_RANGE_Int(size, size, INT32_MAX), + SPA_PARAM_BUFFERS_stride, SPA_POD_CHOICE_RANGE_Int(stride, stride, INT32_MAX), SPA_PARAM_BUFFERS_dataType, SPA_POD_CHOICE_FLAGS_Int(data_type))); } From 86d7e1d2fdd633388f4c8c9ad9fcdcc93876a67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 11 Jul 2026 17:44:04 -0400 Subject: [PATCH 2/2] tests: add pipewire shared-context regression tests Three standalone programs under tests/integration/ exercising the thread-loop context fixes: sync (create/synchronize/destroy churn and a sustained synchronize loop), reconnect (repeated reconnect from the calling thread), and subscriptions (subscribe/unsubscribe churn, filter teardown with live subscriptions, and synchronize from a loop-thread callback). Each skips with exit 0 when no PipeWire daemon is reachable and arms a watchdog so a lock-corruption regression fails loudly instead of hanging. Co-Authored-By: Olivier Gauthier Co-Authored-By: Claude Opus 4.8 --- cmake/libremidi.tests.cmake | 11 ++ .../pipewire_context_reconnect.cpp | 92 +++++++++++ .../pipewire_context_subscriptions.cpp | 148 ++++++++++++++++++ tests/integration/pipewire_context_sync.cpp | 99 ++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 tests/integration/pipewire_context_reconnect.cpp create mode 100644 tests/integration/pipewire_context_subscriptions.cpp create mode 100644 tests/integration/pipewire_context_sync.cpp diff --git a/cmake/libremidi.tests.cmake b/cmake/libremidi.tests.cmake index a4edc371..0fb45654 100644 --- a/cmake/libremidi.tests.cmake +++ b/cmake/libremidi.tests.cmake @@ -67,3 +67,14 @@ add_test(NAME protocols_test COMMAND protocols_test) add_test(NAME midi_stream_decoder_test COMMAND midi_stream_decoder_test) add_test(NAME midi_timing_test COMMAND midi_timing_test) add_test(NAME rawio_test COMMAND rawio_test) + +# PipeWire shared-context regression tests. Standalone programs (no Catch2): +# each skips with exit 0 when no daemon is reachable and arms a watchdog so a +# lock-corruption regression fails instead of hanging. +if(LIBREMIDI_HAS_PIPEWIRE) + foreach(_pwtest pipewire_context_sync pipewire_context_reconnect pipewire_context_subscriptions) + add_executable(${_pwtest}_test tests/integration/${_pwtest}.cpp) + target_link_libraries(${_pwtest}_test PRIVATE libremidi) + add_test(NAME ${_pwtest}_test COMMAND ${_pwtest}_test) + endforeach() +endif() diff --git a/tests/integration/pipewire_context_reconnect.cpp b/tests/integration/pipewire_context_reconnect.cpp new file mode 100644 index 00000000..eab5e88d --- /dev/null +++ b/tests/integration/pipewire_context_reconnect.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: BSL-1.0 +// +// Regression test: context::reconnect() must run on the calling thread, not the +// worker. tear_down() stops and destroys the thread loop, so reconnecting via +// invoke_sync() self-destructs the loop from within — a hang and lock corruption. +// +// Calls reconnect() repeatedly; each must return promptly without hanging. +// Requires a running PipeWire daemon; skips (exit 0) if none is reachable. + +#include +#include +#include + +#include +#include +#include +#include + +namespace lpw = libremidi::pipewire; + +static void arm_watchdog(int seconds) +{ + std::thread( + [seconds] + { + std::this_thread::sleep_for(std::chrono::seconds(seconds)); + std::fprintf(stderr, "FAIL: watchdog timeout (%ds) - likely deadlock\n", seconds); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); + }) + .detach(); +} + +int main() +{ + auto& pw = lpw::load(); + if (!pw.thread_available) + { + std::printf("libpipewire thread-loop not available; skipping\n"); + return 0; + } + + auto inst = lpw::shared_instance(); + if (!inst) + { + std::printf("pw_init failed; skipping\n"); + return 0; + } + + auto ctx = lpw::context::make(inst); + if (!ctx || !ctx->ok()) + { + std::printf("cannot connect to pipewire daemon; skipping\n"); + return 0; + } + + arm_watchdog(60); + + // Subscriptions must survive a reconnect, which tears the loop down and + // rebuilds it from the calling thread. + auto sub_state = ctx->on_state_changed([](lpw::connection_state) {}); + + const std::uint32_t gen0 = ctx->generation(); + for (int i = 0; i < 8; ++i) + { + if (!ctx->reconnect()) + { + std::fprintf(stderr, "FAIL: reconnect() returned false while daemon is up\n"); + return EXIT_FAILURE; + } + if (!ctx->ok()) + { + std::fprintf(stderr, "FAIL: context not connected after reconnect()\n"); + return EXIT_FAILURE; + } + // Prove the loop lock still works after the rebuild. + if (!ctx->synchronize()) + { + std::fprintf(stderr, "FAIL: synchronize() failed after reconnect()\n"); + return EXIT_FAILURE; + } + } + + if (ctx->generation() == gen0) + { + std::fprintf(stderr, "FAIL: generation did not advance across reconnects\n"); + return EXIT_FAILURE; + } + + std::printf("PASS: pipewire_context_reconnect (generation %u -> %u)\n", gen0, ctx->generation()); + return 0; +} diff --git a/tests/integration/pipewire_context_subscriptions.cpp b/tests/integration/pipewire_context_subscriptions.cpp new file mode 100644 index 00000000..91efe14a --- /dev/null +++ b/tests/integration/pipewire_context_subscriptions.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: BSL-1.0 +// +// Regression test for the recursive thread-loop lock. Two paths used to corrupt +// it: unsubscribe() invoked the loop without the proper lock state, and +// synchronize() could re-enter the recursive lock across its wait. +// +// Drops many subscriptions, tears down a MIDI-style filter with live +// subscriptions, and calls synchronize() from a subscriber callback (on the +// loop thread). Must complete without crashing or hanging. +// +// Requires a running PipeWire daemon; skips (exit 0) if none is reachable. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace lpw = libremidi::pipewire; + +static void arm_watchdog(int seconds) +{ + std::thread( + [seconds] + { + std::this_thread::sleep_for(std::chrono::seconds(seconds)); + std::fprintf(stderr, "FAIL: watchdog timeout (%ds) - likely deadlock\n", seconds); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); + }) + .detach(); +} + +int main() +{ + auto& pw = lpw::load(); + if (!pw.thread_available || !pw.filter_available) + { + std::printf("libpipewire thread-loop/filter not available; skipping\n"); + return 0; + } + + auto inst = lpw::shared_instance(); + if (!inst) + { + std::printf("pw_init failed; skipping\n"); + return 0; + } + + auto ctx = lpw::context::make(inst); + if (!ctx || !ctx->ok()) + { + std::printf("cannot connect to pipewire daemon; skipping\n"); + return 0; + } + + arm_watchdog(60); + + // 1. Subscribe/unsubscribe churn: each subscription reset runs unsubscribe() + // through invoke_sync() on the loop thread. + for (int i = 0; i < 50; ++i) + { + auto s1 = ctx->on_port_added([](const lpw::port_info&) {}); + auto s2 = ctx->on_port_removed([](std::uint32_t) {}); + auto s3 = ctx->on_node_added([](const lpw::node_info&) {}); + auto s4 = ctx->on_node_removed([](std::uint32_t) {}); + } + + // 2. A MIDI-style filter with a real port, torn down while port/node + // subscriptions are live. filter::stop() does disconnect + synchronize + + // destroy under the loop lock. + { + auto sub_add = ctx->on_port_added([](const lpw::port_info&) {}); + auto sub_rm = ctx->on_port_removed([](std::uint32_t) {}); + + static constexpr pw_filter_events events = { + .version = PW_VERSION_FILTER_EVENTS, + .destroy = nullptr, + .state_changed = nullptr, + .io_changed = nullptr, + .param_changed = nullptr, + .add_buffer = nullptr, + .remove_buffer = nullptr, + .process = nullptr, + .drained = nullptr, +#if PW_VERSION_FILTER_EVENTS >= 1 + .command = nullptr, +#endif + }; + lpw::filter_config cfg; + cfg.name = "libremidi-regression"; + cfg.media_type = "Midi"; + cfg.media_category = "Filter"; + cfg.media_role = "DSP"; + cfg.format_dsp = "8 bit raw midi"; + cfg.always_process = true; + cfg.pause_on_idle = false; + cfg.suspend_on_idle = false; + cfg.rt_process = true; + + auto flt = std::make_unique(ctx, cfg, events, nullptr); + if (flt->ok()) + { + flt->start(); + auto port = flt->add_port(SPA_DIRECTION_INPUT, "in", "8 bit raw midi"); + if (!port.valid()) + { + std::fprintf(stderr, "FAIL: could not add filter port\n"); + return EXIT_FAILURE; + } + flt->synchronize_node(); + } + flt.reset(); // filter::stop() + } + + // 3. synchronize() from a subscriber callback (on the loop thread): the + // is_in_loop_thread() guard must prevent it waiting on itself. Triggered + // via reconnect(), whose registry re-walk dispatches node_added on the + // worker thread. + { + std::atomic loop_thread_syncs{0}; + auto sub = ctx->on_node_added( + [&](const lpw::node_info&) + { + if (loop_thread_syncs.fetch_add(1) == 0) + (void)ctx->synchronize(); + }); + + if (!ctx->reconnect() || !ctx->ok()) + { + std::fprintf(stderr, "FAIL: reconnect() failed\n"); + return EXIT_FAILURE; + } + for (int i = 0; i < 5; ++i) + (void)ctx->synchronize(); + std::printf("synchronize() from loop thread fired %d time(s)\n", loop_thread_syncs.load()); + } + + std::printf("PASS: pipewire_context_subscriptions\n"); + return 0; +} diff --git a/tests/integration/pipewire_context_sync.cpp b/tests/integration/pipewire_context_sync.cpp new file mode 100644 index 00000000..c8c74f87 --- /dev/null +++ b/tests/integration/pipewire_context_sync.cpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BSL-1.0 +// +// Regression test: synchronize() from a foreign thread must not iterate the +// pw_loop concurrently with the thread-loop worker. Two threads iterating one +// loop corrupt its dispatch bookkeeping and crash in the core-socket callback. +// +// Exercises many create/synchronize/destroy cycles and a tight synchronize() +// loop. Requires a running PipeWire daemon; skips (exit 0) if none is reachable. + +#include +#include +#include + +#include +#include +#include +#include + +namespace lpw = libremidi::pipewire; + +// A deadlock must fail the test rather than hang a CI run. +static void arm_watchdog(int seconds) +{ + std::thread( + [seconds] + { + std::this_thread::sleep_for(std::chrono::seconds(seconds)); + std::fprintf(stderr, "FAIL: watchdog timeout (%ds) - likely deadlock\n", seconds); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); + }) + .detach(); +} + +int main() +{ + auto& pw = lpw::load(); + if (!pw.thread_available) + { + std::printf("libpipewire thread-loop not available; skipping\n"); + return 0; + } + + auto inst = lpw::shared_instance(); + if (!inst) + { + std::printf("pw_init failed; skipping\n"); + return 0; + } + + arm_watchdog(60); + + // Pass 1: repeated create/synchronize/destroy. The destructor stops the + // worker and disconnects the core — the teardown window that used to crash. + for (int i = 0; i < 20; ++i) + { + auto ctx = lpw::context::make(inst); + if (!ctx || !ctx->ok()) + { + std::printf("cannot connect to pipewire daemon; skipping\n"); + return 0; + } + + for (int k = 0; k < 20; ++k) + { + if (!ctx->synchronize()) + { + std::fprintf(stderr, "FAIL: synchronize() failed while connected\n"); + return EXIT_FAILURE; + } + } + } + + // Pass 2: sustained synchronize() round-trips on a single long-lived context. + { + auto ctx = lpw::context::make(inst); + if (!ctx || !ctx->ok()) + { + std::printf("cannot connect to pipewire daemon; skipping\n"); + return 0; + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + long count = 0; + while (std::chrono::steady_clock::now() < deadline) + { + if (!ctx->synchronize()) + { + std::fprintf(stderr, "FAIL: synchronize() failed while connected\n"); + return EXIT_FAILURE; + } + ++count; + } + std::printf("completed %ld synchronize() round-trips\n", count); + } + + std::printf("PASS: pipewire_context_sync\n"); + return 0; +}