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).
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. :::
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.