docs/developer_guide/ffi.md
NautilusTrader exposes several C-compatible types so that compiled Rust code can be
consumed from C-extensions generated by Cython or by other native languages. The most
important of these is CVec - a thin wrapper around a Rust Vec<T> that is passed across
the FFI boundary by value.
The rules below are strict; violating them results in undefined behaviour (usually a double-free or a memory leak).
CVec represents unique ownership when it comes from Vec<T>. It is intentionally neither
Copy nor Clone in Rust. Moving it into a consuming constructor or typed drop helper transfers
that ownership, and the same value must not be reused. C can still copy the repr(C) fields, so
foreign callers must enforce the same exactly-once rule themselves.
Rust panics must never unwind across extern "C" functions. Unwinding into C or Python is
undefined behaviour and can corrupt the foreign stack or leave partially-dropped resources
behind. To enforce the fail-fast architecture we wrap every exported symbol in
crate::ffi::abort_on_panic, which executes the body and calls process::abort() if a panic
occurs. The panic message is still logged before the abort, so debugging output is preserved
while avoiding undefined behaviour.
When adding new FFI functions, call abort_on_panic(|| { … }) around the implementation (or
use a helper that does so) to maintain this guarantee.
| Step | Owner | Action |
|---|---|---|
| 1 | Rust | Build a Vec<T> and convert it with into() - this leaks the vector and transfers ownership of the raw allocation to foreign code. |
| 2 | Foreign (Python / Cython / C) | Use the data while the CVec value is in scope. Do not modify the fields ptr, len, cap. |
| 3 | Foreign | Exactly once, call the type‑specific drop helper exported by Rust (for example vec_drop_book_levels, vec_drop_book_orders, vec_time_event_handlers_drop). The helper reconstructs the original Vec<T> with Vec::from_raw_parts and lets it drop, freeing the memory. |
:::warning If step 3 is forgotten the allocation is leaked for the remainder of the process; if it is performed twice the program will double-free and likely crash. :::
Empty CVec values have len == 0 and cap == 0. Their pointer is an opaque sentinel and must
never be dereferenced or passed to Vec::from_raw_parts. Rust consumers must use
CVec::into_vec, which returns Vec::new() before inspecting the pointer. Borrowing consumers
must use CVec::as_slice, which returns &[] before inspecting the pointer when len == 0.
Both methods are unsafe because public FFI metadata cannot prove allocation provenance,
alignment, initialization, or exclusive ownership. Rust functions that accept a CVec and call
either primitive must also be unsafe extern "C" fn and include a # Safety contract. Rust
callers must use an explicit unsafe block even though this does not change the generated C ABI.
Some constructors borrow buffers allocated outside Rust, including Python buffers created with
PyMem_Malloc. These allocations must never be reconstructed as Vec<T> because Rust's global
allocator did not create them. Borrow them with CVec::as_slice and clone with to_vec() when
the Rust object needs owned storage. The caller retains ownership and remains responsible for
freeing the original buffer with its matching allocator.
CVec is untyped ownership metadata. Do not implement Send for the raw CVec type: it
can represent a Vec<T> for any T, including non-Send element types. When PyO3 requires
Send for a capsule payload, introduce a narrow wrapper for the concrete payload type and
put the unsafe impl Send on that wrapper only after documenting the payload invariant.
For example, DataFFI streaming capsules use DataFfiCVec, a transparent wrapper around
CVec whose allocation always comes from Vec<DataFFI>.
Several Cython routines allocate temporary C buffers with PyMem_Malloc, wrap them into a
CVec, and return the address inside a PyCapsule. Every such capsule is created with a
destructor (capsule_destructor or capsule_destructor_deltas) that frees both the buffer
and the CVec. Callers must therefore not free the memory manually - doing so would double
free.
When Rust code pushes a heap-allocated value into Python and Python becomes the final owner,
it must use PyCapsule::new_with_destructor so that Python knows how to free the
allocation once the capsule becomes unreachable. The closure/destructor is responsible for
reconstructing the original Box<T> or Vec<T> and letting it drop.
use pyo3::types::PyCapsule;
Python::attach(|py| {
// Allocate the value on the heap
let my_data = Box::new(MyStruct::new());
let ptr = Box::into_raw(my_data);
// Move it into the capsule and register a destructor that frees the memory
let capsule = PyCapsule::new_with_destructor(
py,
ptr,
None,
|ptr, _| {
// Reconstruct the Box and let it drop, freeing the allocation
let _ = unsafe { Box::from_raw(ptr) };
},
)
.expect("capsule creation failed");
// ... pass `capsule` back to Python ...
});
Do not use PyCapsule::new(…, None); that variant registers no destructor
and will leak memory unless the recipient manually extracts and frees the pointer.
Rust-owned CVec batch capsules are an explicit exception to the destructor-owned pattern
above. Use this pattern only when the Python/Cython consumer must first extract the batch
into Python objects and then release the Rust allocation explicitly.
Requirements for this pattern:
CVec in a type-specific capsule payload, such as DataFfiCVec.#[repr(transparent)] over CVec, or use #[repr(C)] with
CVec as the first field, before casting capsule pointers back to *mut CVec.nautilus.DataFFI.CVec.
Do not use the default unnamed capsule for this pattern.drop_cvec_pycapsule.CVec batches. Never pass a
single-value capsule, such as one created by data_to_pycapsule, to a CVec drop
function.len <= cap, reject null non-empty pointers, and handle empty CVec values.CVec metadata to CVec::empty() before calling Vec::from_raw_parts,
so cleanup paths can call the drop function more than once without double-freeing.cvec_drop anymoreEarlier versions of the codebase shipped a generic cvec_drop function that always treated the
buffer as Vec<u8>. Using it with any other element type causes a size-mismatch during
deallocation and corrupts the allocator's bookkeeping. Because the helper was not referenced
anywhere inside the project it has been removed to avoid accidental misuse.
Instead, use the type-specific drop helper for your element type (e.g., vec_drop_book_levels,
vec_drop_book_orders). If no helper exists for your type, add one following the pattern in
crates/core/src/ffi/cvec.rs.
*_API wrappers (owned Rust objects)When the Rust core needs to hand a complex value (for example an
OrderBook, SyntheticInstrument, or TimeEventAccumulator) to foreign
code it allocates the value on the heap with Box::new and returns a
small repr(C) wrapper whose only field is that Box.
#[repr(C)]
pub struct OrderBook_API(Box<OrderBook>);
#[unsafe(no_mangle)]
pub extern "C" fn orderbook_new(id: InstrumentId, book_type: BookType) -> OrderBook_API {
OrderBook_API(Box::new(OrderBook::new(id, book_type)))
}
#[unsafe(no_mangle)]
pub extern "C" fn orderbook_drop(book: OrderBook_API) {
drop(book); // frees the heap allocation
}
Memory-safety requirements are therefore:
Every constructor (*_new) must have a matching *_drop exported
next to it.
Validate parameters before heap allocation to fail fast and avoid allocating invalid objects.
The Python/Cython binding must guarantee that *_drop is invoked
exactly once. Two approaches exist:
• Preferred for new code: Wrap the pointer in a PyCapsule created with
PyCapsule::new_with_destructor, passing a destructor that calls
the drop helper.
• Legacy pattern (v1 Cython modules only): Call the helper explicitly in
__del__/__dealloc__ on the Python side:
cdef class OrderBook:
cdef OrderBook_API _mem
def __cinit__(self, ...):
self._mem = orderbook_new(...)
def __del__(self):
if self._mem._0 != NULL:
orderbook_drop(self._mem)
Whichever style is used, remember: forgetting the drop call leaks the entire structure, while calling it twice will double-free and crash.
New FFI code must use PyCapsule with destructors and follow this template before it can be merged.