Skip to content

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

Open
knz wants to merge 1 commit intoRustAudio:masterfrom
knz:fix/pulseaudio-stream-leak-1188
Open

fix(pulseaudio): release server-side stream on Stream::drop#1189
knz wants to merge 1 commit 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.

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.
@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.

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.

Each pulseaudio playback leaks a PA stream

1 participant