Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions livekit-rtc/livekit/rtc/audio_mixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

@devin-ai-integration devin-ai-integration Bot Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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_reads until it completes. aclose() cancels these reads, but cancellation is cooperative: an async iterator can catch CancelledError and 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 CancelledError while waiting on an unset event. After its read times out, await mixer.aclose() never returns; closing an OutputPlayer with that stream also leaves its output device open.

Recommended fix: Bound the wait for cancelled reads in AudioMixer.aclose() using asyncio.wait(..., timeout=...) or an equivalent non-cancelling timeout. If cleanup outlives shutdown, retain and consume those tasks until they complete.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

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: on main, wait_for cancels the read inside the mixer task and waits for it to finish, and aclose() awaits that task, so a stream that keeps catching CancelledError hangs aclose() there too (checked with a script against main's audio_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 than stream_timeout_ms short and leaves reads outliving the mixer (your two comments on that commit). Keeping main's shutdown semantics here and leaving that trade-off to the maintainers; it is listed under Scope in the description.

Copy link
Copy Markdown
Contributor Author

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 let CancelledError propagate once its cleanup is done. That is the contract asyncio itself states ("it should generally be propagated when clean-up is complete"; TaskGroup and asyncio.timeout() "might misbehave if a coroutine swallows asyncio.CancelledError", https://docs.python.org/3/library/asyncio-task.html#task-cancellation), and TaskGroup likewise 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), and main hangs on such a stream as well.


def end_input(self) -> None:
"""
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. That's a regression from this PR: on main the first timeout closed the stream, so the drain ended; here a stream that never yields again kept the drain alive forever.

5074667 bounds it: after end_input(), a stream that times out 10 rounds in a row (about 1 s at the default 100 ms) is dropped for real and its pending read is cancelled. A single late frame is still kept. Before end_input() streams aren't dropped, since a TTS stream can legitimately go quiet between utterances. Test: test_mixer_drops_a_stalled_stream_after_end_input (passes on main, fails on the previous commit, passes now).

Happy to make the bound a constructor argument instead, or apply it outside end_input() as well, if you'd prefer either.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Slow inputs lose remaining audio

After end_input(), a read delayed beyond ten timeouts makes _get_contribution remove its stream. The pending frame and every later frame disappear instead of draining.

Learn more

end_input() prevents new streams but otherwise drains existing streams until they finish. The mixer reuses a pending read across timeout rounds, but ten timeouts now mark its stream exhausted even when that read is still running. The removal cancels the read, so a delayed but valid frame and all later frames are lost.

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 end_input(). The mixer stops roughly one second into the sleep, before that frame arrives.

Recommended fix: Keep waiting for pending reads during normal end_input() drainage, or introduce an explicit, configurable forced-drain deadline separate from the existing per-round timeout. Do not treat ten temporary timeouts as proof of stream exhaustion.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 end_input() a stream must not stall for more than 10 consecutive timeouts, so the drain is guaranteed to end. It is looser than main, where the first timeout during the drain closed the stream and dropped the rest of its audio; a single late frame is still kept (test_mixer_keeps_async_generator_after_a_timeout).

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
Comment thread
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
)
Expand All @@ -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
137 changes: 136 additions & 1 deletion tests/rtc/test_mixer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# type: ignore

import asyncio

import numpy as np
import pytest

from livekit.rtc import AudioMixer
from livekit.rtc import AudioFrame, AudioMixer
from livekit.rtc.utils import sine_wave_generator

SAMPLE_RATE = 48000
Expand Down Expand Up @@ -56,3 +58,136 @@ async def test_mixer_two_sine_waves():
# Assert that the peaks include 440Hz and 880Hz (with a tolerance of ±5 Hz)
assert any(np.isclose(peak_freqs, 440, atol=5)), f"Expected 440 Hz in peaks, got: {peak_freqs}"
assert any(np.isclose(peak_freqs, 880, atol=5)), f"Expected 880 Hz in peaks, got: {peak_freqs}"


@pytest.mark.asyncio
async def test_mixer_keeps_async_generator_after_a_timeout():
"""
A stream that misses the timeout once must not be closed: cancelling the
pending read of an async generator ends it, which dropped the rest of its audio.
"""
frames, block = 10, 480

async def late_once():
for i in range(frames):
if i == 3:
await asyncio.sleep(0.25)
yield AudioFrame(np.full(block, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, block)

mixer = AudioMixer(
sample_rate=SAMPLE_RATE, num_channels=1, blocksize=block, stream_timeout_ms=100
)
mixer.add_stream(late_once())
mixer.end_input()

with_audio = 0
async for frame in mixer:
if np.frombuffer(frame.data.tobytes(), dtype=np.int16).any():
with_audio += 1
await mixer.aclose()

assert with_audio == frames


@pytest.mark.asyncio
async def test_mixer_drops_a_stalled_stream_after_end_input():
async def one_frame_then_stall():
yield AudioFrame(np.full(480, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, 480)
await asyncio.Event().wait()

mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
mixer.add_stream(one_frame_then_stall())
mixer.end_input()

async def drain():
return [frame async for frame in mixer]

draining = asyncio.ensure_future(drain())
done, _ = await asyncio.wait({draining}, timeout=1)
await mixer.aclose()
assert done
assert len(draining.result()) == 1


@pytest.mark.asyncio
@pytest.mark.parametrize("how", ["remove_stream", "aclose"])
async def test_mixer_cancels_a_pending_read_when_the_stream_goes_away(how):
closed = asyncio.Event()

async def never_yields():
try:
await asyncio.Event().wait()
yield # pragma: no cover
finally:
closed.set()

mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
stream = never_yields()
mixer.add_stream(stream)
await asyncio.sleep(0.12) # at least one round has timed out on the stream

if how == "remove_stream":
mixer.remove_stream(stream)
else:
await mixer.aclose()

await asyncio.wait_for(closed.wait(), timeout=1)
assert not mixer._pending_reads
await mixer.aclose()


@pytest.mark.asyncio
async def test_mixer_starts_no_read_on_a_removed_stream():
# a round can be scheduled before remove_stream() and run after it
async def never_yields():
await asyncio.Event().wait()
yield # pragma: no cover

mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=10)
stream = never_yields()
mixer.add_stream(stream)
mixer.remove_stream(stream)

await mixer._get_contribution(stream, np.empty((0, 1), dtype=np.int16))
assert not mixer._pending_reads
await mixer.aclose()


@pytest.mark.asyncio
async def test_mixer_keeps_buffered_audio_when_a_stream_raises_timeout_error():
async def partial_then_timeout():
yield AudioFrame(np.full(240, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, 240)
raise asyncio.TimeoutError

mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480)
mixer.add_stream(partial_then_timeout())
mixer.end_input()

with_audio = 0
async for frame in mixer:
if np.frombuffer(frame.data.tobytes(), dtype=np.int16).any():
with_audio += 1
await mixer.aclose()

assert with_audio == 1


@pytest.mark.asyncio
async def test_mixer_aclose_waits_for_a_cancelled_read_to_clean_up():
cleaned_up = False

async def slow_cleanup():
nonlocal cleaned_up
try:
await asyncio.Event().wait()
yield # pragma: no cover
finally:
await asyncio.sleep(0.1)
cleaned_up = True

mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
mixer.add_stream(slow_cleanup())
await asyncio.sleep(0.12)

await mixer.aclose()
assert cleaned_up
Loading