Skip to content

PipeWire backend: connect the filter only once its port exists (supersedes #249) - #250

Merged
jcelerier merged 3 commits into
masterfrom
pipewire-port-lifecycle
Sep 6, 2026
Merged

jcelerier merged 3 commits into
masterfrom
pipewire-port-lifecycle

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Builds on #249 by @ryanatseolaudio (his commit is kept as the first commit of this branch) and fixes the ordering that made the guard necessary.

Problem

The guard in #249 is correct as far as it goes, but two things remained:

  1. Debug builds still abort. The midi_in variants kept assert(this->flt); assert(this->port.valid()); above the new guard, so with asserts enabled the abort at midi_in.hpp:95 was unchanged.
  2. The root cause is an ordering bug, not a race. The backends connected the filter in the constructor and added the port in open_port(). The node carries node.always-process, so the daemon schedules it as soon as it is exported, ports or not (context.c makes an always-process node runnable on its own), and impl_node_process calls the user callback unconditionally. A midi_in/midi_out constructed and not opened immediately runs process() against a null token within milliseconds, and pw_filter_dequeue_buffer(nullptr) dereferences an invalid pointer (SPA_CONTAINER_OF on null). Same code path in PipeWire 0.3.48, 1.0.5 and 1.6.8.
  3. The close path was a use-after-free window, not just a null token. pw_filter_remove_port frees the port on the caller's thread with no data-loop synchronization while the filter stays connected, so a process() in flight can check port.valid() and then dequeue from freed memory. The guard only narrows that window.

Fix

  • create_filter() no longer connects. A new start_filter() step runs after create_local_port() (and after set_port_buffer() for outputs), connects the filter and waits for the node id as before. Ports and their params therefore exist before pw_filter_connect, the same order PipeWire's own examples use.
  • close_port() and the destructor go through release_filter(): filter::stop() disconnects, which unprepares the node on the data loop synchronously (pw_impl_node_destroydo_node_unprepare), then pw_filter_destroy frees the port itself. The next open_port() creates a fresh filter. pw_filter_remove_port is no longer called on a live filter.
  • The asserts above the guard are removed; the guard stays as defence in depth with an updated comment.

Verification

Header-only test program against PipeWire 1.6.8, run with asserts on and with NDEBUG:

scenario before #249 alone this PR
midi_in constructed, never opened, 3 s segfault / assert abort ok (NDEBUG) / assert abort ok
midi_out constructed, never opened, 3 s segfault ok ok
200 open_virtual_port/close_port cycles (in) crash ~1 run in 4 ok ok
200 open/close cycles (out) ok ok ok
100 construct/open/destroy cycles ok ok ok
6 immediate open/close cycles, 0 ms apart ok ok

The pipewire examples and tests/integration/pipewire_context_subscriptions.cpp still compile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EuaGAzPuXJNAUNkNFPDXro

ryanatseolaudio and others added 3 commits September 4, 2026 14:50
The filter can start before the local port exists (open sequence) or
run after the port was removed (close sequence). With no valid port
token there is no buffer to process; skip instead of dereferencing a
null token. With asserts compiled out (release build) the old code
segfaulted (pw.filter_dequeue_buffer on a null token).
Follow-up to the process() guard: fix the ordering that made it necessary
instead of only surviving it.

The backends connected the filter in the constructor and added the local
port in open_port(). The node carries node.always-process, so the daemon
schedules it as soon as it is exported, ports or not (context.c makes an
always-process node runnable on its own), and impl_node_process calls the
user callback unconditionally. A midi_in/midi_out that is constructed and
not opened immediately therefore runs process() against a null port token
within milliseconds, and pw_filter_dequeue_buffer(nullptr) dereferences an
invalid pointer. Reproduced on pipewire 1.6.8; the code path is the same
in 0.3.48 and 1.0.5.

close_port() had the mirror problem: pw_filter_remove_port frees the port
on the caller's thread with no data-loop synchronization while the filter
stays connected, so a process() in flight can race the free. The guard
only narrows that window.

- create_filter() no longer connects. A new start_filter() step runs after
  create_local_port() (and set_port_buffer() for outputs) and connects the
  filter, then waits for the node id as before.
- close_port() and the destructor go through release_filter(): filter::stop()
  disconnects, which unprepares the node on the data loop synchronously via
  pw_impl_node_destroy, and pw_filter_destroy frees the port itself. The
  next open_port() creates a fresh filter.
- Drop the asserts that sat above the new guard in the midi_in variants;
  they aborted debug builds before the guard could run.

Verified with a header-only test on pipewire 1.6.8: unopened objects idle
for seconds, 200 open/close cycles and 100 construct/open/destroy cycles
pass with asserts on and off; the previous code crashed in the idle case
every time and in the open/close case about one run in four.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuaGAzPuXJNAUNkNFPDXro
None of the existing pipewire tests touch the MIDI backends; they exercise
the shared context and a raw filter. Add a standalone integration test in
the same style (skips with exit 0 without a daemon, watchdog against hangs)
that keeps an unopened midi_in/midi_out alive for 500 ms, runs 100
open/close cycles on both, and 30 construct/open/destroy cycles, then
checks the shared context is still connected.

Against master before the process() guard it segfaults in the idle step;
against the guard alone with asserts enabled it aborts on the assert that
sat above the guard. Passes on this branch with and without NDEBUG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuaGAzPuXJNAUNkNFPDXro
@jcelerier

Copy link
Copy Markdown
Member Author

Added tests/integration/pipewire_midi_port_lifecycle_test, since none of the existing pipewire tests touch the MIDI backends. It idles an unopened midi_in/midi_out for 500 ms, runs 100 open/close cycles on both and 30 construct/open/destroy cycles, then checks the shared context is still connected.

Full suite on this branch: 17/17 pass (the five pipewire_context_* tests run for real against the local daemon, not skipped).

@jcelerier

Copy link
Copy Markdown
Member Author

Also verified on a second machine: Ubuntu 24.04.4 LTS, libpipewire 1.0.7 (the reporter's distro, one point release ahead of 1.0.5), gcc 13.3, running against the user's live daemon. LIBREMIDI_HAS_PIPEWIRE_UMP is not available there, so only the MIDI 1.0 pipewire backends are exercised.

  • Debug (asserts on): 17/17 tests pass; the five pipewire_context_* tests ran for real (not skipped); pipewire_midi_port_lifecycle_test passed 3/3.
  • Release: pipewire_midi_port_lifecycle_test passed 3/3.

@jcelerier

Copy link
Copy Markdown
Member Author

@ryanatseolaudio if you have time to check this PR? it should solve the issues across all cases.

@jcelerier
jcelerier enabled auto-merge September 5, 2026 17:26
@ryanatseolaudio

ryanatseolaudio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

@jcelerier Bug appears to be squashed, thanks!

@ryanatseolaudio ryanatseolaudio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgmt

@jcelerier

Copy link
Copy Markdown
Member Author

great thanks !

@jcelerier
jcelerier disabled auto-merge September 6, 2026 22:39
@jcelerier
jcelerier merged commit cac4d84 into master Sep 6, 2026
40 of 91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants