Skip to content

Free RustCallStatus pointer after every Rust call#38

Merged
1egoman merged 1 commit into
livekit:mainfrom
LautaroPetaccio:fix/free-call-status-pointer
May 12, 2026
Merged

Free RustCallStatus pointer after every Rust call#38
1egoman merged 1 commit into
livekit:mainfrom
LautaroPetaccio:fix/free-call-status-pointer

Conversation

@LautaroPetaccio
Copy link
Copy Markdown
Contributor

Summary

Every synchronous call into Rust through makeRustCall leaks the call-status struct that gets passed in. Wrap the call site in try/finally and reclaim the pointer with freePointer.

Why this is a leak

createCallStatus calls ffi-rs's createPointer({ paramsType: [DataType_UniffiRustCallStatus], ... }). DataType_UniffiRustCallStatus does not set ffiTypeTag: DataType.StackStruct (only the inner error_buf field does), so ffi-rs takes the heap-allocation branch in get_value_pointer:

RsArgsValue::Object(val) => {
  if is_stack_struct {
    generate_c_struct(...)
  } else {
    Box::into_raw(Box::new(generate_c_struct(...))) as *mut c_void
  }
}

That produces two heap allocations: the struct itself (via generate_c_struct) and a wrapper Box<*mut c_void>. Neither is owned by JS. From the ffi-rs README: "JsExternal objects do NOT automatically free memory upon garbage collection. Use freePointer to free the pointer when it is no longer in use." restorePointer is read-only, and unwrapPointer only produces a new external around the inner pointer — neither reclaims the original allocation.

Net effect: every sync FFI call leaks roughly the size of UniffiRustCallStatus plus a pointer-sized wrapper. Method calls leak it twice (once for clonePointer, once for the actual call). Constructors, uniffiDestroy, and the FinalizationRegistry-driven free path each leak it once. For long-running or high-throughput consumers this is unbounded growth.

How the fix works

makeRustCall now wraps the call site in try/finally and calls freePointer on $callStatus in the finally block, using the same DataType_UniffiRustCallStatus paramsType and PointerType.RsPointer so ffi-rs takes the matching free path (free_rs_pointer_memory Object branch: dealloc the struct memory and reclaim the wrapper). The finally ensures the pointer is freed whether the Rust call returned normally, the call status indicated an error and uniffiCheckCallStatus threw, or any other exception propagated through the body.

The call-status pointer allocated by createPointer in makeRustCall was
never released. ffi-rs's createPointer Box-allocates non-StackStruct
values via Box::into_raw(Box::new(generate_c_struct(...))), and the
JsExternal wrapper does not free that memory on garbage collection per
ffi-rs's documented contract. Neither restorePointer nor unwrapPointer
free anything either. Result: every synchronous Rust call leaked the
RustCallStatus struct plus its wrapper Box, and method calls leaked
twice (once for clonePointer, once for the call itself).

Wrap the call site in try/finally and call freePointer with the same
struct paramsType so the allocation is reclaimed regardless of whether
the call returned normally or threw.
Copy link
Copy Markdown
Collaborator

@1egoman 1egoman left a comment

Choose a reason for hiding this comment

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

Makes sense, thanks!

@1egoman 1egoman merged commit 6cb357e into livekit:main May 12, 2026
3 checks passed
@1egoman 1egoman mentioned this pull request May 12, 2026
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.

2 participants