|
26 | 26 | import pytest |
27 | 27 |
|
28 | 28 | from livekit import api, rtc |
| 29 | +from livekit.rtc._ffi_client import FfiClient |
29 | 30 |
|
30 | 31 |
|
31 | 32 | def skip_if_no_credentials() -> Any: |
@@ -61,6 +62,26 @@ def pseudo_random_text(length: int, seed: int = 0x5EED) -> str: |
61 | 62 | return "".join(rng.choices(string.ascii_lowercase, k=length)) |
62 | 63 |
|
63 | 64 |
|
| 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 | + |
64 | 85 | async def connect_rooms( |
65 | 86 | room_name: str, |
66 | 87 | *, |
@@ -441,6 +462,145 @@ async def read() -> None: |
441 | 462 | await sender.disconnect() |
442 | 463 |
|
443 | 464 |
|
| 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 | + |
444 | 604 | @pytest.mark.asyncio |
445 | 605 | @skip_if_no_credentials() # type: ignore[untyped-decorator] |
446 | 606 | async def test_send_file_round_trip(tmp_path: Any) -> None: |
|
0 commit comments