Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Async Functions

Overview

WeaveFFI exposes asynchronous Rust operations through a single callback-based C ABI and language-native async wrappers in every target. Mark a function with async: true (and optionally cancellable: true) in the IDL and the generators emit the right shape per target: async throws in Swift, suspend fun in Kotlin, Promise<T> in JS, async def in Python, Task<T> in .NET, and so on.

When to use

Use async functions for:

  • I/O-bound work (network, disk, database).
  • Long-running operations that should not block the consumer’s event loop (UI threads, JS event loop, asyncio loop).
  • Operations the consumer should be able to cancel (combine with cancellable: true).

Avoid async for:

  • Short CPU-bound work (math, parsing, validation). The callback overhead is more expensive than the call itself.
  • Functions whose Rust implementation is purely synchronous and finishes in microseconds.

Step-by-step

1. Declare the function in the IDL

version: "0.4.0"
modules:
  - name: net
    functions:
      - name: fetch_data
        params:
          - { name: url, type: string }
        return: string
        async: true
        doc: "Fetches data from the given URL"

      - name: upload_file
        params:
          - { name: path, type: string }
          - { name: data, type: bytes }
        return: bool
        async: true
        cancellable: true
        doc: "Uploads a file, can be cancelled"
FieldTypeDefaultDescription
asyncboolfalseMark the function as asynchronous
cancellableboolfalseAllow the async operation to be cancelled

2. Implement it in Rust

The generated C ABI symbol takes a callback pointer and an opaque void* context. The Rust worker invokes the callback exactly once when it is done. The pattern from samples/async-demo/src/lib.rs:

#![allow(unused)]
#![allow(unsafe_code)]
#![allow(non_camel_case_types)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]

fn main() {
use std::ffi::c_void;
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};

pub type weaveffi_net_fetch_data_callback =
    extern "C" fn(context: *mut c_void, err: *mut weaveffi_error, result: *const c_char);

#[no_mangle]
pub extern "C" fn weaveffi_net_fetch_data_async(
    url: *const c_char,
    callback: weaveffi_net_fetch_data_callback,
    context: *mut c_void,
) {
    let url_str = abi::c_ptr_to_string(url).unwrap_or_default();
    let ctx = context as usize;
    std::thread::spawn(move || {
        let payload = std::ffi::CString::new(format!("payload from {url_str}"))
            .unwrap()
            .into_raw();
        callback(ctx as *mut c_void, std::ptr::null_mut(), payload);
    });
}
}

The async launcher symbol always carries the _async suffix (weaveffi_net_fetch_data_async), keeping the name free for a possible synchronous variant.

3. Call it from each target

Swift:

let payload = try await Net.fetchData("https://example.com/data")

Kotlin/Android:

val payload = Net.fetchData("https://example.com/data")

Node.js:

const payload = await fetchData("https://example.com/data");

Python:

payload = await fetch_data("https://example.com/data")

.NET:

var payload = await Net.FetchDataAsync("https://example.com/data");

Dart:

final payload = await fetchData('https://example.com/data');

4. Cancel a running operation

For cancellable: true functions the C launcher gains a weaveffi_cancel_token* slot (before callback and context), and the weaveffi-abi runtime provides the token lifecycle:

weaveffi_cancel_token* token = weaveffi_cancel_token_create();
weaveffi_net_upload_file_async(path, data, data_len, token, on_done, ctx);
/* later, from any thread: */
weaveffi_cancel_token_cancel(token);

The Rust worker polls weaveffi_cancel_token_is_cancelled(token) and stops early, but the callback is always invoked exactly once: either with the result or with a Cancelled error. The pin/unpin pair (see Reference) runs on the cancellation path identically to the success path.

Today the C, C++, and Kotlin surfaces expose the token (C++ as a trailing cancel_token = nullptr parameter, Kotlin as a cancelToken: Long); the other wrappers pass NULL. The operation runs to completion even if the consumer-side future is abandoned.

Reference

C ABI shape

Each async function gets its own callback typedef of the form (context, err, <result slots>), and a launcher with the _async suffix:

typedef void (*weaveffi_net_fetch_data_callback)(
    void* context,
    weaveffi_error* err,
    const char* result);

void weaveffi_net_fetch_data_async(
    const char* url,
    weaveffi_net_fetch_data_callback callback,
    void* context);

For cancellable: true the launcher takes a token slot before the callback, and the runtime provides the token lifecycle:

void weaveffi_net_upload_file_async(
    const char* path,
    const uint8_t* data, size_t data_len,
    weaveffi_cancel_token* cancel_token,
    weaveffi_net_upload_file_callback callback,
    void* context);

weaveffi_cancel_token* weaveffi_cancel_token_create(void);
void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token);
bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token);
void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token);

Per-target async surface

TargetAsync surfaceCancel token exposure (cancellable: true)
CRaw callback + _async launcherweaveffi_cancel_token* slot before the callback
C++std::future<T>trailing cancel_token = nullptr parameter
Swiftasync throwsnot exposed; wrapper passes nil
Kotlinsuspend funcancelToken: Long parameter (raw token pointer)
Node.jsPromise<T> (thread-safe function settling)not exposed; wrapper passes NULL
Pythonasync def (executor thread + event)not exposed; wrapper passes None
.NETTask<T>not exposed; wrapper passes IntPtr.Zero
DartFuture<T> (NativeCallable.listener)not exposed; wrapper passes nullptr
WASMPromise<T> (table trampolines)not exposed; wrapper passes 0
Goblocking bridge (chan receive); call from a goroutinenot exposed; wrapper passes nil
Rubyblocking bridge (Queue#pop); call from a Threadnot exposed; wrapper passes NULL

A wrapper that does not expose the token still launches and completes the call correctly; the operation simply runs to completion even if the consumer abandons the future. Drop to the C surface when you need cooperative cancellation from one of those targets.

Pin / unpin matrix

Every binding pins the user-supplied void* context and the callback closure for the lifetime of the operation, then releases them exactly once on the callback path. The matrix below is the contract every generator implements; each row is verified by a {generator}_async_pins_callback_for_lifetime unit test plus the 1000-call stress test under examples/{target}/async_stress.{ext}.

TargetPin (allocate / retain)Unpin (free / release) on callbackNotes
SwiftUnmanaged.passRetained(ContinuationRef(...))Unmanaged.fromOpaque(ctx).takeRetainedValue()The retained +1 is dropped exactly once when the continuation resumes.
.NETGCHandle.Alloc(callback, GCHandleType.Normal)GCHandle.FromIntPtr(context).Free()The catch path also frees the handle on synchronous failure.
KotlinJNI (*env)->NewGlobalRef(env, callback)(*env)->DeleteGlobalRef(env, ctx->callback)The JNI shim mallocs and frees the per-call context exactly once.
Node.jsnapi_create_promise(env, &deferred, &promise)napi_resolve_deferred or napi_reject_deferredThe N-API runtime owns the deferred; the per-call context is malloc-ed and freed exactly once.
Python_cb = ctypes.CFUNCTYPE(...)(impl) (kept by helper)_ev.set() in the callback’s finally releases the helper’s _ev.wait()The helper blocks on the event so _cb (and its trampoline) outlive the callback.
C++new std::promise<T>() plus the lambda capturedelete p; once at the end of the lambdaThe lambda owns the heap promise on every exit branch.
DartNativeCallable<...>.listener(...)callable.close() in finally and on the catch pathPointer-typed parameters are kept alive in whenComplete.
WASM_registerTrampoline per signature plus _asyncContexts.set(ctxId, ...) per call_asyncContexts.delete(ctxId) in the trampolinePer-call resolver closures are removed after resolve/reject.
GowvCallbackStore(ch) registers the channel in a global registry keyed by an integer idwvCallbackTake(id) removes it when the exported trampoline firesThe context crossing C is an integer id, never a Go pointer (cgo rule); the channel is buffered so the producer thread never blocks.
Rubythe FFI::Function trampoline is a local kept alive by the enclosing method scopethe blocking queue.pop returns only after the callback ranThe wrapper blocks the calling Ruby thread, so the trampoline cannot be collected while the producer can still call it.

Audit invariants

For every async-capable target:

  1. The void* context has exactly one owner at any moment.
  2. The callback closure is pinned by an explicit “+1” allocation (GCHandle.Alloc, Unmanaged.passRetained, NewGlobalRef, NativeCallable.listener, …) before the C worker can see it, and released by the matching “-1” exactly once on the callback path.
  3. Synchronous failure of the C call (the callback never fires) is handled in a catch / try that frees the pin so it does not leak.
  4. The stress test asserts weaveffi_tasks_active_callbacks() returns to zero after 1000 concurrent calls.

Pitfalls

  • Async void functions: the validator emits a warning. They are valid but almost always indicate a missing return type.
  • Forgetting cancellable: true: without it, the launcher has no cancel-token slot and the operation cannot be cancelled at all.
  • Using async for CPU-bound work: the callback overhead exceeds the work being done; keep it synchronous.
  • Calling Go/Ruby async functions on a latency-sensitive thread: both wrappers block the calling thread until the producer completes. Wrap the call in a goroutine / Ruby Thread when you need concurrency; the native work already runs off-thread.
  • Letting the callback closure get garbage-collected: every generator pins it explicitly. Do not strip those pins when editing generated code by hand.
  • Returning null instead of invoking the callback: the contract is that the callback fires exactly once for every async call, including on cancellation.