Skip to content

Commit d21086f

Browse files
committed
fix: ensure that data streams readers are never orphaned when there's an error reading via ffi
1 parent da90a5b commit d21086f

3 files changed

Lines changed: 286 additions & 7 deletions

File tree

livekit-rtc/livekit/rtc/data_stream.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,25 @@ def _byte_stream_info_from_proto(info: proto_data_stream.ByteStreamInfo) -> Byte
8989

9090

9191
class TextStreamReader:
92+
"""An incoming text stream.
93+
94+
Use as an async iterator to receive chunks, or :meth:`read_all` to collect
95+
the whole payload::
96+
97+
async for chunk in reader:
98+
process(chunk)
99+
100+
A reader subscribes to the FFI event queue as soon as it is handed to your
101+
handler, so one that is never iterated to completion — abandoned after a
102+
``break``, or received by a handler that never reads it — must be closed
103+
with :meth:`close`, or its subscription lives for the rest of the process.
104+
Iterating to the end closes the reader for you.
105+
106+
If the stream terminates abnormally (aborted by the sender, oversized, or
107+
the room disconnects mid-stream), :class:`StreamError` is raised instead of
108+
a normal ``StopAsyncIteration``.
109+
"""
110+
92111
def __init__(
93112
self,
94113
owned_info: proto_data_stream.OwnedTextStreamReader,
@@ -151,25 +170,80 @@ async def read_all(self) -> str:
151170
final_string += chunk
152171
return final_string
153172

173+
def _unsubscribe(self) -> None:
174+
"""Drops the FFI queue subscription without ending the stream.
175+
176+
Events already delivered stay in the queue and remain readable; only
177+
the global subscription, and the filter it runs against every FFI
178+
event, goes away. Safe to call repeatedly — unsubscribing a queue that
179+
is no longer registered is a no-op.
180+
181+
Only the subscription is released: the reader handle was consumed by
182+
the read_incremental request in __init__, so there is nothing left to
183+
dispose (and disposing it would drop a handle the FFI server no longer
184+
owns).
185+
"""
186+
FfiClient.instance.queue.unsubscribe(self._queue)
187+
154188
def _close(self) -> None:
155189
if not self._closed:
156190
self._closed = True
157-
FfiClient.instance.queue.unsubscribe(self._queue)
191+
self._unsubscribe()
158192
if self._on_close is not None:
159193
self._on_close()
160194

195+
def close(self) -> None:
196+
"""Explicitly close the reader and unsubscribe.
197+
198+
Call this on a reader you stop consuming before it ends. Closing a
199+
reader that already reached end-of-stream is a no-op, and iterating a
200+
closed reader raises ``StopAsyncIteration`` (or :class:`StreamError`
201+
if the stream had already failed).
202+
"""
203+
self._close()
204+
205+
async def aclose(self) -> None:
206+
self.close()
207+
161208
def _signal_disconnect(self) -> None:
162209
"""Injects a synthetic EOS-with-error event so pending reads wake up
163-
and raise StreamError when the room disconnects mid-stream."""
210+
and raise StreamError when the room disconnects mid-stream.
211+
212+
Also drops the queue subscription, which would otherwise outlive the
213+
room: no further events can arrive once the room is gone, and the
214+
injected one is already queued. The reader is deliberately left open
215+
so chunks that arrived before the disconnect are still delivered
216+
before the StreamError, as they were before this was unsubscribed
217+
here.
218+
"""
164219
if self._closed:
165220
return
166221
event = proto_ffi.FfiEvent()
167222
event.text_stream_reader_event.reader_handle = self._reader_handle
168223
event.text_stream_reader_event.eos.error.description = _DISCONNECT_ERROR
169224
self._queue.put_nowait(event)
225+
self._unsubscribe()
170226

171227

172228
class ByteStreamReader:
229+
"""An incoming byte stream.
230+
231+
Use as an async iterator to receive chunks::
232+
233+
async for chunk in reader:
234+
process(chunk)
235+
236+
A reader subscribes to the FFI event queue as soon as it is handed to your
237+
handler, so one that is never iterated to completion — abandoned after a
238+
``break``, or received by a handler that never reads it — must be closed
239+
with :meth:`close`, or its subscription lives for the rest of the process.
240+
Iterating to the end closes the reader for you.
241+
242+
If the stream terminates abnormally (aborted by the sender, oversized, or
243+
the room disconnects mid-stream), :class:`StreamError` is raised instead of
244+
a normal ``StopAsyncIteration``.
245+
"""
246+
173247
def __init__(
174248
self,
175249
owned_info: proto_data_stream.OwnedByteStreamReader,
@@ -225,22 +299,59 @@ async def __anext__(self) -> bytes:
225299
def info(self) -> ByteStreamInfo:
226300
return self._info
227301

302+
def _unsubscribe(self) -> None:
303+
"""Drops the FFI queue subscription without ending the stream.
304+
305+
Events already delivered stay in the queue and remain readable; only
306+
the global subscription, and the filter it runs against every FFI
307+
event, goes away. Safe to call repeatedly — unsubscribing a queue that
308+
is no longer registered is a no-op.
309+
310+
Only the subscription is released: the reader handle was consumed by
311+
the read_incremental request in __init__, so there is nothing left to
312+
dispose (and disposing it would drop a handle the FFI server no longer
313+
owns).
314+
"""
315+
FfiClient.instance.queue.unsubscribe(self._queue)
316+
228317
def _close(self) -> None:
229318
if not self._closed:
230319
self._closed = True
231-
FfiClient.instance.queue.unsubscribe(self._queue)
320+
self._unsubscribe()
232321
if self._on_close is not None:
233322
self._on_close()
234323

324+
def close(self) -> None:
325+
"""Explicitly close the reader and unsubscribe.
326+
327+
Call this on a reader you stop consuming before it ends. Closing a
328+
reader that already reached end-of-stream is a no-op, and iterating a
329+
closed reader raises ``StopAsyncIteration`` (or :class:`StreamError`
330+
if the stream had already failed).
331+
"""
332+
self._close()
333+
334+
async def aclose(self) -> None:
335+
self.close()
336+
235337
def _signal_disconnect(self) -> None:
236338
"""Injects a synthetic EOS-with-error event so pending reads wake up
237-
and raise StreamError when the room disconnects mid-stream."""
339+
and raise StreamError when the room disconnects mid-stream.
340+
341+
Also drops the queue subscription, which would otherwise outlive the
342+
room: no further events can arrive once the room is gone, and the
343+
injected one is already queued. The reader is deliberately left open
344+
so chunks that arrived before the disconnect are still delivered
345+
before the StreamError, as they were before this was unsubscribed
346+
here.
347+
"""
238348
if self._closed:
239349
return
240350
event = proto_ffi.FfiEvent()
241351
event.byte_stream_reader_event.reader_handle = self._reader_handle
242352
event.byte_stream_reader_event.eos.error.description = _DISCONNECT_ERROR
243353
self._queue.put_nowait(event)
354+
self._unsubscribe()
244355

245356

246357
class BaseStreamWriter:

livekit-rtc/livekit/rtc/room.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,10 +1196,18 @@ def on_close() -> None:
11961196
byte_stream_handler(byte_reader, opened.participant_identity)
11971197

11981198
def _error_stream_readers(self) -> None:
1199-
"""Wakes up any pending stream reads with a StreamError on disconnect."""
1200-
for text_reader in self._text_stream_readers.values():
1199+
"""Wakes up any pending stream reads with a StreamError on disconnect.
1200+
1201+
This also drops each reader's FFI queue subscription, which would
1202+
otherwise outlive the room: a reader unsubscribes itself only once a
1203+
read consumes its end-of-stream event, so one the application never
1204+
finished reading would stay subscribed for the life of the process.
1205+
The readers stay open, so a read can still drain whatever arrived
1206+
before the disconnect and then raise StreamError.
1207+
"""
1208+
for text_reader in list(self._text_stream_readers.values()):
12011209
text_reader._signal_disconnect()
1202-
for byte_reader in self._byte_stream_readers.values():
1210+
for byte_reader in list(self._byte_stream_readers.values()):
12031211
byte_reader._signal_disconnect()
12041212
self._text_stream_readers.clear()
12051213
self._byte_stream_readers.clear()

tests/rtc/test_e2e_data_streams.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import pytest
2727

2828
from livekit import api, rtc
29+
from livekit.rtc._ffi_client import FfiClient
2930

3031

3132
def skip_if_no_credentials() -> Any:
@@ -61,6 +62,26 @@ def pseudo_random_text(length: int, seed: int = 0x5EED) -> str:
6162
return "".join(rng.choices(string.ascii_lowercase, k=length))
6263

6364

65+
def reader_is_subscribed(reader: Any) -> bool:
66+
"""True while the reader's filtered subscriber is still on the FFI queue.
67+
68+
Checked by identity against the reader's own queue rather than by counting
69+
subscribers, so unrelated churn (per-request callback subscriptions, the
70+
room's own subscribers going away at disconnect) can't affect the result.
71+
"""
72+
queue = reader._queue
73+
return any(q is queue for q, _, _ in FfiClient.instance.queue._subscribers)
74+
75+
76+
async def wait_until_unsubscribed(reader: Any, timeout: float = 5.0) -> None:
77+
deadline = asyncio.get_event_loop().time() + timeout
78+
while asyncio.get_event_loop().time() < deadline:
79+
if not reader_is_subscribed(reader):
80+
return
81+
await asyncio.sleep(0.02)
82+
raise AssertionError("reader is still subscribed to the FFI event queue")
83+
84+
6485
async def connect_rooms(
6586
room_name: str,
6687
*,
@@ -441,6 +462,145 @@ async def read() -> None:
441462
await sender.disconnect()
442463

443464

465+
@pytest.mark.asyncio
466+
@skip_if_no_credentials() # type: ignore[untyped-decorator]
467+
async def test_abandoned_reader_releases_subscription_on_close() -> None:
468+
"""A reader abandoned mid-iteration never consumes its terminal eos event,
469+
so it stays subscribed to the FFI queue until close() is called."""
470+
receiver, sender = await connect_rooms(unique_room_name("ds-abandon"))
471+
try:
472+
got_reader: asyncio.Future[rtc.TextStreamReader] = asyncio.get_event_loop().create_future()
473+
abandoned = asyncio.Event()
474+
475+
def handler(reader: rtc.TextStreamReader, _identity: str) -> None:
476+
got_reader.set_result(reader)
477+
478+
async def read() -> None:
479+
async for _chunk in reader:
480+
break # walk away without draining to end-of-stream
481+
abandoned.set()
482+
483+
asyncio.create_task(read())
484+
485+
receiver.register_text_stream_handler("abandon-topic", handler)
486+
487+
await sender.local_participant.send_text(pseudo_random_text(50_000), topic="abandon-topic")
488+
reader = await asyncio.wait_for(got_reader, timeout=10.0)
489+
await asyncio.wait_for(abandoned.wait(), timeout=10.0)
490+
491+
assert reader_is_subscribed(reader)
492+
reader.close()
493+
await wait_until_unsubscribed(reader)
494+
495+
reader.close() # idempotent
496+
finally:
497+
await asyncio.gather(receiver.disconnect(), sender.disconnect())
498+
499+
500+
@pytest.mark.asyncio
501+
@skip_if_no_credentials() # type: ignore[untyped-decorator]
502+
async def test_never_read_reader_releases_subscription_on_close() -> None:
503+
"""A handler that never iterates its reader still leaves a subscription
504+
behind, because readers subscribe eagerly on construction."""
505+
receiver, sender = await connect_rooms(unique_room_name("ds-noread"))
506+
writer = None
507+
try:
508+
got_reader: asyncio.Future[rtc.TextStreamReader] = asyncio.get_event_loop().create_future()
509+
510+
def handler(reader: rtc.TextStreamReader, _identity: str) -> None:
511+
got_reader.set_result(reader) # never read
512+
513+
receiver.register_text_stream_handler("noread-topic", handler)
514+
515+
writer = await sender.local_participant.stream_text(topic="noread-topic")
516+
await writer.write("some data")
517+
518+
reader = await asyncio.wait_for(got_reader, timeout=10.0)
519+
assert reader_is_subscribed(reader)
520+
521+
await reader.aclose()
522+
await wait_until_unsubscribed(reader)
523+
finally:
524+
if writer is not None:
525+
await writer.aclose()
526+
await asyncio.gather(receiver.disconnect(), sender.disconnect())
527+
528+
529+
@pytest.mark.asyncio
530+
@skip_if_no_credentials() # type: ignore[untyped-decorator]
531+
async def test_disconnect_unsubscribes_unread_reader() -> None:
532+
"""Disconnecting must drop the subscriptions of readers the application
533+
never consumed, without the application closing them.
534+
535+
The reader is left open, so a read starting after the disconnect still
536+
drains whatever arrived beforehand and then reports the failure — the
537+
behaviour callers had before disconnect released the subscription.
538+
"""
539+
receiver, sender = await connect_rooms(unique_room_name("ds-disc-unread"))
540+
writer = None
541+
try:
542+
got_reader: asyncio.Future[rtc.TextStreamReader] = asyncio.get_event_loop().create_future()
543+
544+
def handler(reader: rtc.TextStreamReader, _identity: str) -> None:
545+
got_reader.set_result(reader) # never read
546+
547+
receiver.register_text_stream_handler("disc-unread-topic", handler)
548+
549+
writer = await sender.local_participant.stream_text(topic="disc-unread-topic")
550+
await writer.write("buffered ")
551+
await writer.write("data")
552+
553+
reader = await asyncio.wait_for(got_reader, timeout=10.0)
554+
assert reader_is_subscribed(reader)
555+
# let the chunks land in the reader's queue while nothing is reading
556+
await asyncio.sleep(0.5)
557+
558+
await receiver.disconnect()
559+
await wait_until_unsubscribed(reader)
560+
561+
# chunks received before the disconnect are still delivered, and the
562+
# stream then terminates with StreamError rather than a clean EOF
563+
chunks = []
564+
with pytest.raises(rtc.StreamError):
565+
async for chunk in reader:
566+
chunks.append(chunk)
567+
assert "".join(chunks) == "buffered data"
568+
finally:
569+
if writer is not None:
570+
await writer.aclose()
571+
await sender.disconnect()
572+
573+
574+
@pytest.mark.asyncio
575+
@skip_if_no_credentials() # type: ignore[untyped-decorator]
576+
async def test_fully_read_reader_needs_no_close() -> None:
577+
"""Reading to end-of-stream unsubscribes on its own; close() afterwards is
578+
a no-op."""
579+
receiver, sender = await connect_rooms(unique_room_name("ds-drain"))
580+
try:
581+
got_reader: asyncio.Future[rtc.TextStreamReader] = asyncio.get_event_loop().create_future()
582+
received: asyncio.Future[str] = asyncio.get_event_loop().create_future()
583+
584+
def handler(reader: rtc.TextStreamReader, _identity: str) -> None:
585+
got_reader.set_result(reader)
586+
587+
async def read() -> None:
588+
received.set_result(await reader.read_all())
589+
590+
asyncio.create_task(read())
591+
592+
receiver.register_text_stream_handler("drain-topic", handler)
593+
594+
await sender.local_participant.send_text("hello", topic="drain-topic")
595+
assert await asyncio.wait_for(received, timeout=10.0) == "hello"
596+
597+
reader = await asyncio.wait_for(got_reader, timeout=5.0)
598+
await wait_until_unsubscribed(reader)
599+
reader.close()
600+
finally:
601+
await asyncio.gather(receiver.disconnect(), sender.disconnect())
602+
603+
444604
@pytest.mark.asyncio
445605
@skip_if_no_credentials() # type: ignore[untyped-decorator]
446606
async def test_send_file_round_trip(tmp_path: Any) -> None:

0 commit comments

Comments
 (0)