Skip to content

pipewire: cannot eject (drop) disconnected stream #105

@strohel

Description

@strohel

Bug Description

In our application we have code that detects when the audio callback isn't called, and try to recreate the stream if that happens. However, when trying to clean up the old stream, calling AudioStreamHandle::eject() on it hangs forever.

Ejecting a stream that is running normally works.

Steps To Reproduce

  1. Start a stream (capture or playback, doesn't matter) using the pipewire backend
  2. Trigger a condition where the audio callback is no longer called. Either:
    a. disconnect any connections from the stream node in pipewire patchbay tool like qpwgraph, helvum or pw-viz
    b. disconnect the (USB) sound card that the stream was running on (depending on pipewire/wireplumber config, it may get reconnected to a different stream though)
  3. Call AudioStreamHandle::eject() on it

Expected Behavior

The stream is ejected and cleaned up immediately.

Actual Behavior

The eject() call hangs, and the stream still exists in tools like pw-top, wpctl status, pipewire patchbay tools.

Once I trigger the audio callback being called again (i.e. by manualy connecting the stream's node to some device in a patchbay tool), it finally ejects and is cleaned up.

Environment

  • OS: Linux (Gentoo, Arch)
  • Audio driver (enable info logs): pipewire
  • Rust Version: 1.90.0
  • Project Version/Commit (see Cargo.lock): 7f73ef5 (recent main as of 2025-10-28)

Additional Context

The root cause is likely around

.process(move |stream, inner| {
log::debug!("Processing stream");
inner.handle_commands();

We only ever process the commands inside the the audio callback. But pipewire doesn't call that one when the node is disconnected.

Pipewire allows many different types of callbacks/communication patterns on the stream and in its MainLoop, which would likely be a better way to process commands.

This is also related to #77

Aside 1

I see that interflow uses pipewire in somewhat non-canonical way: it spawns a pipewire MainLoop per every stream (which then spawns another "data loop" for the audio callback). I think the intended way is that a single MainLoop is spawned (more or less globally) and then streams are added/removed from that loop. If this something interflow wants to transition to?

Aside 2

Was the API AudioStreamHandle::eject() returning the callback back to the caller inspired by something?

I imagine getting the callback back may be handy in some cases (though not ours), though it complicates the implementation slightly: sending some one-shot "please drop and destroy yourself" to the stream would be more straightforward. (callers would be still able to get data back from the callback by wrapping it in something like Arc<Mutex<Option<...>>>, though that isn't elegant)

Aside 3

In the previous backends that we've used (portaudio, cpal), dropping the stream handle resulted in stopping and disposing of the stream. In interflow, the stream keeps running in this case, without any way to stop it afterwards (short of panicking in the callback).

Is this intentional?

Possible Solution

For our use I've implemented a hacky work-around in tonarino#5. It works for us, but I don't think it's upstreamable as-is (for example eject() takes up to a second).

If you have an idea how you want to address this architecturally I can try implementing that.

CC @mbernat @jacksongoode.

Metadata

Metadata

Assignees

No one assigned

    Labels

    B-PipeWireIssues regarding the PipeWire backendP-LinuxIssues and PRs targetting LinuxbugSomething isn't working

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions