Skip to content

fix(rtc): pass the FFI request bytes without a new ctypes array type - #847

Open
zdurm wants to merge 4 commits into
livekit:mainfrom
zdurm:fix/ffi-request-no-ctypes-array-type
Open

zdurm wants to merge 4 commits into
livekit:mainfrom
zdurm:fix/ffi-request-no-ctypes-array-type

Conversation

@zdurm

@zdurm zdurm commented Oct 2, 2026 •

Copy link
Copy Markdown

Fixes #848

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 array types, so every FFI request leaves a type object in a reference cycle. Only the cyclic garbage collector frees it. The old line also copied the payload one byte at a time.

This PR casts a c_char_p over the serialized bytes instead. livekit_ffi_request receives the same pointer, nothing is copied, and no type is created. (ctypes.cast accepts bytes at runtime, but typeshed rejects it, hence the c_char_p.)

Why it matters

The FFI is called for every captured frame and every room operation, so the garbage grows with traffic. In a process that runs many agent sessions, these types survive into generation 2 and make each full collection longer. A full collection holds the GIL and pauses every event loop in the process.

Measurements

CPython 3.12.12, macOS arm64.

Before After
Cyclic types left by 200 requests 199 0
PyCArrayType objects collected in generation 2, 18 agent sessions in one worker process 1,510 0
Time to build the request pointer, 32-byte request 1.19 µs 0.34 µs
Time to build the request pointer, 256-byte request 8.81 µs 0.35 µs

The new test test_request_leaves_no_ctypes_array_type_in_garbage starts a fresh interpreter, runs 50 real requests through the native library under gc.DEBUG_SAVEALL, and counts the ctypes array types in the collected garbage. A fresh interpreter is needed because ctypes reuses a c_ubyte * n type that another live object still holds. On main it fails with assert '1' == '0'. With this change it passes.

GIL hold on large requests

The removed line also unpacked the payload into one Python int per byte and stored them one at a time, holding the GIL for the whole copy. A large ByteStreamWriter.write sends its payload in one request, so the copy blocked every other thread for the length of it. The cast passes the existing buffer, so the request holds the GIL only for the native call.

The reproduction script in #848 sends a real request through liblivekit_ffi (no server) while a second thread records its longest gap. Python 3.12, livekit==1.1.20, then the same install with this PR's _ffi_client.py:

Request size Longest gap before Longest gap after
1 MB 102.3 ms 0.6 ms
4 MB 397.3 ms 0.9 ms
16 MB 1585.7 ms 5.0 ms

The machine was under other load during this run, so the before values are about twice the ones in #848. The ratio is the same.

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Devin Review: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

zdurm added 2 commits October 3, 2026 18:55
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FfiClient.request holds the GIL for about 42 ms per MB of request (per-byte ctypes copy)

1 participant