From 24e30072b3b6ff1cac8543e5d334496b50b8734d Mon Sep 17 00:00:00 2001 From: Zek Durmus Date: Fri, 2 Oct 2026 15:14:48 -0500 Subject: [PATCH 1/4] fix(rtc): pass the FFI request bytes without a new ctypes array type FfiClient.request built `(ctypes.c_ubyte * proto_len)(*proto_data)` on every call. Each `c_ubyte * n` creates a new ctypes array type, and CPython does not cache it, so every request left a type object in a reference cycle that only the cyclic garbage collector frees. The copy also unpacked the payload byte by byte. Cast a `c_char_p` over the serialized bytes instead. The native call receives the same pointer, nothing is copied, and no type is created. --- livekit-rtc/livekit/rtc/_ffi_client.py | 3 ++- livekit-rtc/tests/test_ffi_client.py | 34 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 livekit-rtc/tests/test_ffi_client.py diff --git a/livekit-rtc/livekit/rtc/_ffi_client.py b/livekit-rtc/livekit/rtc/_ffi_client.py index 3ae44aac..d239b3eb 100644 --- a/livekit-rtc/livekit/rtc/_ffi_client.py +++ b/livekit-rtc/livekit/rtc/_ffi_client.py @@ -288,7 +288,8 @@ def request(self, req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse: ) proto_data = req.SerializeToString() proto_len = len(proto_data) - data = (ctypes.c_ubyte * proto_len)(*proto_data) + # A `c_ubyte * n` array type is a reference cycle that only the gc frees. + data = ctypes.cast(ctypes.c_char_p(proto_data), ctypes.POINTER(ctypes.c_ubyte)) resp_ptr = ctypes.POINTER(ctypes.c_ubyte)() resp_len = ctypes.c_size_t() diff --git a/livekit-rtc/tests/test_ffi_client.py b/livekit-rtc/tests/test_ffi_client.py new file mode 100644 index 00000000..4810d2d2 --- /dev/null +++ b/livekit-rtc/tests/test_ffi_client.py @@ -0,0 +1,34 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FfiClient.request tests against the native library.""" + +import ctypes +import gc + +from livekit import rtc + + +def test_request_leaves_no_cyclic_garbage() -> None: + gc.collect() + gc.set_debug(gc.DEBUG_SAVEALL) + try: + for _ in range(50): + rtc.AudioProcessingModule() + gc.collect() + array_types = [o for o in gc.garbage if isinstance(o, type(ctypes.Array))] + finally: + gc.set_debug(0) + gc.garbage.clear() + assert array_types == [] From f9a8773d54f68d7be3b7207251ac53e273be5811 Mon Sep 17 00:00:00 2001 From: Zek Durmus Date: Fri, 2 Oct 2026 15:20:59 -0500 Subject: [PATCH 2/4] test(rtc): restore the caller's gc debug flags and garbage list --- livekit-rtc/tests/test_ffi_client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/livekit-rtc/tests/test_ffi_client.py b/livekit-rtc/tests/test_ffi_client.py index 4810d2d2..32fa2f44 100644 --- a/livekit-rtc/tests/test_ffi_client.py +++ b/livekit-rtc/tests/test_ffi_client.py @@ -22,13 +22,16 @@ def test_request_leaves_no_cyclic_garbage() -> None: gc.collect() - gc.set_debug(gc.DEBUG_SAVEALL) + debug_flags = gc.get_debug() + garbage_len = len(gc.garbage) + gc.set_debug(debug_flags | gc.DEBUG_SAVEALL) try: for _ in range(50): rtc.AudioProcessingModule() gc.collect() - array_types = [o for o in gc.garbage if isinstance(o, type(ctypes.Array))] + new_garbage = gc.garbage[garbage_len:] + array_types = [o for o in new_garbage if isinstance(o, type(ctypes.Array))] finally: - gc.set_debug(0) - gc.garbage.clear() + gc.set_debug(debug_flags) + del gc.garbage[garbage_len:] assert array_types == [] From 2138489a62983b93e5cda8c1e0f63163dc79c728 Mon Sep 17 00:00:00 2001 From: Zek Durmus Date: Sat, 3 Oct 2026 18:55:18 -0500 Subject: [PATCH 3/4] test(rtc): count the leftover array types in a fresh interpreter ctypes reuses a c_ubyte * n type that another live object still holds, so in a shared test process the old per-request type could be found and the test would pass against it. --- livekit-rtc/tests/test_ffi_client.py | 37 +++++++++++++++++----------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/livekit-rtc/tests/test_ffi_client.py b/livekit-rtc/tests/test_ffi_client.py index 32fa2f44..53873884 100644 --- a/livekit-rtc/tests/test_ffi_client.py +++ b/livekit-rtc/tests/test_ffi_client.py @@ -14,24 +14,31 @@ """FfiClient.request tests against the native library.""" +import subprocess +import sys + +# Runs in a fresh interpreter: ctypes reuses a `c_ubyte * n` type that another +# live object still holds, which would hide a new one made per request. +_COUNT_ARRAY_TYPES_IN_GARBAGE = """ import ctypes import gc from livekit import rtc +gc.collect() +gc.set_debug(gc.DEBUG_SAVEALL) +for _ in range(50): + rtc.AudioProcessingModule() +gc.collect() +print(sum(isinstance(o, type(ctypes.Array)) for o in gc.garbage)) +""" + -def test_request_leaves_no_cyclic_garbage() -> None: - gc.collect() - debug_flags = gc.get_debug() - garbage_len = len(gc.garbage) - gc.set_debug(debug_flags | gc.DEBUG_SAVEALL) - try: - for _ in range(50): - rtc.AudioProcessingModule() - gc.collect() - new_garbage = gc.garbage[garbage_len:] - array_types = [o for o in new_garbage if isinstance(o, type(ctypes.Array))] - finally: - gc.set_debug(debug_flags) - del gc.garbage[garbage_len:] - assert array_types == [] +def test_request_leaves_no_ctypes_array_type_in_garbage() -> None: + result = subprocess.run( + [sys.executable, "-c", _COUNT_ARRAY_TYPES_IN_GARBAGE], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "0" From 979785ab11f0e7d65584217cac174adee52c6528 Mon Sep 17 00:00:00 2001 From: Zek Durmus Date: Sat, 3 Oct 2026 19:45:17 -0500 Subject: [PATCH 4/4] chore: drop comments that restate the code --- livekit-rtc/livekit/rtc/_ffi_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/livekit-rtc/livekit/rtc/_ffi_client.py b/livekit-rtc/livekit/rtc/_ffi_client.py index d239b3eb..aab703f6 100644 --- a/livekit-rtc/livekit/rtc/_ffi_client.py +++ b/livekit-rtc/livekit/rtc/_ffi_client.py @@ -288,7 +288,6 @@ def request(self, req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse: ) proto_data = req.SerializeToString() proto_len = len(proto_data) - # A `c_ubyte * n` array type is a reference cycle that only the gc frees. data = ctypes.cast(ctypes.c_char_p(proto_data), ctypes.POINTER(ctypes.c_ubyte)) resp_ptr = ctypes.POINTER(ctypes.c_ubyte)()