Skip to content

Free wrapper Box created by wrapPointer in toUint8Array#40

Open
LautaroPetaccio wants to merge 1 commit intolivekit:mainfrom
LautaroPetaccio:fix/free-wrap-pointer-in-to-uint8-array
Open

Free wrapper Box created by wrapPointer in toUint8Array#40
LautaroPetaccio wants to merge 1 commit intolivekit:mainfrom
LautaroPetaccio:fix/free-wrap-pointer-in-to-uint8-array

Conversation

@LautaroPetaccio
Copy link
Copy Markdown

Summary

UniffiRustBufferValue.toUint8Array allocates a wrapper Box via wrapPointer and never reclaims it. Free it in a finally block.

Why this is a leak

toUint8Array does:

const [contents] = restorePointer({
  retType: [arrayConstructor({ type: DataType.U8Array, length: ... })],
  paramsValue: wrapPointer([this.struct.data]),
});

ffi-rs's wrap_pointer heap-allocates a fresh Box around the inner pointer:

env.create_external(
  Box::into_raw(Box::new(ptr)),
  Some(std::mem::size_of::<*mut c_void>() as i64),
)

Per ffi-rs's documented contract — "JsExternal objects do NOT automatically free memory upon garbage collection. Use freePointer to free the pointer when it is no longer in use." — that wrapper Box is only reclaimed when freePointer is called on the resulting external. restorePointer is documented (and implemented) as a read-only conversion. Nothing here calls freePointer, so the wrapper Box is leaked on every invocation.

toUint8Array is on the hot path for error handling: uniffiCheckCallStatus calls consumeIntoUint8Array (which calls toUint8Array) for every CALL_ERROR and CALL_UNEXPECTED_ERROR response with a non-null error_buf. It's also reachable directly from user code lifting buffer-shaped return values. Each call leaks one pointer-sized Box.

How the fix works

Capture the wrapPointer result in a local (wrapped) so it can be referenced in both the try and finally halves, and lift Number(this.struct.len) into length so the arrayConstructor shape is byte-identical between the restorePointer and the matching freePointer. After restorePointer reads the bytes out, freePointer({ paramsType: [arrayConstructor({ type: DataType.U8Array, length })], paramsValue: wrapped, pointerType: PointerType.RsPointer }) runs unconditionally.

ffi-rs's free path for a U8Array (free_rs_pointer_memoryRefDataType::U8Array) is Box::from_raw(ptr as *mut *mut u8), which only reclaims the wrapper Box. The underlying buffer the wrapper points at is owned by the Rust side and is released separately through rustbuffer_free when destroy() runs — so it stays intact.

UniffiRustBufferValue.toUint8Array calls wrapPointer to hand the buffer
data to restorePointer. ffi-rs's wrapPointer heap-allocates a Box that
wraps the inner pointer:

  env.create_external(
    Box::into_raw(Box::new(ptr)),
    Some(std::mem::size_of::<*mut c_void>() as i64),
  )

Per ffi-rs's documented contract, the JsExternal does not free its
underlying memory on garbage collection - the wrapper Box only goes
away if freePointer is called on it. Nothing here calls freePointer,
so every read of a Rust buffer through toUint8Array (and therefore
every consumeIntoUint8Array, which is hit on every error response in
uniffiCheckCallStatus) leaks a pointer-sized Box.

Capture the wrapPointer result in a local, pass it into restorePointer,
and reclaim it in a finally block. ffi-rs's free path for a U8Array is
just Box::from_raw on the wrapper, so the underlying buffer the wrapper
points at - which is owned by the Rust side and freed separately via
rustbuffer_free - is left intact.
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.

1 participant