Expand description
FFI support for future_form: host-driven polling, opaque handles,
and effect slots.
This crate provides the building blocks for FFI bridges that let foreign hosts (Go, Java, Python, C, Swift) drive Rust async state machines without an async runtime.
§Core types
| Type | Purpose |
|---|---|
PollOnce | Extension trait: poll a boxed future once with a no-op waker |
HostHandle | Thin-pointer wrapper for passing boxed futures through C ABI |
AtomicSlot | Lock-free, thread-safe slot for a single value |
EffectSlot | Shared-state channel for the effect protocol (built on AtomicSlot) |
EffectHandle | Future handle + stashed effect + context pointer |
§Architecture
┌────────────┐ ┌──────────────────────┐
│ FFI Host │ poll_once() │ HostHandle<F> │
│ (Go, Java │ ──────────────────>│ or EffectHandle │
│ Python…) │ <──────────────────│ │
│ │ Poll<T> │ │
│ │ │ ┌────────────────┐ │
│ │ take_effect() │ │ EffectSlot │ │
│ │ ──────────────────>│ │ <E, R> │ │
│ │ <──────────────────│ └────────────────┘ │
│ │ fulfill(resp) │ │
│ │ ──────────────────>│ │
└────────────┘ └──────────────────────┘§FFI bridge pattern
FFI hosts can’t receive a Rust Box<T> directly — they need a raw
pointer. The standard pattern is:
- Create: heap-allocate the handle and convert to a raw pointer
with
Box::into_raw, transferring ownership to the host. - Poll/inspect: the host passes the pointer back into Rust
bridge functions that reconstruct a reference (
&mut *ptr). - Free: the host calls a destructor that reclaims ownership
with
Box::from_rawand drops the handle.
// 1. Create — ownership moves to the host
let handle = HostHandle::new(fut);
let ptr = Box::into_raw(Box::new(handle)); // thin *mut for C ABI
// 2. Poll — borrow from the raw pointer
let handle = unsafe { &mut *ptr };
let result = handle.poll_once();
// 3. Free — reclaim and drop
unsafe { drop(Box::from_raw(ptr)) };EffectHandle follows the same
pattern, adding a stashed effect and context pointer for the effect
protocol.
§Example
Simple bridge using HostHandle:
use core::task::Poll;
use future_form::{FutureForm, Sendable};
use future_form_ffi::host_handle::HostHandle;
let fut = Sendable::from_future(async { 42u64 });
let mut handle = HostHandle::new(fut);
assert_eq!(handle.poll_once(), Poll::Ready(42));Effect protocol using EffectSlot:
use core::task::Poll;
use future_form_ffi::effect_slot::EffectSlot;
#[derive(Clone, Debug, PartialEq)]
enum Fx { GetTime }
#[derive(Debug, PartialEq)]
enum Resp { Time(u64) }
let slot: EffectSlot<Fx, Resp> = EffectSlot::new();
assert_eq!(slot.request(&Fx::GetTime), Poll::Pending);
slot.fulfill(Resp::Time(1234));
assert_eq!(slot.request(&Fx::GetTime), Poll::Ready(Resp::Time(1234)));§Slot ownership
An EffectSlot is a single
request-response channel. If multiple futures share the same slot,
they will overwrite each other’s pending effects and receive
responses intended for other futures. This is memory-safe (all
operations are atomic) but semantically incorrect.
Two ownership strategies are supported:
§Per-struct slot (simple, one future at a time)
Embed the slot in the struct. All futures share it. Only one future may be in flight at a time:
struct MyService {
state: u64,
effects: EffectSlot<Effect, Response>,
}§Per-future Arc<EffectSlot> (unlimited concurrent futures)
Create a fresh slot for each future. Share it via Arc between the
async closure and the EffectHandle.
There is no limit on the number of in-flight futures — each one gets
its own independent effect channel:
use alloc::sync::Arc;
let slot = Arc::new(EffectSlot::new());
let slot_for_future = Arc::clone(&slot);
let fut = Box::pin(async move {
let resp = core::future::poll_fn(|_cx| {
slot_for_future.request(&Effect::GetTimestamp)
}).await;
// ...
});
let handle = EffectHandle::new(HostHandle::new(fut), context);
// The bridge stores `slot` alongside the handle (e.g., in a wrapper struct)
// so the host can call slot.take_effect() / slot.fulfill().The per-future approach has no capacity limit — the host can create
as many concurrent futures as memory allows, each with its own
effect channel. The cost is one Arc allocation per future,
negligible alongside the Box::pin that already heap-allocates the
future. The key_value_store example demonstrates this pattern
with concurrent Get/Put/Delete/List operations. See the design docs
for a full comparison of ownership strategies including slot
registries and fixed-size arrays.
§Handle storage
Rust does not assign or manage future IDs. The
EffectHandle is the identity —
an opaque token bundling the future, its slot, and its context. The
host stores these however its concurrency model demands:
§Host-side collection
The host keeps a map of handles, keyed however it likes:
Host (Go / Java / Python)
┌─────────────────────────────────────────────┐
│ │
│ handles: Map<FutureId, *mut EffectHandle> │
│ │
│ handle_1 ──► EffectHandle ──► Arc<slot_1> │
│ handle_2 ──► EffectHandle ──► Arc<slot_2> │
│ handle_3 ──► EffectHandle ──► Arc<slot_3> │
│ │
└─────────────────────────────────────────────┘The FutureId is whatever the host wants — an integer counter, a
UUID, a task name. It is entirely host-side bookkeeping. Rust does
not know or care about it.
§Inline in host tasks
Each host-side task/goroutine/thread holds its handle directly — no map needed:
Go goroutine A Go goroutine B
┌──────────────────┐ ┌──────────────────┐
│ handle *C.Handle │ │ handle *C.Handle │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
EffectHandle EffectHandle
└─► Arc<slot_a> └─► Arc<slot_b>No future ID at all — the handle is the identity.
§Event loop with a vec
Host event loop
┌──────────────────────────────┐
│ active: Vec<EffectHandle> │
│ │
│ for handle in &mut active { │
│ match handle.poll() { │
│ Pending => check fx │
│ Ready => remove │
│ } │
│ } │
└──────────────────────────────┘The index in the vec is the implicit ID, or the host uses a slab
allocator. When a future completes, the host removes it — the
handle drops, its Arc<EffectSlot> drops, and the slot is freed.
All three patterns work because EffectHandle is self-contained:
the host never needs to coordinate with Rust about which handle
maps to which future. See the design docs for more detail.
§no_std support
This crate is #![no_std] and requires only core + alloc. No
feature flags, no platform-specific dependencies.
EffectSlot is built on
AtomicSlot, which uses lock-free
AtomicPtr swaps for thread
safety — no mutex, no poisoning, no global lock contention.
Modules§
- atomic_
slot - A lock-free, thread-safe slot that holds at most one value.
- effect_
handle - FFI handle that pairs a future with a stashed effect snapshot.
- effect_
slot - Shared effect channel between async state machines and FFI bridges.
- host_
handle - Opaque thin-pointer handle for passing boxed futures through C ABI.
- poll_
once - The
PollOnceextension trait for host-driven polling.