An analysis of how ScyllaDB connected the Rust driver and C# through FFI to maintain low latency and an async model without unnecessary copies.
The challenge for ScyllaDB was not with a single language, but with the scale of support. Each language requires its own driver, and separate implementations quickly become a burdensome maintenance operation. The Driver Team sought a path to a One Driver To Rule Them All model: one Rust Driver as the core and thin language wrappers on top of it. In this context, C# became another test of how far unification could go without losing predictability.
The key challenge lay at the inter-language boundary. For Rust and C#, there were few available ready-made solutions, and the bindgen approaches they considered obscured some of the FFI complexity and could bury errors deep within the auto-generated glue code. An additional constraint was async compatibility: there were no ready-made tools for Rust <-> C# async interop. Therefore, the team chose a pragmatic path and built their own bridge based on the experience already gained from the CPP RS Driver and Node.js RS Driver.
The solution was built around the C ABI as a common language between runtimes. Rust exposes `extern “C”` functions, while C# calls them via P/Invoke, translating types into equivalent C forms. The reverse direction is more complex: Rust calls C#, and one cannot rely on a static function address as in a native library. For this, they used `UnmanagedCallersOnly` and passed function pointers as callbacks. This is Reverse P/Invoke, but with an important engineering caveat: the call crosses the managed and unmanaged world boundary, so each step must be predictable in terms of types and object lifetimes.
An additional layer of complexity was in the async model. Both Rust with tokio and modern C# use async/await, but their executors behave differently. In C#, a continuation may execute synchronously on the same thread, while tokio expects tasks not to block the executor thread. This is where the risk of starvation for tokio worker threads arose. The team closed this gap through `TaskCompletionSource` with the `RunContinuationsAsynchronously` flag, so continuations would go to the .NET thread pool rather than remain on the tokio worker thread. This is not a decorative setting, but a way to reconcile two different scheduling models.
Data crossing the boundary proved to be just as sensitive as function calls. Blittable types like numbers and raw pointers pass through almost cost-free, but strings, arrays, and complex structures require an explicit agreement on layout. For this, they used C ABI-compatible structures with `#[repr(C)]` in Rust and `StructLayout(LayoutKind.Sequential)` in C#. This way, both sides see the same memory layout and field order. For slices, they applied `FFISlice`, and for strings—wrappers over UTF-8 representations that can be converted to managed strings on the C# side.
A separate practical lesson the team learned was regarding `bool`. Formally, the type seems simple, but standard marshalling in .NET can expand it to 4 bytes, which breaks Rust’s expectations and leads to undefined behavior. Attempting to use `[MarshalAs(UnmanagedType.U1)]` partially helped, but broke on the callback scenario with `UnmanagedCallersOnly`. Ultimately, the team moved away from ambiguity altogether and replaced `bool` with `byte / u8`, wrapped in `FFIBool`. This is a compromise, but a reliable choice: less magic, fewer hidden rules, and reduced chances of mismatch between runtimes.
To ensure the FFI layer did not become a source of elusive errors, the team strengthened typing. They adapted ideas from `argconv.rs` in `ffi.rs` for the C# RS Driver and added strong typing for pointers and size matching checks for structures on the Rust side. This is especially important where opaque Rust data, opaque C# data, and managed GC are mixed. For resource management on the C# side, they chose `SafeHandle`. This allowed them to tie manual `Dispose()` and finalization during garbage collection into one model, without relying on fragile custom memory release schemes.
The outcome of this approach is not described through metrics in the source, so they are not included here. However, the architectural effect is clear: ScyllaDB gained a way to connect the Rust driver and C# driver through a more transparent FFI layer, preserving async behavior and reducing the risk of hidden errors at the runtime boundary. For systems where the cost of an error is not only latency but also memory, this is not a cosmetic improvement, but an engineering-calibrated stabilization of the interface between two worlds.