|
26 | 26 | import pytest |
27 | 27 |
|
28 | 28 | from livekit import api, rtc |
29 | | -from livekit.rtc._ffi_client import FfiClient |
| 29 | +from livekit.rtc._ffi_client import FfiClient, FfiHandle |
30 | 30 |
|
31 | 31 |
|
32 | 32 | def skip_if_no_credentials() -> Any: |
@@ -73,6 +73,21 @@ def reader_is_subscribed(reader: Any) -> bool: |
73 | 73 | return any(q is queue for q, _, _ in FfiClient.instance.queue._subscribers) |
74 | 74 |
|
75 | 75 |
|
| 76 | +def ffi_handle_is_dropped(handle_id: int) -> bool: |
| 77 | + """True when the FFI server no longer holds ``handle_id``. |
| 78 | +
|
| 79 | + Probes by trying to drop it: ``FfiHandle.dispose()`` asserts that the |
| 80 | + native drop succeeded, so a handle that is already gone raises. Destructive |
| 81 | + when the handle *is* still held — it gets dropped — so only call this once, |
| 82 | + at the end of a test. |
| 83 | + """ |
| 84 | + try: |
| 85 | + FfiHandle(handle_id).dispose() |
| 86 | + except AssertionError: |
| 87 | + return True |
| 88 | + return False |
| 89 | + |
| 90 | + |
76 | 91 | async def wait_until_unsubscribed(reader: Any, timeout: float = 5.0) -> None: |
77 | 92 | deadline = asyncio.get_event_loop().time() + timeout |
78 | 93 | while asyncio.get_event_loop().time() < deadline: |
@@ -601,6 +616,49 @@ async def read() -> None: |
601 | 616 | await asyncio.gather(receiver.disconnect(), sender.disconnect()) |
602 | 617 |
|
603 | 618 |
|
| 619 | +@pytest.mark.asyncio |
| 620 | +@skip_if_no_credentials() # type: ignore[untyped-decorator] |
| 621 | +async def test_writer_handle_untracked_after_close() -> None: |
| 622 | + """Closing a writer consumes its handle natively, so it must drop out of |
| 623 | + the open-writer set and not be dropped again at disconnect.""" |
| 624 | + receiver, sender = await connect_rooms(unique_room_name("ds-writer-close")) |
| 625 | + try: |
| 626 | + local = sender.local_participant |
| 627 | + writer = await local.stream_text(topic="writer-close-topic") |
| 628 | + handle = writer._writer_handle |
| 629 | + assert handle is not None |
| 630 | + assert handle in local._open_stream_writers |
| 631 | + |
| 632 | + await writer.write("data") |
| 633 | + await writer.aclose() |
| 634 | + |
| 635 | + assert handle not in local._open_stream_writers |
| 636 | + finally: |
| 637 | + await asyncio.gather(receiver.disconnect(), sender.disconnect()) |
| 638 | + |
| 639 | + |
| 640 | +@pytest.mark.asyncio |
| 641 | +@skip_if_no_credentials() # type: ignore[untyped-decorator] |
| 642 | +async def test_abandoned_writer_disposed_on_disconnect() -> None: |
| 643 | + """A writer opened and never closed holds its native writer in the FFI |
| 644 | + handle table; disconnecting must drop it.""" |
| 645 | + receiver, sender = await connect_rooms(unique_room_name("ds-writer-abandon")) |
| 646 | + try: |
| 647 | + writer = await sender.local_participant.stream_text(topic="writer-abandon-topic") |
| 648 | + handle = writer._writer_handle |
| 649 | + assert handle is not None |
| 650 | + |
| 651 | + await writer.write("data") # abandoned: aclose() is never called |
| 652 | + |
| 653 | + await sender.disconnect() |
| 654 | + |
| 655 | + # asserted against the FFI handle table rather than the SDK's |
| 656 | + # bookkeeping, so this fails on an actually-leaked native writer |
| 657 | + assert ffi_handle_is_dropped(handle) |
| 658 | + finally: |
| 659 | + await receiver.disconnect() |
| 660 | + |
| 661 | + |
604 | 662 | @pytest.mark.asyncio |
605 | 663 | @skip_if_no_credentials() # type: ignore[untyped-decorator] |
606 | 664 | async def test_send_file_round_trip(tmp_path: Any) -> None: |
|
0 commit comments