xabi
xabi generates a stable native ABI from Rust traits.
The intended use case is a host application that wants to load third-party Rust implementations from a dynamic library without asking users to hand-write C ABI vtables, exported symbols, panic guards, async polling glue, or host-side handles.
xabi is not a plugin framework. A plugin registry, package format, discovery
protocol, permission model, or product-specific lifecycle belongs in the host
project. xabi only owns the contract boundary.
What It Generates
Given a Rust trait:
pub const TRAIT_ID: &str = "dev.example.index";
pub const ABI_VERSION: u32 = 1;
xabi generates:
- a versioned C-compatible vtable,
- export thunks for sync and async methods,
- panic guards that convert unwinds to ABI status codes,
- host-side owned and borrowed handles,
xabi_manifestintegration for dynamic modules,- typed error payload encoding,
- composable optional payload encoding through
Option<T>whereT: XabiType, - exact-version wire layouts for
#[xabi::data]values.
Generated ABI artifacts use an explicit XabiV1 prefix, for example:
XabiV1AbiTraitIndexPlugin
XabiV1VtableTraitIndexPlugin
XabiV1HandleTraitIndexPlugin
XabiV1BorrowedTraitIndexPlugin
XabiV1OwnedTraitIndexPlugin
These names are ABI artifacts. Domain crates should usually re-export only the handles or helper APIs they want users to see.
Export A Module
An implementation crate exports one or more implementations with
#[xabi::module]:
;
The exported crate is built as a cdylib. The module macro emits the manifest
symbol that hosts load. The implementation version is stored as the export
version; the trait ABI version is stored separately and checked before the
generated host calls the export constructor.
Load From A Host
The host loads a trusted dynamic library and asks the generated handle to find a matching export:
let module = unsafe ;
let plugin = xabi_load?;
let name = plugin.name?;
let bytes = plugin.train.await?;
Loading native code is unsafe. The host must trust the library and must define the higher-level registration policy. Once a module is loaded, generated handle loaders validate the xabi manifest, trait id, contract version, and generated ABI prefixes before returning safe Rust handles.
Data, Handles, And Returns
Use #[xabi::data] for values that cross the ABI by value or as typed error
payloads:
Each field is lowered through its own XabiType::Wire, so nested xabi data,
strings, owned bytes, callback refs, and opaque handles follow one recursive
rule.
Generated data layouts are exact-version contracts. A consumer rejects both
shorter and larger data wires and owned payloads. Appending even a scalar field
therefore requires incrementing the version of every #[xabi::xabi] trait that
uses the data type and updating that contract's layout snapshots. Older and
newer contract versions must not exchange the data value: an older consumer
cannot safely destroy an unknown tail field that owns a string, byte buffer, or
handle.
Use XabiOwnedBytesOwner when a contract needs to retain and read one
producer-owned contiguous byte buffer without copying it at the boundary:
let bytes = reader.read.await?;
consume;
let copied: = bytes.into_vec;
The generated path lowers this value to the raw XabiOwnedBytes wire
descriptor, validates and adopts it, and calls the producer's free callback
exactly once when the non-Copy owner is dropped. Vec<u8> remains available
when an explicitly Rust-owned copy is preferable. A generated module handle
also keeps the producer library loaded until the owner is dropped. Re-encoding
such a retained foreign owner across another ABI boundary makes a defensive
copy because the fixed raw descriptor has no field for module-lifetime context.
Segmented buffers and stream protocols remain domain contracts outside xabi.
u128 and i128 are supported with their native Rust representations. Host
and module must target the same platform and use ABI-compatible Rust toolchains;
xabi carries 128-bit integer arguments behind pointers and returns them through
owned payloads.
Use #[xabi::opaque] for non-null pointer handles owned by another standard or
domain:
Trait object returns are represented as impl Trait:
The exporter turns the concrete Rust value into the returned trait's vtable. The host decodes it into the generated handle while preserving the dynamic module lifetime.
Borrowed callback traits use the same mechanism. A host can export a local
callback as XabiV1OwnedTrait*, pass xabi_borrow() to the plugin, and the
plugin calls the generated borrowed handle. The borrowed type carries the
owner lifetime as XabiV1BorrowedTrait*<'a>, so a synchronous result or async
future cannot retain it after the owner is dropped. Implementations should use
XabiV1BorrowedTrait*<'_> in method signatures.
Borrowed callbacks can also be grouped with other call inputs without erasing that lifetime:
#[xabi::data] accepts lifetime parameters for this borrowed-input shape. Its
wire struct remains lifetime-free and pointer-based; decoding the raw wire is
unsafe, while the generated safe call path keeps the owner borrowed through
completion or cancellation.
Generated owned trait handles can also cross a method boundary by ownership. This is the contract shape used by layers and decorators that must retain an inner service after the call returns:
The safe generated method consumes inner. XabiV1OwnedRefTrait* is only the
single-use wire representation: generated caller glue guards it until the
export thunk claims it, and generated export glue immediately places a claimed
vtable under the XabiV1OwnedTrait* RAII owner. Contract authors do not copy or
adopt the raw owned-ref token themselves.
ABI Stability Model
Extensible ABI descriptors and generated data wire structs start with:
size: usize,
abi_version: u32,
Hosts validate the required prefix of extensible descriptors, and generated handles do not read descriptor fields beyond the reported size. Vtable methods live after the stable release prefix, so a shorter vtable reports an ABI mismatch instead of reading unavailable tail fields. Additive descriptor fields are appended to the tail. Breaking descriptor changes require a new ABI version.
#[xabi::data] wires use the same leading metadata for validation but are not
prefix-extensible. Direct wire decoding and owned payload decoding both require
the exact generated size. Any field change requires a new contract version for
every trait that references the data type; the version mismatch is rejected
before a method can transfer arguments or results with incompatible ownership.
Small primitive wire carriers such as XabiStr, XabiSlice, XabiBytes, the
raw XabiOwnedBytes descriptor, and XabiResult have fixed layouts. The safe
XabiOwnedBytesOwner is a Rust RAII wrapper whose wire representation is
XabiOwnedBytes; it has no separate ABI layout. Extending the carrier field
sets is a breaking runtime ABI change.
The contract identity is:
- a stable trait
id, - a contract ABI version carried in
XabiExport::contract_version, - the generated vtable and wire layouts.
The Rust trait name is not the runtime identity. It is used to generate Rust API artifacts.
The repository checks fixture layouts through xabi-assert tests:
When an ABI change is intentional, update snapshots with:
XABI_UPDATE=1
Review prefix-layout snapshots with the append-only rule and generated data snapshots with the exact-layout rule.
Provider crates that declare xabi contracts can assert their generated contract
layout in tests with xabi-assert:
[]
= "0.1.2"
The assertion reads snapshots from
xabi/snapshots/<contract-id>/<target>.txt by default. Create or
intentionally update the snapshot with:
XABI_UPDATE=1
Examples
examples/async-plugin: minimal async trait export and dynamic loading.examples/scalar-index: a richer fixture with nested data, callbacks, an opaque Arrow stream handle, an object return, provider-side registration, and a Python package wrapper that registers the native library back into the provider.examples/access-like: an OpenDALAccess-shaped fixture with all accessor operations, returned reader/writer/lister/deleter/copier handles, provider-side registration, and a Python package wrapper for the native plugin.
Run the main end-to-end fixture:
Run all workspace tests:
How xabi Differs From Existing Choices
xabi sits in a narrow space: native, in-process, Rust-authored contracts with
explicit ABI stability.
| Existing choice | Difference |
|---|---|
Hand-written C ABI with libloading |
xabi still uses native dynamic loading, but users write Rust traits and data structs. Vtables, manifests, error payloads, async polling, panic guards, and host handles are generated. |
| Rust stable-ABI libraries | xabi does not try to make Rust's general ABI stable. It generates a small C-compatible ABI per contract and keeps the public model close to Rust trait definitions. |
| Wasm Component Model | Wasm gives portability, sandboxing, and a language-neutral component boundary. xabi chooses trusted native dynamic libraries and direct FFI for hosts that need the Rust implementation to run in-process. |
| Protobuf, gRPC, Arrow Flight, or IPC | Those are serialization and transport boundaries. xabi is an in-process ABI boundary; it avoids a service process and leaves persistence or network transport to the host. |
| PyO3, N-API, JNI, or other language bindings | Those expose Rust to a specific language runtime. xabi defines the native host/plugin contract; a PyO3 package can be used only as distribution glue for the native library. |
| A full plugin framework | xabi intentionally does not define discovery, registries, configuration, permissions, or lifecycle policy. The host project owns those product decisions. |
The tradeoff is intentional. xabi provides less runtime infrastructure than a
component system and less language reach than an RPC protocol, but it gives a
small, auditable ABI for Rust hosts that want dynamic native extension points.
Compared With abi_stable And stabby
Community crates such as abi_stable and
stabby solve an adjacent problem: they provide
reusable ABI-stable Rust-like types and trait-object machinery. xabi takes a
different axis: contract-first generation from host-owned Rust traits.
| Project | Primary model | Where xabi differs |
|---|---|---|
abi_stable |
Interface, implementation, and user crates built around StableAbi, #[sabi_trait], prefix types, runtime layout checks, and ffi-safe standard-library replacements. |
abi_stable is a good fit when a project wants to adopt its module model and ABI-safe type ecosystem. xabi keeps the public contract as ordinary Rust traits and data structs, then generates the contract-specific vtable, manifest export, panic guards, typed error path, async polling glue, and host handles. |
stabby |
ABI as a library: IStable, #[stabby::stabby], repr(stabby), stable dyn pointers, closures, futures, and compact ABI-stable representations for options, results, strings, vectors, and enums. |
stabby is a good fit when preserving a rich Rust-like ABI type universe and compact layout rules is the main goal. xabi intentionally avoids making a general Rust data-layout scheme the user-facing API; it lowers only the selected #[xabi::xabi] and #[xabi::data] boundary surface and snapshots those generated layouts per contract. |
The practical distinction is ownership of the abstraction. With abi_stable or
stabby, the domain API is usually shaped by the chosen stable-ABI type system.
With xabi, the host crate owns the domain trait, and the generated ABI is an
implementation detail with explicit names and snapshot fixtures.
Current Status
xabi is experimental. The repository is still iterating on generated API
shape, naming, and ABI fixtures. Treat the ABI snapshot checks as part of the
design process, not as a release promise.