FFI Memory Contract
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).
Fail-fast panics at the FFI boundary
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.
CVec lifecycle
| 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. |
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.
Typed CVec wrappers and Send
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>.
Capsules created on the Python side
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.
Capsules created on the Rust side (PyO3 bindings)
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 capsules with explicit drop
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:
- Wrap the raw
CVecin a type-specific capsule payload, such asDataFfiCVec. - Mark that wrapper
#[repr(transparent)]overCVec, or use#[repr(C)]withCVecas the first field, before casting capsule pointers back to*mut CVec. - Give the capsule a stable, explicit name, such as
nautilus.DataFFI.CVec. Do not use the default unnamed capsule for this pattern. - Require all consumers to check the same capsule name before reading the pointer.
- Expose one type-specific drop function, such as
drop_cvec_pycapsule. - Call that drop function only for capsules created as
CVecbatches. Never pass a single-value capsule, such as one created bydata_to_pycapsule, to aCVecdrop function. - Validate
len <= cap, reject null non-empty pointers, and handle emptyCVecvalues. - Reset the stored
CVecmetadata toCVec::empty()before callingVec::from_raw_parts, so cleanup paths can call the drop function more than once without double-freeing. - Add tests for wrong capsule names, invalid metadata, empty capsules, and repeated drops.
Why there is no generic cvec_drop anymore
Earlier 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.
Box-backed *_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*_dropexported next to it. -
Validate parameters before heap allocation to fail fast and avoid allocating invalid objects.
-
The Python/Cython binding must guarantee that
*_dropis invoked exactly once. Two approaches exist:• Preferred for new code: Wrap the pointer in a
PyCapsulecreated withPyCapsule::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.