Skip to content

Commit e205a8c

Browse files
authored
fix(rtc): deliver media stream events only to their own stream (#910)
1 parent 4b8d2bd commit e205a8c

4 files changed

Lines changed: 201 additions & 7 deletions

File tree

‎livekit-rtc/livekit/rtc/audio_source.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,19 @@ async def capture_frame(self, frame: AudioFrame) -> None:
136136
req.capture_audio_frame.source_handle = self._ffi_handle.handle
137137
req.capture_audio_frame.buffer.CopyFrom(frame._proto_info())
138138

139-
queue = FfiClient.instance.queue.subscribe(loop=self._loop)
139+
# The async_id is only known once the request returns; until then every capture
140+
# callback is let through, and wait_for drops the ones of other captures.
141+
async_id: int | None = None
142+
queue = FfiClient.instance.queue.subscribe(
143+
loop=self._loop,
144+
filter_fn=lambda e: (
145+
e.WhichOneof("message") == "capture_audio_frame"
146+
and (async_id is None or e.capture_audio_frame.async_id == async_id)
147+
),
148+
)
140149
try:
141150
resp = FfiClient.instance.request(req)
151+
async_id = resp.capture_audio_frame.async_id
142152
cb: proto_ffi.FfiEvent = await queue.wait_for(
143153
lambda e: e.capture_audio_frame.async_id == resp.capture_audio_frame.async_id
144154
)

‎livekit-rtc/livekit/rtc/audio_stream.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,16 @@ def __init__(
106106
self._num_channels = num_channels
107107
self._frame_size_ms = frame_size_ms
108108
self._loop = loop or asyncio.get_event_loop()
109-
# Only subscribe to audio_stream_event to avoid unnecessary memory allocations
110-
# from other event types (room_event, track_event, etc.)
109+
# Subscribe before creating the stream so none of its events are missed. Its
110+
# handle is only known once the request returns; until then every audio stream
111+
# event is let through, and _run drops the ones of other streams.
112+
handle_id: int | None = None
111113
self._ffi_queue = FfiClient.instance.queue.subscribe(
112114
self._loop,
113-
filter_fn=lambda e: e.WhichOneof("message") == "audio_stream_event",
115+
filter_fn=lambda e: (
116+
e.WhichOneof("message") == "audio_stream_event"
117+
and (handle_id is None or e.audio_stream_event.stream_handle == handle_id)
118+
),
114119
)
115120
self._queue: RingQueue[AudioFrameEvent | None] = RingQueue(capacity)
116121

@@ -133,6 +138,7 @@ def __init__(
133138
else:
134139
stream = self._create_owned_stream()
135140
self._ffi_handle = FfiHandle(stream.handle.id)
141+
handle_id = stream.handle.id
136142
self._info = stream.info
137143

138144
# Start _run only after _ffi_handle is set, so a failure above doesn't orphan

‎livekit-rtc/livekit/rtc/video_stream.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,16 @@ def __init__(
4848
**kwargs: Any,
4949
) -> None:
5050
self._loop = loop or asyncio.get_event_loop()
51-
# Only subscribe to video_stream_event to avoid unnecessary memory allocations
52-
# from other event types (room_event, track_event, etc.)
51+
# Subscribe before creating the stream so none of its events are missed. Its
52+
# handle is only known once the request returns; until then every video stream
53+
# event is let through, and _run drops the ones of other streams.
54+
handle_id: int | None = None
5355
self._ffi_queue = FfiClient.instance.queue.subscribe(
5456
self._loop,
55-
filter_fn=lambda e: e.WhichOneof("message") == "video_stream_event",
57+
filter_fn=lambda e: (
58+
e.WhichOneof("message") == "video_stream_event"
59+
and (handle_id is None or e.video_stream_event.stream_handle == handle_id)
60+
),
5661
)
5762
self._queue: RingQueue[VideoFrameEvent | None] = RingQueue(capacity)
5863
self._track: Track | None = track
@@ -68,6 +73,7 @@ def __init__(
6873
stream = self._create_owned_stream()
6974

7075
self._ffi_handle = FfiHandle(stream.handle.id)
76+
handle_id = stream.handle.id
7177
self._info = stream.info
7278

7379
self._task = self._loop.create_task(self._run())

‎tests/rtc/test_media_ffi_filter.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Copyright 2026 LiveKit, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Exercise the media streams' and AudioSource's FFI subscriptions without a native FFI."""
16+
17+
import asyncio
18+
from collections.abc import Iterator
19+
from types import SimpleNamespace
20+
from typing import Union
21+
from unittest.mock import patch
22+
23+
import pytest
24+
25+
from livekit import rtc
26+
from livekit.rtc._ffi_client import FfiClient, FfiQueue
27+
from livekit.rtc._proto import ffi_pb2 as proto_ffi
28+
from livekit.rtc._proto import track_pb2 as proto_track
29+
30+
31+
@pytest.fixture
32+
def queue(monkeypatch: pytest.MonkeyPatch) -> Iterator[FfiQueue[proto_ffi.FfiEvent]]:
33+
queue = FfiQueue[proto_ffi.FfiEvent]()
34+
handles = iter(range(100, 200))
35+
36+
def request(req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse:
37+
response = proto_ffi.FfiResponse()
38+
which = req.WhichOneof("message")
39+
if which == "new_audio_stream":
40+
response.new_audio_stream.stream.handle.id = next(handles)
41+
elif which == "new_video_stream":
42+
response.new_video_stream.stream.handle.id = next(handles)
43+
elif which == "new_audio_source":
44+
response.new_audio_source.source.handle.id = next(handles)
45+
elif which == "capture_audio_frame":
46+
response.capture_audio_frame.async_id = 1
47+
else:
48+
raise AssertionError(f"unexpected FFI request: {which}")
49+
return response
50+
51+
# A different PID prevents synthetic handles from being dropped natively.
52+
monkeypatch.setattr(
53+
FfiClient, "_instance", SimpleNamespace(queue=queue, request=request, _pid=-1)
54+
)
55+
yield queue
56+
assert not queue._subscribers
57+
58+
59+
def _create_stream(event_type: str) -> Union[rtc.AudioStream, rtc.VideoStream]:
60+
track = rtc.Track(proto_track.OwnedTrack())
61+
if event_type == "audio_stream_event":
62+
return rtc.AudioStream(track)
63+
return rtc.VideoStream(track)
64+
65+
66+
def _stream_event(event_type: str, stream_handle: int, *, eos: bool = False) -> proto_ffi.FfiEvent:
67+
event = proto_ffi.FfiEvent()
68+
stream_event = getattr(event, event_type)
69+
stream_event.stream_handle = stream_handle
70+
if eos:
71+
stream_event.eos.SetInParent()
72+
return event
73+
74+
75+
@pytest.mark.parametrize("event_type", ["audio_stream_event", "video_stream_event"])
76+
async def test_stream_does_not_receive_other_streams_events(
77+
queue: FfiQueue[proto_ffi.FfiEvent], event_type: str
78+
) -> None:
79+
first = _create_stream(event_type)
80+
second = _create_stream(event_type)
81+
loop = asyncio.get_running_loop()
82+
event = _stream_event(event_type, second._ffi_handle.handle)
83+
84+
try:
85+
with patch.object(
86+
loop, "call_soon_threadsafe", wraps=loop.call_soon_threadsafe
87+
) as schedule:
88+
for _ in range(100):
89+
queue.put(event)
90+
91+
# Only the stream the events belong to should incur an event-loop wakeup.
92+
assert schedule.call_count == 100
93+
assert all(call.args[0] == second._ffi_queue.put_nowait for call in schedule.call_args_list)
94+
finally:
95+
for stream in (first, second):
96+
queue.put(_stream_event(event_type, stream._ffi_handle.handle, eos=True))
97+
await asyncio.wait_for(stream.aclose(), 1)
98+
99+
100+
@pytest.mark.parametrize("event_type", ["audio_stream_event", "video_stream_event"])
101+
async def test_stream_receives_events_sent_while_it_is_created(
102+
queue: FfiQueue[proto_ffi.FfiEvent],
103+
monkeypatch: pytest.MonkeyPatch,
104+
event_type: str,
105+
) -> None:
106+
instance = FfiClient._instance
107+
assert instance is not None
108+
create = instance.request
109+
110+
def request(req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse:
111+
response = create(req)
112+
which = req.WhichOneof("message")
113+
assert which is not None
114+
# the native stream can end before the request that created it returns
115+
handle = getattr(response, which).stream.handle.id
116+
queue.put(_stream_event(event_type, handle, eos=True))
117+
return response
118+
119+
monkeypatch.setattr(instance, "request", request)
120+
stream = _create_stream(event_type)
121+
await asyncio.wait_for(stream.aclose(), 1)
122+
123+
124+
async def test_capture_frame_only_receives_its_own_callback(
125+
queue: FfiQueue[proto_ffi.FfiEvent],
126+
) -> None:
127+
source = rtc.AudioSource(48000, 1)
128+
capture = asyncio.create_task(source.capture_frame(rtc.AudioFrame.create(48000, 1, 480)))
129+
await asyncio.sleep(0)
130+
131+
loop = asyncio.get_running_loop()
132+
room_event = proto_ffi.FfiEvent()
133+
room_event.room_event.SetInParent()
134+
other_capture = proto_ffi.FfiEvent()
135+
other_capture.capture_audio_frame.async_id = 2
136+
try:
137+
with patch.object(
138+
loop, "call_soon_threadsafe", wraps=loop.call_soon_threadsafe
139+
) as schedule:
140+
for event in (room_event, _stream_event("audio_stream_event", 1), other_capture):
141+
queue.put(event)
142+
143+
# Neither other events nor another capture's callback should wake the capture up.
144+
assert schedule.call_count == 0
145+
finally:
146+
callback = proto_ffi.FfiEvent()
147+
callback.capture_audio_frame.async_id = 1
148+
queue.put(callback)
149+
await asyncio.wait_for(capture, 1)
150+
await source.aclose()
151+
152+
153+
async def test_capture_frame_receives_callback_sent_before_request_returns(
154+
queue: FfiQueue[proto_ffi.FfiEvent], monkeypatch: pytest.MonkeyPatch
155+
) -> None:
156+
instance = FfiClient._instance
157+
assert instance is not None
158+
create = instance.request
159+
160+
def request(req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse:
161+
response = create(req)
162+
if req.WhichOneof("message") == "capture_audio_frame":
163+
# the native capture can complete before the request that started it returns
164+
callback = proto_ffi.FfiEvent()
165+
callback.capture_audio_frame.async_id = response.capture_audio_frame.async_id
166+
queue.put(callback)
167+
return response
168+
169+
monkeypatch.setattr(instance, "request", request)
170+
source = rtc.AudioSource(48000, 1)
171+
await asyncio.wait_for(source.capture_frame(rtc.AudioFrame.create(48000, 1, 480)), 1)
172+
await source.aclose()

0 commit comments

Comments
 (0)