Skip to content

fix(pulseaudio): release server-side stream on Stream::drop#1189

Open
knz wants to merge 4 commits intoRustAudio:masterfrom
knz:fix/pulseaudio-stream-leak-1188
Open

fix(pulseaudio): release server-side stream on Stream::drop#1189
knz wants to merge 4 commits intoRustAudio:masterfrom
knz:fix/pulseaudio-stream-leak-1188

Conversation

@knz
Copy link
Copy Markdown

@knz knz commented May 3, 2026

The PulseAudio backend spawns a driver thread that calls pulseaudio::PlaybackStream::play_all, which awaits source EOF before draining and exiting. EOF for a PlaybackSource is signalled by poll_read returning 0, but the data-callback wrapper used by new_playback always reports the full buffer length to satisfy cpal's no-short-write contract. The driver thread therefore parks in source_eof for the lifetime of the process.

Because that thread holds a PlaybackStream clone (an Arc<InnerPlaybackStream>), the inner Arc count never drops to zero, InnerPlaybackStream::drop never fires, and the DeletePlaybackStream command it would otherwise queue is never sent. Each Stream created and dropped by the user thus leaks one server-side stream and one OS thread; long-running clients accumulate pactl list short sink-inputs entries until PulseAudio runs out of channels.

Queue a DeletePlaybackStream from Stream::drop so the reactor removes the stream state, drops the source's eof_tx channel, and wakes the driver thread with a cancellation error. now_or_never mirrors InnerPlaybackStream::drop and avoids blocking in Drop.

Fixes #1188.

@knz
Copy link
Copy Markdown
Author

knz commented May 3, 2026

NB: cleanup is asynchronous (the delete is queued, not awaited), so a Drop returning does not guarantee the server-side stream is gone yet. That mirrors InnerPlaybackStream::drop and avoids block_on in destructors. If you prefer strict synchronous cleanup, we can use block_on instead.

@roderickvd
Copy link
Copy Markdown
Member

Thank you for the analysis and follow-up fix! This seems the right approach.

One concern: the driver thread is never joined. drop returns before the thread has processed the cancellation, so DeletePlaybackStream may not reach the server if the process exits shortly after.

Fixing this requires storing the worker thread's JoinHandle and joining it with self-join detection to prevent deadlocks if a user's error callback drops the Stream. In #1187 I'm fixing a similar issue for PipeWire. Within that large PR the key lines are these:

if handle.thread().id() != std::thread::current().id() {
    let _ = handle.join();
}

Would you take that along with this PR?

@knz
Copy link
Copy Markdown
Author

knz commented May 6, 2026

Done; added a commit, see if you like it.

@knz
Copy link
Copy Markdown
Author

knz commented May 7, 2026

hm I sense there's something wrong. let me investigate further.

@knz
Copy link
Copy Markdown
Author

knz commented May 7, 2026

Okay this patch is actually fine!

(while testing it I ran into the unrelated issue #1190. I've been able to work around that issue in my own code, but it may be worth investigating further.)

@knz knz force-pushed the fix/pulseaudio-stream-leak-1188 branch from 2c8009c to 1149632 Compare May 7, 2026 14:47
@knz
Copy link
Copy Markdown
Author

knz commented May 7, 2026

There's a new failure in CI, but it appears unrelated to my change.

Copy link
Copy Markdown
Member

@roderickvd roderickvd left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. Please take care to keep the documentation style consistent with the rest and ensure what seems to be your LLM follows that.

Don't worry about the unrelated Rust nightly failure in the CI.

Comment thread src/host/pulseaudio/stream.rs
Comment thread src/host/pulseaudio/stream.rs Outdated
Comment thread src/host/pulseaudio/stream.rs Outdated
Comment thread CHANGELOG.md Outdated
@knz knz force-pushed the fix/pulseaudio-stream-leak-1188 branch from 1149632 to 5597cb7 Compare May 8, 2026 11:03
Copy link
Copy Markdown
Author

@knz knz left a comment

Choose a reason for hiding this comment

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

Thank you for your feedback!

Comment thread src/host/pulseaudio/stream.rs
Comment thread src/host/pulseaudio/stream.rs Outdated
Comment thread src/host/pulseaudio/stream.rs Outdated
Comment thread CHANGELOG.md Outdated
@knz
Copy link
Copy Markdown
Author

knz commented May 8, 2026

I'll handle the rebase; hold on

knz added 4 commits May 8, 2026 13:12
I believe this entry was dropped accidentally while reordering the
file in dccd6b4.
The PulseAudio backend spawns a driver thread that calls
`pulseaudio::PlaybackStream::play_all`, which awaits source EOF
before draining and exiting. EOF for a `PlaybackSource` is signalled
by `poll_read` returning `0`, but the data-callback wrapper used by
`new_playback` always reports the full buffer length to satisfy
cpal's no-short-write contract. The driver thread therefore parks in
`source_eof` for the lifetime of the process.

Because that thread holds a `PlaybackStream` clone (an
`Arc<InnerPlaybackStream>`), the inner Arc count never drops to zero,
`InnerPlaybackStream::drop` never fires, and the
`DeletePlaybackStream` command it would otherwise queue is never
sent. Each `Stream` created and dropped by the user thus leaks one
server-side stream and one OS thread; long-running clients
accumulate `pactl list short sink-inputs` entries until PulseAudio
runs out of channels.

Queue a `DeletePlaybackStream` from `Stream::drop` so the reactor
removes the stream state, drops the source's `eof_tx` channel, and
wakes the driver thread with a cancellation error. `now_or_never`
mirrors `InnerPlaybackStream::drop` and avoids blocking in `Drop`.

Fixes RustAudio#1188.
The previous fix queued `DeletePlaybackStream` from `Stream::drop` but
returned immediately. Because the delete is processed asynchronously
on the reactor and the play_all driver thread only exits *after* the
reactor wakes it, a process that exits shortly after dropping the
Stream can race the worker threads — the delete may never reach the
server, leaving exactly the leak this PR set out to fix.

Store the spawned threads' JoinHandles on `Stream` and join them in
`Drop` after signalling cancellation. Skip handles whose thread id
matches the current thread, since the user's error_callback runs on
those workers and may itself drop the Stream; joining ourselves would
deadlock. Mirrors the pattern roderickvd is introducing for PipeWire
in RustAudio#1187.
The play_all driver thread surfaces ClientError::Disconnected when the
reactor drops the source's eof_tx — which happens not only on real
server disconnect but also as a *direct consequence* of Stream::drop
queueing DeletePlaybackStream and the server processing it. The error
callback then fires "PulseAudio client disconnected" on every clean
teardown, despite the connection being healthy.

Check the cancel flag in the play_all spawn before emit_error,
mirroring the latency thread's existing pre-poll cancel check. After a
clean Stream::drop the cancel flag is already set when play_all
unblocks, so the spurious notification is silenced; real disconnects
(reactor exits, socket closed) leave the flag clear and still surface
through the user's error_callback.
@knz knz force-pushed the fix/pulseaudio-stream-leak-1188 branch from 5597cb7 to fb8131a Compare May 8, 2026 11:34
@knz
Copy link
Copy Markdown
Author

knz commented May 8, 2026

rebase completed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Each pulseaudio playback leaks a PA stream

2 participants