Async¶
Async crosses the FFI in both directions:
- Rust future → Kotlin
suspend: Kotlin drives a Rust future by polling it. - Kotlin
suspend→ Rust future: an async callback method implemented in Kotlin is handed to Rust as a foreign future.
Both use kotlinx.coroutines. The helpers live in the runtime,
runtime/src/{jvmMain,androidMain,nativeMain}/kotlin/uniffi/runtime/Async.kt.
Rust future → suspend fun¶
An async scaffolding function doesn't return a value. It returns a future handle. Four more FFI functions per return type drive the future:
ffi_<crate>_rust_future_poll_<type>(future, continuation_callback, continuation_handle)
ffi_<crate>_rust_future_complete_<type>(future, status) -> value
ffi_<crate>_rust_future_cancel_<type>(future)
ffi_<crate>_rust_future_free_<type>(future)
The call_async macro in macros.kt generates a call to the runtime's uniffiRustCallAsync:
override suspend fun sayAfter(ms: UShort, who: String): String =
uniffiRustCallAsync(
UniffiLib.INSTANCE.uniffi_futures_fn_func_say_after(lower(ms), lower(who)), // the future
{ future, callback, continuation -> UniffiLib.INSTANCE.ffi_futures_rust_future_poll_rust_buffer(future, callback, continuation) },
{ future, status -> UniffiLib.INSTANCE.ffi_futures_rust_future_complete_rust_buffer(future, status) },
{ future -> UniffiLib.INSTANCE.ffi_futures_rust_future_free_rust_buffer(future) },
{ future -> UniffiLib.INSTANCE.ffi_futures_rust_future_cancel_rust_buffer(future) },
{ FfiConverterString.lift(it) },
UniffiNullRustCallStatusErrorHandler,
)
The four lambdas come from the async_poll, async_complete, async_free and async_cancel
filters in mod.rs. For a method on an object, the call that creates the future is wrapped in
callWithHandle. Once the future exists it holds its own reference to the object.
The poll loop¶
sequenceDiagram
participant K as uniffiRustCallAsync
participant M as uniffiContinuationHandleMap
participant R as Rust future
loop until poll result is READY
K->>M: insert(continuation) → handle
K->>R: poll(future, callback, handle)
Note over R: returns immediately,<br/>calls back when woken
R-->>M: callback(handle, pollResult)
M->>K: remove(handle).resume(pollResult)
end
K->>R: complete(future, status) → value
K->>K: check status, lift value
Note over K,R: finally: free(future)
- The loop runs inside
withContext(Dispatchers.IO), becausecompleteis a blocking call. - The continuation is passed to Rust as a handle in
uniffiContinuationHandleMap, not as a pointer. On Native the callback is astaticCFunction, which can't capture anything, so the continuation has to be found through a global map. - Poll results:
0is ready,1means "maybe ready", so poll again. continuation.invokeOnCancellation { cancel(future) }propagates coroutine cancellation to Rust.free(future)runs in afinally, after success, error and cancellation.
Kotlin suspend → Rust future¶
An async method of a callback or trait interface can't block the Rust thread that calls it. Its
vtable entry starts a coroutine and returns immediately. Rust passes in a completion callback, and
gets back a handle plus a "dropped" callback:
sequenceDiagram
participant R as Rust
participant V as vtable method
participant J as coroutine (GlobalScope)
R->>V: call(handle, args, futureCallback, callbackData, outDropped)
V->>J: launch makeCall()
V-->>R: outDropped = (job handle, droppedCallback)
J->>J: suspend implementation runs
J->>R: futureCallback(callbackData, result + status)
Note over R,J: if Rust drops its future first:<br/>droppedCallback(job handle) → job.cancel()
The runtime helpers are uniffiTraitInterfaceCallAsync and uniffiTraitInterfaceCallAsyncWithError.
Running jobs are kept in uniffiForeignFutureHandleMap.
GlobalScope is deliberate. The parent of the coroutine is a Rust future, so Kotlin's structured
concurrency can't express the relationship anyway. The dropped callback restores it: when Rust
drops the future, the coroutine is cancelled.
The result struct is ForeignFutureResult<Type> (value plus RustCallStatus). These structs and
the callback types are in FFI_BUILTINS, so they are declared once in common.h
(see Bindgen).
Things to keep in mind¶
- The coroutine imports (
suspendCancellableCoroutine,GlobalScope,Dispatchers, …) are added by eachTypes.kttemplate whenci.has_async_fns(). A new coroutine API used in generated code must be added there too. reject_async_borrowed_bytesrefuses&[u8]arguments on async callables. The borrow would end when the future handle is returned, while the future is still running.- For a return type from another crate,
async_completere-wraps the value into that crate'sRustBuffer<Name>ByValuealias (see External and remote types). tests/uniffi/futurescontains timing-based tests. The first Tokio-based call starts the Tokio runtime, which is slow, so the test class warms it up in@BeforeTest.