Stabby
A Stable ABI for Rust with compact sum-types
Install / Use
/learn @ZettaScaleLabs/StabbyREADME
[!WARNING]
Due to a breaking change in Rust 1.78,stabby's implementation of trait objects may raise performance issues:
- Only non-nightly, >= 1.78 versions of Rust are affected
- The v-tables backing trait objects are now inserted in a global lock-free set.
- This set is leaked:
valgrindwill be angry at you.- This set grows with the number of distinct
(type, trait-set)pairs. Its current implementation is a vector:
- Lookup is done through linear search (O(n)), which stays the fastest for <100 number of elements.
- Insertion is done by cloning the vector (O(n)) and replacing it atomically, repeating the operation in case of collision.
- Efforts to replace this implementation with immutable b-tree maps are ongoing (they will be scrapped if found to be much slower than the current implementation).
This note will be updated as the situation evolves. In the meantime, if your project uses many
stabby-defined trait objects, I suggest using eithernightlyor a< 1.78version of the compiler.
A Stable ABI for Rust with compact sum-types
stabby is your one-stop-shop to create stable binary interfaces for your shared libraries easily, without having your sum-types (enums) explode in size.
Your main vector of interaction with stabby will be the #[stabby::stabby] proc-macro, with which you can annotate a lot of things.
Why would I want a stable ABI? And what even is an ABI?
ABI stands for Application Binary Interface, and is like API's more detail focused sibling. While an API defines what type of data a function expects, and what properties these types should have; ABI defines how this data should be laid out in memory, and how a function call even works.
How data is laid out in memory is often called "representation": field ordering, how variants of enums are distinguished, padding, size... In order to communicate using certain types, two software units must agree on what these types look like in memory.
Function calls are also highly complex under the hood (although it's rare for developers to need to think about them): is the callee or caller responsible for protecting the caller's register from callee's operations? In which registers / order on the stack should arguments be passed? What CPU instruction is used to actually trigger the call? A set of replies to these questions (and a few more) is called "calling convention".
In Rust, unless you explicitly select a known representation for your types through #[repr(_)], or an explicit calling convention for your functions with extern "_", the compiler is free to do whatever it pleases with these aspects of your software: the process by which it does that is explicitly unstable, and depends on your compiler version, the optimization level you selected, some llama's mood in a wool farm near Berkshire... who knows?
The problem with that comes when dynamic linkage is involved: since the ABI for most things in Rust is unstable, software units (such as a dynamic library and the executable that requires it) that have been built through different compiler calls may disagree on these decisions about ABI, even though there's no way for the linker to know that they did.
Concretely, this could mean that your executable thinks the leftmost 8 bytes of Vec<Potato> is the pointer to the heap allocation, while the library believes them to be its length. This could also mean the library thinks it's free to clobber registers when its functions are called, while the executable relied on it to save them and restore them before returning.
stabby seeks to help you solve these issues by helping you pin the ABI for a subset of your program, while helping you retain some of the layout optimizations rustc provides when using its unstable ABI. On top of this, stabby allows you to annotate function exports and imports in a way that also serves as a check of your dependency versioning for types that are stabby::abi::IStable.
Structures
When you annotate structs with #[stabby::stabby], two things happen:
- The struct becomes
#[repr(C)]. Unless you specify otherwise or your struct has generic fields,stabbywill assert that you haven't ordered your fields in a suboptimal manner at compile time. stabby::abi::IStablewill be implemented for your type. It is similar toabi_stable::Stable, but represents the layout (including niches) through associated types. This is key to being able to provide niche-optimization in enums (at least, until#[feature(generic_const_exprs)]becomes stable).
Enums
When you annotate an enum with #[stabby::stabby], you may select an existing stable representation (like you must with abi_stable), but you may also select #[repr(stabby)] (the default representation) to let stabby turn your enum into a tagged-union with a twist: the tag may be a ZST that inspects the union to emulate Rust's niche optimizations.
Note that #[repr(stabby)] does lose you the ability to pattern-match.
Due to limitations of the trait solver, #[repr(stabby)] enums have a few paper-cuts:
- Compilation times suffer from
#[repr(stabby)]enums. - Additional trait bounds are required when writing
impl-blocks for generic enums. They will always be of the form of one or multipleA: stabby::abi::IDeterminantProvider<B>bounds (althoughrustc's error may suggest more complex bounds, the bounds should always be of thisIDeterminantProvidershape).
#[repr(stabby)] enums are implemented as a balanced binary tree of stabby::result::Result<Ok, Err>, so discriminants are always computed between two types through the following process:
- If some of
Err's forbidden values (think0for non-zero types) fit inside the bits thatOkdoesn't care for, that value is used to signify that we are in theOkvariant. - The same thing is attempted with
ErrandOk's roles inverted. - If no single value discriminant is found,
OkandErr's unused bits are intersected. If the intersection exists, the least significant bit is used, while the others are kept as potential niches for sum-types that would contain aResult<Ok, Err>variant. - Should no niche be found, the smallest of the two types is shifted right by its alignment, and the process is attempted again. This shifting process stops if the union would become bigger, or at the 8th time it has been attempted. If the process stops before a niche is found, a single bit will be used as the determinant (shifting the union right by its own alignment, with
1representingOk).
Unions
If you want to make your own internally tagged unions, you can tag them with #[stabby::stabby] to let stabby check that you only used stable variants, and let it know the size and alignment of your unions. Note that stabby will always consider that unions have no niches.
Traits
When you annotate a trait with #[stabby::stabby], an ABI-stable v-table is generated for it. You can then use any of the following type equivalence:
&'a dyn Traits→DynRef<'a, vtable!(Traits)>ordynptr!(&'a dyn Trait)&'a mut dyn Traits→Dyn<&'a mut (), vtable!(Traits)>ordynptr!(&'a mut dyn Traits)Box<dyn Traits + 'a>→Dyn<'a, Box<()>, vtable!(Traits)>ordynptr!(Box<dyn Traits + 'a>)Arc<dyn Traits + 'a>→Dyn<'a, Arc<()>, vtable!(Traits)>ordynptr!(Arc<dyn Traits + 'a>)
Note that vtable!(Traits) and dynptr!(..dyn Traits..) support any number of traits: vtable!(TraitA + TraitB<Output = u8>) or dynptr!(Box<dyn TraitA + TraitB<Output = u8>>) are perfectly valid, but ordering must remain consistent.
However, the v-tables generated by stabby will not take super-traits into account.
In order for stabby::dynptr!(Box<dyn Traits + 'a>) to have Trait's methods, you will need to use trait::{TraitDyn, TraitDynMut};, so make sure you don't accidentally seal these traits which are automatically declared with the same visibility as your Trait.
stabby::closure exports the CallN, CallMutN and CallOnceN traits, where N (in 0..=9) is the number of arguments, as ABI-stable equivalents of Fn, FnMut and FnOnce respectively.
Since version 1.0.1, the v-tables generated by #[stabby::stabby] always assume all of their method arguments to be ABI-stable, to prevent the risk of freezing rustc.
Unless your trait has methods referencing its own v-table, it's advised to use #[stabby::stabby(checked)] instead to avoid the v-table being marked as stable despite some types in its
interface not actually being stable.
Since stabby generates an additional struct when placed on a trait, you may want to add attributs to that struct's declaration. You can do so, you can use #[stabby::vt_attr(your_attribe = "goes here")]. Any other attribute will be placed on the original trait.
Functions
#[stabby::stabby]
Annotating a function with #[stabby::stabby] makes it extern "C" (but not #[no_mangle]) and checks its signature to ensure all exchanged types are marked with stabby::abi::IStable. You may also specify the calling convention of your choice.
#[stabby::export]
Works just like #[stabby::stabby], but will add #[no_mangle] to the annotated function, and produce two other no-mangle functions:
extern "C" fn <fn_name>_stabbied(&stabby::abi::report::TypeReport) -> Option<...>, will return<fn_name>as a function pointer if the type-report matches that of<fn_name>'s signature, ensuring that they indeed have the same signature.extern "C" fn <fn_name>_stabbied_report() -> &'static stabby::abi::report::TypeReportwill return<fn_name>'s type report, allowing debugging if the previous function returnedNone.
#[stabby::export(canaries)]
Works on any function, includ
Related Skills
node-connect
338.0kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
83.4kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
338.0kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
83.4kCommit, push, and open a PR
