Repository navigation
fix(rtc): keep an AudioMixer input alive after a read timeout #862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
17f66bb
8642fa8
2fe5a4e
09b6002
e57d4fd
5074667
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,9 @@ | |
|
|
||
| _Stream = AsyncIterator[AudioFrame] | ||
|
|
||
| # consecutive timeouts after which a stream is dropped once end_input() was called | ||
| _MAX_STALLED_ROUNDS = 10 | ||
|
|
||
|
|
||
| @dataclass | ||
| class _Contribution: | ||
|
|
@@ -52,6 +55,10 @@ def __init__( | |
| """ | ||
| self._streams: set[_Stream] = set() | ||
| self._buffers: dict[_Stream, np.ndarray] = {} | ||
| # A read that timed out is kept for the next round: cancelling it would close an | ||
| # async generator stream for good. | ||
| self._pending_reads: dict[_Stream, asyncio.Future[AudioFrame]] = {} | ||
| self._stalled_rounds: dict[_Stream, int] = {} | ||
| self._sample_rate: int = sample_rate | ||
| self._num_channels: int = num_channels | ||
| self._chunk_size: int = blocksize if blocksize > 0 else int(sample_rate // 10) | ||
|
|
@@ -90,6 +97,10 @@ def remove_stream(self, stream: AsyncIterator[AudioFrame]) -> None: | |
| """ | ||
| self._streams.discard(stream) | ||
| self._buffers.pop(stream, None) | ||
| self._stalled_rounds.pop(stream, None) | ||
| pending = self._pending_reads.pop(stream, None) | ||
| if pending is not None: | ||
| _discard_read(pending) | ||
|
|
||
| def __aiter__(self) -> "AudioMixer": | ||
| return self | ||
|
|
@@ -105,11 +116,19 @@ async def aclose(self) -> None: | |
| Immediately stop mixing and close the mixer. | ||
|
|
||
| This cancels the mixing task, and any unconsumed output in the queue may be dropped. | ||
| Pending stream reads are cancelled and awaited, so a stream must let | ||
| asyncio.CancelledError propagate once its cleanup is done. | ||
| """ | ||
| self._ending = True | ||
| self._mixer_task.cancel() | ||
| with contextlib.suppress(asyncio.CancelledError): | ||
| await self._mixer_task | ||
| pending = list(self._pending_reads.values()) | ||
| self._pending_reads.clear() | ||
| for read in pending: | ||
| _discard_read(read) | ||
| # let the streams finish their cleanup before the mixer counts as closed | ||
| await asyncio.gather(*pending, return_exceptions=True) | ||
|
|
||
| def end_input(self) -> None: | ||
| """ | ||
|
|
@@ -173,17 +192,34 @@ async def _get_contribution( | |
| ) -> _Contribution: | ||
| had_data = buf.shape[0] > 0 | ||
| exhausted = False | ||
| while buf.shape[0] < self._chunk_size and not exhausted: | ||
| try: | ||
| frame = await asyncio.wait_for( | ||
| stream.__anext__(), timeout=self._stream_timeout_ms / 1000 | ||
| ) | ||
| except asyncio.TimeoutError: | ||
| # a round scheduled before remove_stream() must not start a new read on that stream | ||
| while buf.shape[0] < self._chunk_size and not exhausted and stream in self._streams: | ||
| # The read stays in _pending_reads until it completes, so remove_stream() | ||
| # and aclose() can always cancel it. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how about end_input(): ? when end_input() is called, _ending is set to true, but it doesn't cancel anything, termination of that drain depends entirely on the _mixer loop's exit condition, and in that case, end_input() no longer terminates on a permanently stalled generator ? I wonder if we should consider bounding how long a stream may stall before it's dropped for real ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, thanks. That's a regression from this PR: on 5074667 bounds it: after Happy to make the bound a constructor argument instead, or apply it outside |
||
| read = self._pending_reads.get(stream) | ||
| if read is None: | ||
| read = asyncio.ensure_future(stream.__anext__()) | ||
| self._pending_reads[stream] = read | ||
| done, _ = await asyncio.wait({read}, timeout=self._stream_timeout_ms / 1000) | ||
| if not done: | ||
| logger.warning(f"AudioMixer: stream {stream} timeout, ignoring") | ||
| stalled = self._stalled_rounds.get(stream, 0) + 1 | ||
| self._stalled_rounds[stream] = stalled | ||
| # after end_input() a stream that keeps stalling is dropped, so the drain ends | ||
| exhausted = self._ending and stalled >= _MAX_STALLED_ROUNDS | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Slow inputs lose remaining audio After Learn more
Example: With the default 100 ms timeout, a generator yielding one frame, sleeping 1.2 seconds, and then yielding another loses its second frame after Recommended fix: Keep waiting for pending reads during normal Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the intended bound, added at the reviewer's request (thread above) and approved: after |
||
| break | ||
| self._stalled_rounds.pop(stream, None) | ||
| if self._pending_reads.get(stream) is read: | ||
| del self._pending_reads[stream] | ||
| try: | ||
| frame = read.result() | ||
| except StopAsyncIteration: | ||
| exhausted = True | ||
| break | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| except asyncio.TimeoutError: | ||
| # raised by the stream itself: handled like the mixer's own timeout, as before | ||
| logger.warning(f"AudioMixer: stream {stream} timeout, ignoring") | ||
| break | ||
| new_data = np.frombuffer(frame.data.tobytes(), dtype=np.int16).reshape( | ||
| -1, self._num_channels | ||
| ) | ||
|
|
@@ -198,3 +234,14 @@ async def _get_contribution( | |
| np.empty((0, self._num_channels), dtype=np.int16), | ||
| ) | ||
| return _Contribution(stream, contrib, buf, had_data, exhausted) | ||
|
|
||
|
|
||
| def _discard_read(read: "asyncio.Future[AudioFrame]") -> None: | ||
| """Cancel a read that is no longer wanted and consume its outcome whenever it ends.""" | ||
| read.cancel() | ||
| read.add_done_callback(_consume_outcome) | ||
|
|
||
|
|
||
| def _consume_outcome(read: "asyncio.Future[AudioFrame]") -> None: | ||
| if not read.cancelled(): | ||
| read.exception() # mark a failed read as handled | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Mixer shutdown hangs on uncancellable input
When a pending stream read ignores cancellation,
aclose()waits for it indefinitely.OutputPlayer.aclose()then cannot finish closing the player.Learn more
A timed-out input read remains in
_pending_readsuntil it completes.aclose()cancels these reads, but cancellation is cooperative: an async iterator can catchCancelledErrorand continue waiting. Awaiting all reads without a deadline then blocks shutdown indefinitely. OutputPlayer.aclose awaits mixer shutdown before clearing its mixer and closing the output device.Example: A stream repeatedly catches
CancelledErrorwhile waiting on an unset event. After its read times out,await mixer.aclose()never returns; closing anOutputPlayerwith that stream also leaves its output device open.Recommended fix: Bound the wait for cancelled reads in
AudioMixer.aclose()usingasyncio.wait(..., timeout=...)or an equivalent non-cancelling timeout. If cleanup outlives shutdown, retain and consume those tasks until they complete.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is the same as on
main, not introduced here: onmain,wait_forcancels the read inside the mixer task and waits for it to finish, andaclose()awaits that task, so a stream that keeps catchingCancelledErrorhangsaclose()there too (checked with a script againstmain'saudio_mixer.py:aclose()had not returned after 10 s). The bounded wait you suggest was tried in 2fe5a4e and reverted in 09b6002: it cuts cleanup longer thanstream_timeout_msshort and leaves reads outliving the mixer (your two comments on that commit). Keepingmain's shutdown semantics here and leaving that trade-off to the maintainers; it is listed under Scope in the description.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documented the contract in e57d4fd instead:
aclose()cancels and awaits pending reads, so a stream must letCancelledErrorpropagate once its cleanup is done. That is the contract asyncio itself states ("it should generally be propagated when clean-up is complete";TaskGroupandasyncio.timeout()"might misbehave if a coroutine swallows asyncio.CancelledError", https://docs.python.org/3/library/asyncio-task.html#task-cancellation), andTaskGrouplikewise waits for cancelled children without a deadline. A bounded wait was tried in 2fe5a4e and reverted (it cut legitimate cleanup short and left reads outliving the mixer), andmainhangs on such a stream as well.