Skip to content

Commit bb3ed60

Browse files
fix(rtc): avoid AssertionError in FfiHandle.__del__ after global FFI disposal (#857)
1 parent b0730f9 commit bb3ed60

2 files changed

Lines changed: 110 additions & 7 deletions

File tree

‎livekit-rtc/livekit/rtc/_ffi_client.py‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
import signal
1618
import asyncio
1719
import sys
@@ -24,6 +26,7 @@
2426
import platform
2527
import atexit
2628
import threading
29+
import weakref
2730
from typing import Callable, Generic, List, Optional, TypeVar
2831

2932
from ._proto import ffi_pb2 as proto_ffi
@@ -68,10 +71,14 @@ def get_ffi_lib() -> ctypes.CDLL:
6871
INVALID_HANDLE = 0
6972

7073

74+
_live_handles: weakref.WeakSet[FfiHandle] = weakref.WeakSet()
75+
76+
7177
class FfiHandle:
7278
def __init__(self, handle: int) -> None:
7379
self.handle = handle
7480
self._disposed = False
81+
_live_handles.add(self)
7582

7683
def __del__(self) -> None:
7784
self.dispose()
@@ -93,7 +100,7 @@ def dispose(self) -> None:
93100
if self.handle != INVALID_HANDLE and not self._disposed:
94101
self._disposed = True
95102
ffi = FfiClient._instance
96-
if ffi is None or ffi._pid != os.getpid():
103+
if ffi is None or ffi._pid != os.getpid() or getattr(FfiClient, "_ffi_disposed", False):
97104
return
98105
dropped = ffi._ffi_lib.livekit_ffi_drop_handle(ctypes.c_uint64(self.handle))
99106
assert dropped
@@ -223,6 +230,7 @@ def to_python_level(level: proto_ffi.LogLevel.ValueType) -> Optional[int]:
223230

224231
class FfiClient:
225232
_instance: Optional["FfiClient"] = None
233+
_ffi_disposed: bool = False
226234

227235
@classproperty
228236
def instance(cls) -> "FfiClient":
@@ -231,6 +239,7 @@ def instance(cls) -> "FfiClient":
231239
return cls._instance
232240

233241
def __init__(self) -> None:
242+
FfiClient._ffi_disposed = False
234243
self._pid = os.getpid()
235244
self._lock = threading.RLock()
236245
self._queue = FfiQueue[proto_ffi.FfiEvent]()
@@ -266,14 +275,17 @@ def __init__(self) -> None:
266275
ffi_event_callback, True, b"python", __version__.encode("ascii")
267276
)
268277

269-
ffi_lib = self._ffi_lib
270-
init_pid = self._pid
271-
272278
@atexit.register
273279
def _dispose_lk_ffi() -> None:
274-
if os.getpid() != init_pid:
275-
return
276-
ffi_lib.livekit_ffi_dispose()
280+
self._dispose()
281+
282+
def _dispose(self) -> None:
283+
if self._pid != os.getpid() or FfiClient._ffi_disposed:
284+
return
285+
FfiClient._ffi_disposed = True
286+
for handle in list(_live_handles):
287+
handle.mark_consumed()
288+
self._ffi_lib.livekit_ffi_dispose()
277289

278290
@property
279291
def queue(self) -> FfiQueue[proto_ffi.FfiEvent]:
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
from __future__ import annotations
16+
17+
import gc
18+
import subprocess
19+
import sys
20+
21+
import pytest
22+
23+
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)
37+
38+
39+
def test_active_runtime_invalid_handle_asserts() -> None:
40+
FfiClient.instance
41+
with pytest.raises(AssertionError):
42+
FfiHandle(999999).dispose()
43+
44+
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
49+
50+
# Global dispose marks all live handles consumed and sets _ffi_disposed
51+
FfiClient.instance._dispose()
52+
assert handle.disposed
53+
54+
# Explicit dispose or __del__ after global dispose should be a no-op and not assert
55+
handle.dispose()
56+
handle.__del__()
57+
58+
# Newly instantiated handle after global dispose also safely no-ops without assert
59+
late_handle = FfiHandle(12345)
60+
late_handle.dispose()
61+
62+
del handle
63+
del late_handle
64+
gc.collect()
65+
66+
67+
def test_subprocess_exit_with_undisposed_handle_has_no_assertion_error() -> None:
68+
code = """
69+
import sys
70+
from livekit.rtc._proto import audio_frame_pb2, ffi_pb2
71+
from livekit.rtc._ffi_client import FfiClient, FfiHandle
72+
73+
req = ffi_pb2.FfiRequest()
74+
req.new_audio_source.type = audio_frame_pb2.AUDIO_SOURCE_NATIVE
75+
req.new_audio_source.sample_rate = 48000
76+
req.new_audio_source.num_channels = 1
77+
req.new_audio_source.queue_size_ms = 100
78+
resp = FfiClient.instance.request(req)
79+
handle_id = resp.new_audio_source.source.handle.id
80+
# Leave handle alive at process shutdown
81+
global_handle = FfiHandle(handle_id)
82+
sys.exit(0)
83+
"""
84+
result = subprocess.run(
85+
[sys.executable, "-c", code],
86+
capture_output=True,
87+
text=True,
88+
)
89+
assert result.returncode == 0
90+
assert "AssertionError" not in result.stderr
91+
assert "Exception ignored" not in result.stderr

0 commit comments

Comments
 (0)