A Rust plugin framework for loading native .so libraries at runtime — with self-describing IDL schemas, zero-copy FFI, and Python bindings.
In-process by design. A spier is a
.soloaded into the host viadlopen— same process, same address space. Arguments and return values cross the boundary through a flatu64[]slot convention over a C ABI: borrows and owned values pass by raw pointer, and opaque structs hand over a boxed pointer (1 slot) rather than a serialized copy, so live objects cross freely.
You wrote a Rust library. You want to load it at runtime as a plugin — discover its methods, call them, and get typed results back. Without hand-writing FFI boilerplate. Without stubs.
DynSpire does that — a .dspi file is the contract; build.rs generates everything.
Define an interface in a .dspi file:
interface Rle {
// Type declarations
struct CompressionReport {
original_size: u64,
compressed_size: u64,
ratio: f64,
runs: u64,
}
enum Tone {
Quiet,
Normal,
Loud(u8),
}
// Methods — Result<T, String> is implicit on every return
fn compress(data: &[u8]) -> Vec<u8>;
fn decompress(data: &[u8]) -> Vec<u8>;
fn compress_into(data: &[u8], out: &mut Vec<u8>) -> ();
fn stats(data: &[u8]) -> (u64, u64);
fn analyze(data: &[u8]) -> CompressionReport;
fn report_summary(report: CompressionReport) -> String;
fn classify(data: &[u8]) -> Tone;
fn first_byte(data: &[u8]) -> Option<u8>;
}
build.rs generates the trait, types, Op enum, and spier dispatch macro (spier side) or IDL descriptor and tower client (host side). Implement the trait and load it:
// Spier crate
impl RleEngine for RleState {
fn compress(&self, data: &[u8]) -> Result<Vec<u8>, String> { /* ... */ }
fn analyze(&self, data: &[u8]) -> Result<CompressionReport, String> { /* ... */ }
// ...
}
impl_rle_spier!(RleState, init, "rle");// Host crate
let client = DynSpireRle::connect("rle_spier", &config)?;
let compressed: Vec<u8> = client.compress(&input[..])?;
let report = client.analyze(&input[..])?; // typed CompressionReportOr from Python — with full schema reflection:
with load_spier("rle_spier", lib_dir="target/debug").create_handle() as h:
compressed = h.compress(input_data)
report = h.analyze(input_data) # OpaqueHandle- DSL-driven — a
.dspifile is the single source of truth.build.rsgenerates trait, types, Op enum, and spier dispatch macro (spier side) or IDL descriptor and tower client (host side). No proc macros on business code. - Self-describing — spiers export their full IDL schema (methods, types, enums) via a C ABI. Hosts discover everything at runtime.
- Zero-copy FFI — borrows (
&[u8],&str) and mutable out-params (&mut Vec<u8>) pass through raw pointers. No serialization overhead.Vec<T: Clone>input works for any element type (Rust→Rust). - Type-safe dispatch — Rust hosts use the generated tower wrapper. No magic numbers, no manual slot encoding.
- IDL hash verification — incompatible plugins are rejected at load time.
- Python without codegen — a PyO3 extension reads the IDL schema from the
.sodirectly. No stub generation, nobindgen, no C headers. - Any return type —
Result<T, String>whereTcan be(),Vec<u8>,(u64, u64, u64),Option<String>, any DSL-declared enum or struct, or any composed combination. Application errors use IDL-declared enums (e.g.,enum ParseResult { Ok(u64), Err(ParseError) }) — self-contained, schema-reflected, no Result nesting.
The .dspi file declares one interface containing type declarations and method signatures. It is the single source of truth — build.rs generates all Rust code from it. Type declarations can be shared across interfaces via include directives that pull in type fragment files.
| Declaration | Syntax | Notes |
|---|---|---|
| Include | include "path.dspi"; |
Imports types from a fragment file (no interface wrapper). Paths are relative to the including file. Placed before interface. |
| Struct | struct Name { field: Type, ... } |
Crosses FFI as a boxed pointer (1 slot). Trailing comma allowed. |
| Enum | enum Name { Variant, Variant(Type, ...), ... } |
Unit variants and tuple variants. Trailing comma allowed. |
| Opaque struct | opaque struct Name; |
No body — same FFI behavior as struct but no field access. Use for handles you only pass around. |
| Method | fn name(param: Type, ...) -> Type; |
Arrow + return type required. Every return is implicitly Result<T, String>. |
| DSL syntax | Rust equivalent | Slots | Notes |
|---|---|---|---|
(), bool |
(), bool |
0–1 | Unit = no slots |
u8 u16 u32 u64 |
same | 1 | Zero-extended to u64 |
i8 i16 i32 i64 |
same | 1 | Sign-extended to u64 |
f32 f64 |
same | 1 | Via to_bits() |
&[u8] |
&[u8] |
2 | Zero-copy borrow. Only u8 accepted. |
&str |
&str |
2 | Zero-copy borrow |
&mut Vec<u8> |
&mut Vec<u8> |
1 | Raw pointer — spier writes directly. Only Vec<u8> accepted. |
String |
String |
2 | Owned |
Vec<T> |
Vec<T> |
2 | Owned. T can be any type: Vec<u8>, Vec<String>, Vec<Vec<u8>>, ... |
Option<T> |
Option<T> |
1 + T | Tag + inner |
(A, B, ...) |
(A, B, ...) |
sum | 2–8 elements (matches slot limit). Single-element (X) collapses to X. |
[u8; N] |
[u8; N] |
N/8 | Fixed-size byte array. N must be a multiple of 8. Runtime support: N = 16. |
| Named type | same | 1 (boxed ptr) or disc+fields | Must be a declared struct/enum/opaque in the same interface |
- Comments:
//line comments only (no/* */) - Keywords:
interface,struct,enum,opaque,fn,mut,include - Trailing commas: allowed in struct fields, enum variants, and tuples
- Tuple arity: 2–8 elements
- Borrow constraints:
&[only acceptsu8;&mutonly acceptsVec<u8> - Named type references: must be declared in the same interface or included from a fragment — undeclared types are a parse error
- The interface must have at least one method
- Includes:
include "path";directives appear beforeinterface, import types from fragment files. Fragments contain only type declarations (nofn, nointerface). Paths resolve relative to the including file. Circular includes are an error; diamond includes (same file via different paths) are deduplicated.
Every method return is implicitly Result<T, String> (transport layer: null handle, init failure, etc.). For application-level errors, declare a custom Result enum:
interface Parser {
enum ParseError { InvalidFormat, TooLarge(u64) }
enum ParseResult { Ok(u64), Err(ParseError) }
fn parse(data: &[u8]) -> ParseResult;
}
The enum's discriminant IS the application-level Ok/Err tag — no Result<Result<T,E>, String> nesting. See architecture.md for details.
The .dspi file lives in the spier crate. The host compiles the same file by path reference. Each side uses a different build.rs entry point — build_spier() for the spier, build_host() for the host. The generated IDL hash guarantees compatibility at load time.
my-spier/ my-host/
Cargo.toml Cargo.toml
build.rs build.rs
src/
my.dspi src/
lib.rs main.rs
Spier crate (Cargo.toml deps: dynspire-codegen as build-dep, dynspire):
// build.rs
fn main() {
let mut ctx = dynspire_codegen::BuildContext::new();
ctx.build_spier("src/my.dspi");
}// lib.rs — include the generated spier code
#![allow(non_upper_case_globals)]
include!(concat!(env!("OUT_DIR"), "/my_spier.rs"));
// Implement the generated trait
impl MyEngine for MyState {
fn do_thing(&self, x: &[u8]) -> Result<Vec<u8>, String> { /* ... */ }
}
fn init(_cfg: &HashMap<String, String>) -> Result<MyState, String> {
Ok(MyState)
}
// Generate all C-ABI dispatch functions
impl_my_spier!(MyState, init, "my");Host crate (same .dspi, referenced by path):
// build.rs
fn main() {
let mut ctx = dynspire_codegen::BuildContext::new();
ctx.build_host("../my-spier/src/my.dspi");
}// main.rs — include the generated host code
#![allow(non_upper_case_globals)]
include!(concat!(env!("OUT_DIR"), "/my_host.rs"));
let client = DynSpireMy::connect("my_spier", &config)?;
let result = client.do_thing(&input[..])?;The IDL hash is computed from the interface's canonical signature — both sides produce the same hash from the same .dspi, so connect() accepts the spier.
When a host needs to talk to multiple spiers that share type fragments, use BuildContext to deduplicate type definitions:
// build.rs
fn main() {
let mut ctx = dynspire_codegen::BuildContext::new();
ctx.build_spier("src/a.dspi"); // generates SharedHandle
ctx.build_spier("src/b.dspi"); // skips SharedHandle (already emitted, same content)
}Types with the same name but different content are a hard error at codegen time.
Symbol names are derived from the interface name in the .dspi file:
| Interface name | Generated symbol | Example (interface My) |
|---|---|---|
interface {N} |
pub trait {N}Engine |
MyEngine |
pub enum {N}Op |
MyOp |
|
pub struct DynSpire{N} |
DynSpireMy |
|
pub const {N_UPPER}_IDL_HASH: u64 |
MY_IDL_HASH |
|
macro_rules! impl_{n_lower}_spier! |
impl_my_spier! |
|
output file (spier): {n_lower}_spier.rs |
my_spier.rs |
|
output file (host): {n_lower}_host.rs |
my_host.rs |
Python needs no .dspi or build.rs at all — the PyO3 extension reads the schema from the .so at runtime:
from dynspire import load_spier
lib = load_spier("my_spier", lib_dir="target/debug")The IDL + .so split isn't just about runtime loading — it's an architectural
constraint that enforces clean separation at compile time.
- Every dependency is explicit. The
.dspifile defines the interface. The spier and host each compile it; whatever isn't in the.dspidoesn't cross the boundary. No sneaky imports, no shared private modules. - Interfaces stay focused. Return types cross as ≤8
u64slots. You can't return a 50-field struct without consciously choosing an opaque struct declaration. This friction is intentional — it surfaces design problems at the interface, not at integration time. - Components are independently built and tested. Each spier is a separate
crate with its own
Cargo.toml, test suite, and release cycle. You can't reach into another component's internals during a refactor.
This is particularly effective with LLM-assisted development. LLMs naturally gravitate toward tight coupling — sharing types, building implicit dependencies, reaching across boundaries. DynSpire makes those patterns impossible at compile time. The only path through is a clean, explicitly declared interface.
The FFI overhead per dispatch is ~5x a direct function call — tens of nanoseconds for slot encode + indirect call + decode. This is insignificant compared to any real work the function performs: a single HashMap lookup or Vec allocation already costs more. For plugins that do I/O, data processing, or storage operations, the overhead is unmeasurable noise.
An RLE compression spier showcases the full cycle:
demo/
rle-spier/ .dspi interface + build.rs (generates trait, types, spier macro)
rle-host/ build.rs compiles same .dspi (generates trait, types, tower)
# Build everything
cargo build
# Run Rust host
cargo run -p rle-host
# Run Python host
uv run python demo/rle_client.py
uv run python demo/rle_client2.pyOutput:
compress()
-> [04 41 03 42 04 43 05 44 04 45 06 46 03 47] (14 bytes)
decompress()
-> "AAAABBBCCCCDDDDDEEEEFFFFFFGGG" (29 bytes) [round-trip OK]
compress_into(&mut Vec<u8>)
out buffer : [04 41 03 42 ...] (14 bytes) [matches compress]
stats()
original : 29 bytes
compressed: 14 bytes
ratio : 48.3%
pyproject.toml uv project root (declares dynspire-py as local dependency)
dynspire/ Core: arena FFI, slot system, tower client
dynspire-codegen/ DSL parser + code generator (.dspi → .rs)
dynspire-py/ Python bindings (PyO3, schema-driven, zero codegen)
demo/ RLE compression showcase
rle-spier/ .dspi + build.rs (generates spier code) + cdylib implementation
rle-host/ build.rs compiles same .dspi (generates host code) + binary
Host (Rust binary or Python script)
│
│ DynSpire{Name}::connect("my_spier", &config)
│ 1. find .so (DYNSPIRE_LIB_DIR / LD_LIBRARY_PATH / explicit)
│ 2. dlopen
│ 3. verify IDL hash
│ 4. resolve dispatch functions
│
▼
Spier .so (cdylib, loaded at runtime)
dynspire_create() → *mut State
dynspire_dispatch_{method}() → encode args → call → encode result
dynspire_destroy() → free State
Arguments and return values flow through u64 slots — a compact calling convention that handles scalars, borrows, owned types, tuples, enums, and structs without heap allocation on the FFI boundary. Complex structs cross as opaque boxed pointers (1 slot) via the DSL's opaque struct declaration.
For the deep dive, see docs/architecture.md.
The Python adapter is a compiled PyO3 extension that loads any DynSpire .so
and discovers its full interface at runtime:
from dynspire import load_spier
lib = load_spier("rle_spier", lib_dir="target/debug")
schema = lib.schema()
# Schema reflection
for m in schema.methods: # list[SpierMethod]
print(schema.method_sig(m)) # "compress(data: Slice<U8>) -> Result<Vec<U8>, String>"
# Introspect a single method
m = schema.method("compress") # SpierMethod
# m.name -> "compress"
# m.params -> [SpierParam(name="data", type_idx=5)]
# m.return_type -> 3 (type-table index)
# m.index -> 0
# Type introspection
ti = schema.type_at(m.params[0].type_idx) # SpierTypeInfo
# ti.kind_name -> "Slice"
# Enum introspection + value construction
tone_schema = schema.enum_by_name("Tone") # SpierEnumSchema
# tone_schema.variant_names -> ["Quiet", "Normal", "Loud"]
Tone = tone_schema.create_enum_class() # SpierEnumClass
loud = Tone.Loud(71) # SpierEnumValue("Loud", (71,))
# lib.idl_hash() == schema.hash
assert lib.idl_hash() == schema.hash
# Call via attribute access with native Python types
with lib.create_handle() as h:
compressed = h.compress(b"AAAABBBBCCCC")
decompressed = h.decompress(compressed)
# Out-vec methods (&mut Vec<u8>) auto-return (ret_val, list[bytes])
ok, outs = h.compress_into_checked(b"AAAABBBBCCCC")
# Dict args and kwargs also supported
h.call("compress", {"data": b"AAAA"})
h.compress(data=b"AAAA")All four are equivalent — use whichever reads best:
| Style | Example |
|---|---|
| Attribute (preferred) | h.compress(data) |
| Attribute + kwargs | h.compress(data=data) |
call escape hatch |
h.call("compress", data) |
| Dict args | h.call("compress", {"data": data}) |
| Object | Property/Method | Returns |
|---|---|---|
SpierLib |
.schema() |
SpierSchema |
.idl_hash() |
int |
|
.create_handle(config=None) |
SpierHandle |
|
SpierSchema |
.name |
str |
.hash |
int |
|
.methods |
list[SpierMethod] |
|
.method(name) |
SpierMethod |
|
.method_sig(name_or_method) |
str |
|
.type_at(type_idx) |
SpierTypeInfo |
|
.enum_by_name(name) |
SpierEnumSchema |
|
SpierMethod |
.name |
str |
.index |
int |
|
.params |
list[SpierParam] |
|
.return_type |
int (type-table index) |
|
SpierParam |
.name |
str |
.type_idx |
int |
|
SpierTypeInfo |
.kind_name |
str ("Slice", "U64", "Enum", ...) |
.child_count |
int (number of child type indices) |
|
.children |
list[int] (child type-table indices) |
|
SpierEnumSchema |
.name |
str |
.variant_names |
list[str] |
|
.create_enum_class() |
SpierEnumClass |
|
SpierEnumClass |
.VariantName(payload) |
SpierEnumValue (factory per variant) |
SpierEnumValue |
.variant |
str |
.fields |
tuple |
|
supports == (by variant name) |
h.compress(data) is sugar for h.call("compress", data). The bound method
holds a reference to the handle, so f = h.compress; del h; f(data) is safe.
Finding the .so:
| Priority | Mechanism |
|---|---|
| 1 | lib_dir= parameter |
| 2 | DYNSPIRE_LIB_DIR env var |
| 3 | bare name → dlopen resolves via LD_LIBRARY_PATH |
See LICENSE.