Repository navigation
fix(rdpsnd): stop blocking the real-time playback callback - #1803
Marc-André Moreau (mamoreau-devolutions) merged 4 commits into
Conversation
`RxBuffer::fill()` ran on the cpal real-time audio thread and called `Receiver::recv_timeout(Duration::from_millis(4000))` whenever it ran out of buffered PCM. Any pause in incoming Wave PDUs longer than one callback period — completely normal whenever nothing is currently playing remotely — blocked the audio device's callback thread for up to 4 seconds, starving its ring buffer and triggering a cpal "Buffer underrun/overrun occurred" error plus a "timed out waiting on channel" warning, repeating every ~4s for as long as playback stayed idle. Replace the `mpsc::Receiver<Vec<u8>>` between the producer (network/ decode) and the consumer (cpal callback) with a lock-free SPSC byte ring buffer (`ringbuf`). `RxBuffer::fill()` now calls `pop_slice`, which is non-blocking and returns immediately with whatever is available; any shortfall is filled with correct silence (0x80 for unsigned 8-bit PCM, 0x00 otherwise) instead of blocking or leaving stale bytes. Volume is still applied at pop time, right before playback, preserving the existing responsiveness of VolumePdu changes. This also drops the old `last`/`idx` partial-block bookkeeping in `RxBuffer` (the ring buffer handles partial reads natively), and moves Opus decoding off the `stream_handle` thread into its own thread spawned directly from `wave()`, pushing decoded PCM into the same ring buffer non-blockingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Occasional small drops (a few tens to ~100 bytes, roughly every 10-20s) are expected: the server's audio clock and the local playback device never run at exactly the same rate, and with no resampling in this path, that drift is absorbed by periodically dropping a sub-millisecond sliver of audio instead of letting the buffer grow unbounded. This is harmless and inaudible, but was logged at WARN every time, which is needlessly noisy. Only log at WARN when a drop is large enough to suggest an actual stall on the audio thread (e.g. the machine briefly starving the real-time callback); log everything smaller at trace instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
484c570 to
4bb537a
Compare
Marc-André Moreau (mamoreau-devolutions)
left a comment
There was a problem hiding this comment.
Requesting changes for the example and workspace-lint build failures, plus the feature-gating and public-dependency issues noted inline.
Note
LLM-assisted content (no human feedback).
- Update examples/cpal.rs to build/split a HeapRb instead of an mpsc channel, matching DecodeStream::new's HeapCons<u8> signature. - Use a checked usize::try_from conversion for the drop-size threshold instead of an 'as usize' cast, matching ring_buffer_capacity and satisfying clippy::as_conversions. - Mark the ringbuf dependency '# public' since HeapCons<u8> is part of DecodeStream::new's public signature. - Silent warning when compiling without `opus` feature
|
Addressed, thanks! |
|
Mariusz Białończyk (@manio) can you fix the failing CI checks |
There was a problem hiding this comment.
Thank you for the fix!
In addition to fixing the CI, before we merge, can you make sure you follow the repository conventions about PR/commits?
We are providing skills to help with that:
https://github.com/Devolutions/IronRDP/blob/master/.agents/skills/commit-and-pr-conventions/SKILL.mdhttps://github.com/Devolutions/IronRDP/blob/master/.agents/skills/commit-scope/SKILL.md
I think Claude is supposed to read them when necessary, I’m not sure why it did not happen here. If you see how we could improve our setup, let us know.
EDIT: Looks like you updated accordingly while I was reviewing, thank you! 🙂
| ironrdp-rdpeai = { path = "../ironrdp-rdpeai", version = "0.1", optional = true } # public | ||
| ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public | ||
| opus2 = { version = "0.4", optional = true, features = ["bundled"] } | ||
| ringbuf = "0.5" # public |
There was a problem hiding this comment.
ringbuf itself looks sufficiently lightweight and is a good fit for the real-time SPSC path. However, can we keep it implementation-private? Otherwise, a breaking change for them, is a breaking change for us as well.
There was a problem hiding this comment.
It was, but I did this after Marc-André Moreau (@mamoreau-devolutions) review
There was a problem hiding this comment.
ringbuf itself looks sufficiently lightweight and is a good fit for the real-time SPSC path. However, can we keep it implementation-private? Otherwise, a breaking change for them, is a breaking change for us as well.
Changed, please see if it is now OK
Hide ringbuf types behind PcmProducer so they are no longer exposed through the public DecodeStream API.
850f654
into
Devolutions:master
RxBuffer::fill()ran on the cpal real-time audio thread and calledReceiver::recv_timeout(Duration::from_millis(4000))whenever it ran out of buffered PCM. Any pause in incoming Wave PDUs longer than one callback period — completely normal whenever nothing is currently playing remotely — blocked the audio device's callback thread for upto 4 seconds, starving its ring buffer and triggering a cpal "Buffer underrun/overrun occurred" error plus a "timed out waiting on channel" warning, repeating every ~4s for as long as playback stayed idle.
Replace the
mpsc::Receiver<Vec<u8>>between the producer (network/decode) and the consumer (cpal callback) with a lock-free SPSC byte ring buffer (ringbuf).RxBuffer::fill()now callspop_slice, which is non-blocking and returns immediately with whatever is available; any shortfall is filled with correct silence (0x80 for unsigned 8-bit PCM, 0x00 otherwise) instead of blocking or leaving stale bytes. Volume is still applied at pop time, right before playback, preserving the existing responsiveness of VolumePdu changes.This also drops the old
last/idxpartial-block bookkeeping inRxBuffer(the ring buffer handles partial reads natively), and moves Opus decoding off thestream_handlethread into its own thread spawned directly fromwave(), pushing decoded PCM into the same ring buffer non-blockingly.Fixes for this kind of errors: