Async

Rust async functions can be exported just like regular functions. Add async to the function signature and BoltFFI handles bridging the future to the target language’s async model. The generated bindings let callers await the result using native syntax, with no manual callback wiring or polling logic on either side. Cancellation propagates from the caller back to Rust, and errors work the same as synchronous functions.

Async function
RustSource
#[export]
pub async fn fetch_user(id: i32) -> User {
  db.query_user(id).await
}
// Generated
func fetchUser(id: Int32) async -> User

// Usage
let user = await fetchUser(id: 42)

How It Works

BoltFFI uses continuation-based polling to bridge Rust futures to target languages. When you call an async function, BoltFFI creates a handle to the future and returns it immediately. The target language’s async runtime then polls this handle, passing a continuation callback. When the Rust future makes progress or completes, the callback fires and the target language resumes execution. This approach is lock-free with no busy-waiting, and the target language drives the entire polling lifecycle.

See Async Internals for the full implementation details including the FFI protocol and sequence diagram.

Standalone Functions

Standalone async functions work like regular exported functions with the async keyword added. The function runs on the target language’s async context and returns when the Rust future completes. Parameters are captured when the function is called, and the result is delivered through the native async mechanism.

Async function
RustSource
#[export]
pub async fn load_config(path: &str) -> Config {
  read_file(path).await
}
// Generated
func loadConfig(path: String) async -> Config

// Usage
let config = await loadConfig(path: "settings.json")

Methods

Methods on classes can be async. The object reference is captured along with any parameters when the method is called. This lets you build clients and services that hold connections or state and expose async operations on them. Both &self and &mut self methods can be async.

Database client with async queries
RustSource
pub struct Database {
  connection: Connection,
}

#[export]
impl Database {
  pub fn connect(url: &str) -> Self {
      Database {
          connection: Connection::new(url)
      }
  }
  
  pub async fn query(&self, sql: &str) -> Vec<Row> {
      self.connection.execute(sql).await
  }
}
// Generated
public class Database {
  public static func connect(url: String) -> Database
  public func query(sql: String) async -> [Row]
}

// Usage
let db = Database.connect(url: "postgres://...")
let rows = await db.query(sql: "SELECT * FROM users")

Error Handling

Async functions can return Result just like synchronous functions. The error is delivered through the target language’s native error handling mechanism when the async operation completes. The same error types and conversion rules apply as described in Errors.

Async function that can fail
RustSource
#[export]
pub async fn fetch_user(id: i32) -> Result<User, DbError> {
  db.query_user(id).await
}
// Generated
func fetchUser(id: Int32) async throws -> User

// Usage
do {
  let user = try await fetchUser(id: 42)
} catch {
  print("Failed: \(error)")
}

Cancellation

Cancellation in the target language propagates back to Rust. When a caller cancels an async operation, the future is marked as cancelled, and the next poll returns immediately without running more of the future’s code. This is cooperative cancellation, not preemption. The future is not forcibly aborted mid-execution, so any cleanup code after an await point may not run if cancellation happens before reaching it.

Cancelling a long-running operation
RustSource
#[export]
pub async fn sync_all() -> SyncResult {
  // sync work
}
// Generated
func syncAll() async -> SyncResult

// Usage
let task = Task {
  await syncAll()
}
// later
task.cancel()

Runtime

BoltFFI wraps your future and lets the target language drive polling. BoltFFI itself does not require a Rust async runtime. The polling mechanism is built into the generated bindings and works without tokio, async-std, or any other executor.

However, if your async code uses libraries that depend on a runtime, that runtime must be available. Pure computation and channel-based async works without any runtime. Libraries like reqwest or tokio::fs require tokio’s reactor to be running because they rely on it for I/O. If you use such libraries, you need to ensure a tokio runtime is active when your async functions execute.