Skip to content

Commit d8d4010

Browse files
authored
fix(tests): run FFI dispose test in a subprocess and add test timeouts (#931)
1 parent 60ba761 commit d8d4010

3 files changed

Lines changed: 58 additions & 36 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
test:
3232
name: Test (${{ inputs.os }}, Python ${{ inputs.python-version }})
3333
runs-on: ${{ inputs.os }}
34+
timeout-minutes: 45
3435
steps:
3536
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
3637
with:
@@ -100,7 +101,7 @@ jobs:
100101
uv venv .test-venv
101102
source .test-venv/bin/activate
102103
uv pip install rtc-wheel/*.whl ./livekit-api ./livekit-protocol
103-
uv pip install pytest pytest-asyncio numpy matplotlib
104+
uv pip install pytest pytest-asyncio pytest-timeout numpy matplotlib
104105
105106
- name: Create venv and install dependencies (macOS)
106107
if: runner.os == 'macOS'
@@ -109,15 +110,15 @@ jobs:
109110
source .test-venv/bin/activate
110111
uv pip install "${{ steps.select-wheel-macos.outputs.wheel }}"
111112
uv pip install ./livekit-api ./livekit-protocol
112-
uv pip install pytest pytest-asyncio numpy matplotlib
113+
uv pip install pytest pytest-asyncio pytest-timeout numpy matplotlib
113114
114115
- name: Create venv and install dependencies (Windows)
115116
if: runner.os == 'Windows'
116117
run: |
117118
uv venv .test-venv
118119
$wheel = (Get-ChildItem rtc-wheel\*.whl)[0].FullName
119120
uv pip install --python .test-venv $wheel .\livekit-api .\livekit-protocol
120-
uv pip install --python .test-venv pytest pytest-asyncio numpy matplotlib
121+
uv pip install --python .test-venv pytest pytest-asyncio pytest-timeout numpy matplotlib
121122
shell: pwsh
122123

123124
- name: Run tests (Unix)
@@ -128,14 +129,14 @@ jobs:
128129
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
129130
run: |
130131
source .test-venv/bin/activate
131-
pytest tests/ livekit-rtc/tests/
132+
pytest --timeout=300 tests/ livekit-rtc/tests/
132133
133134
- name: Run tests (Windows)
134135
if: runner.os == 'Windows'
135136
env:
136137
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
137138
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
138139
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
139-
run: .test-venv\Scripts\python.exe -m pytest tests/ livekit-rtc/tests/
140+
run: .test-venv\Scripts\python.exe -m pytest --timeout=300 tests/ livekit-rtc/tests/
140141
shell: pwsh
141142

‎livekit-rtc/tests/test_dc.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,13 @@ async def test_publish_data_makes_no_ctypes_array_type() -> None:
308308
lambda: len(collector.packets) >= len(payloads),
309309
message="receiver did not get all payloads",
310310
)
311+
# Check `type(o)` instead of `isinstance(o, type)`: gc can hand back a
312+
# dead `weakref.proxy`, and isinstance() dereferences it and raises
313+
# ReferenceError (seen on Windows ARM64).
311314
array_types = [
312315
o
313316
for o in gc.get_objects()
314-
if isinstance(o, type)
317+
if issubclass(type(o), type)
315318
and issubclass(o, ctypes.Array)
316319
and getattr(o, "_length_", None) in lengths
317320
]

‎livekit-rtc/tests/test_ffi_handle_lifecycle.py‎

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,12 @@
1414

1515
from __future__ import annotations
1616

17-
import gc
1817
import subprocess
1918
import sys
2019

2120
import pytest
2221

2322
from livekit.rtc._ffi_client import FfiClient, FfiHandle
24-
from livekit.rtc._proto import audio_frame_pb2, ffi_pb2
25-
26-
27-
def _allocate_real_handle() -> int:
28-
req = ffi_pb2.FfiRequest()
29-
req.new_audio_source.type = audio_frame_pb2.AUDIO_SOURCE_NATIVE
30-
req.new_audio_source.sample_rate = 48000
31-
req.new_audio_source.num_channels = 1
32-
req.new_audio_source.queue_size_ms = 100
33-
resp = FfiClient.instance.request(req)
34-
handle_id = resp.new_audio_source.source.handle.id
35-
assert handle_id != 0
36-
return int(handle_id)
3723

3824

3925
def test_active_runtime_invalid_handle_asserts() -> None:
@@ -42,26 +28,57 @@ def test_active_runtime_invalid_handle_asserts() -> None:
4228
FfiHandle(999999).dispose()
4329

4430

45-
def test_live_handles_marked_consumed_and_no_assert_after_dispose() -> None:
46-
handle_id = _allocate_real_handle()
47-
handle = FfiHandle(handle_id)
48-
assert not handle.disposed
31+
# Runs in a fresh interpreter: FfiClient._dispose() tears down the process-wide
32+
# FFI runtime, and every later test in the same pytest process would then wait
33+
# forever for FFI callbacks that never arrive.
34+
_DISPOSE_MARKS_LIVE_HANDLES_CONSUMED = """
35+
import gc
36+
37+
from livekit.rtc._ffi_client import FfiClient, FfiHandle
38+
from livekit.rtc._proto import audio_frame_pb2, ffi_pb2
39+
40+
req = ffi_pb2.FfiRequest()
41+
req.new_audio_source.type = audio_frame_pb2.AUDIO_SOURCE_NATIVE
42+
req.new_audio_source.sample_rate = 48000
43+
req.new_audio_source.num_channels = 1
44+
req.new_audio_source.queue_size_ms = 100
45+
resp = FfiClient.instance.request(req)
46+
handle_id = resp.new_audio_source.source.handle.id
47+
assert handle_id != 0
48+
49+
handle = FfiHandle(handle_id)
50+
assert not handle.disposed
4951
50-
# Global dispose marks all live handles consumed and sets _ffi_disposed
51-
FfiClient.instance._dispose()
52-
assert handle.disposed
52+
# Global dispose marks all live handles consumed and sets _ffi_disposed
53+
FfiClient.instance._dispose()
54+
assert handle.disposed
5355
54-
# Explicit dispose or __del__ after global dispose should be a no-op and not assert
55-
handle.dispose()
56-
handle.__del__()
56+
# Explicit dispose or __del__ after global dispose should be a no-op and not assert
57+
handle.dispose()
58+
handle.__del__()
59+
60+
# Newly instantiated handle after global dispose also safely no-ops without assert
61+
late_handle = FfiHandle(12345)
62+
late_handle.dispose()
63+
64+
del handle
65+
del late_handle
66+
gc.collect()
67+
print("ok")
68+
"""
5769

58-
# Newly instantiated handle after global dispose also safely no-ops without assert
59-
late_handle = FfiHandle(12345)
60-
late_handle.dispose()
6170

62-
del handle
63-
del late_handle
64-
gc.collect()
71+
def test_live_handles_marked_consumed_and_no_assert_after_dispose() -> None:
72+
result = subprocess.run(
73+
[sys.executable, "-c", _DISPOSE_MARKS_LIVE_HANDLES_CONSUMED],
74+
capture_output=True,
75+
text=True,
76+
timeout=60,
77+
)
78+
assert result.returncode == 0, result.stderr
79+
assert result.stdout.strip() == "ok"
80+
assert "AssertionError" not in result.stderr
81+
assert "Exception ignored" not in result.stderr
6582

6683

6784
def test_subprocess_exit_with_undisposed_handle_has_no_assertion_error() -> None:
@@ -85,6 +102,7 @@ def test_subprocess_exit_with_undisposed_handle_has_no_assertion_error() -> None
85102
[sys.executable, "-c", code],
86103
capture_output=True,
87104
text=True,
105+
timeout=60,
88106
)
89107
assert result.returncode == 0
90108
assert "AssertionError" not in result.stderr

0 commit comments

Comments
 (0)