Module stabby::_tutorial_

source ·
Expand description

§The Stabby Tutorial

stabby provides a set of tools to use all of Rust’s power accross FFI with as little runtime overhead as possible. This page is meant to guide you through its various features and how you’re expected to use it.

§What’s the point of ABI stability?

I have an entire RustConf talk on the subject, but here’s the gist of it.

§Static vs Dynamic Linkage

Rust has perfected the user experience of linking your projects statically: when you add a dependency in your Cargo.toml, that dependency’s code will be compiled and added to your own to produce a single binary object.

Static linkage has its advantages:

  • It produces a single binary with no dynamic dependencies (unless your dependencies had dynamic dependencies themselves). This makes that binary super-easy to install.
  • Since all code is available at once, more optimizations can be performed, including inlining code accross crate-boundaries.

It’s not the only way to handle dependencies though: you may instead decide to keep some of your dependencies separate from your own binary. There are two flavours of doing so:

  • If you need between 0 and N implementations of a given symbol, you’re probably designing a plugin system. Here, you’ll be importing functions at runtime when you figure you need them.
  • If you need exactly one implementation of a given symbol, it may make more sense to ensure it’s available before your program actually runs: this can be achieved with load-time linkage.

Despite the flavours looking very different, the core concept is the same: dynamic linkage consists in asking the OS to load external code from a “shared object” (or “shared library”) for you, and then ask it for the memory addresses of the specific functions that are of interest to you.

Doing so enables a few nice things:

  • Plugin systems are able to share memory between host and plugin, allowing them to be as fast as possible. When you have N optional features that may each be heavy, this allows fine-grained packaging without having to build 2^N versions of your binary.
  • Static linkage requires the dependency to be available at compile-time. In some edge cases, this may not be possible.
  • Static linkage embeds the dependency in your binary, meaning that if you and 20 other binaries statically link one dependency, and run in parallel on a user’s computer, 20 copies of that dependencies will exist in that user’s disk and RAM. Dynamic linkage allows all these binaries to share a single copy of that dependency, both on disk and in RAM for most OSes.
  • Because static linkage embeds a snapshot of the dependency in your binary, that means that recompiling is necessary to update that dependency for your binary. By contrast, if your binary links to that dependency dynamically, then all the user needs to do for your binary to use the latest version of that dependency is to update the shared library file. This makes patching vulnerabilities much easier, notably.

§Dynamic linkage is dumb

But dynamic linkage has one great pitfal: it’s not very clever. When you ask for a function, you’re only asking the linker to give you the address of the symbol that has the name you asked for. This doesn’t take into account any of the following:

  1. That symbol may not actually be a function: it could be a static variable.
  2. Even if it’s a function, the signatures aren’t compared at all: it could be expecting very different arguments from what you intend to pass.
  3. Even if the argumens and return type line up, the function may expect to be called a specific way (in terms of binary operations). This is called a calling convention, and your program must respect it.
  4. Finally, the values passed need to be understood by both binaries the same way: for example, if a vector is passed, both binaries need to agree on which of its words is the start pointer. This is called type layout.

It’s rather rare to run into problems 1 and 2: just make sure you checked the signatures of the symbols you dynamically load for your dependency’s documentation.

Problem 3 is usually not a problem in most language, because their default calling convention tends to be stable (meaning you’ll only run into problems when you ignore your dependency specifying a different one).

However, Rust’s default calling convention is not stable: it’s susceptible to change (to attempt to extract more performance), even while using the same version of the compiler.

This means that unless you explicitly specify a stable calling convention, your binaries may be unable to agree on how certain types of functions should be called, leading to undefined behaviours.

Problem 4 is also not usually a problem as most languages will blindly follow your source to lay out types. Rust doesn’t do that: it tries to lay your types out in the most compact manner possible.

Since the algorithm to pick these layouts may change, Rust doesn’t offer stability in these layouts: field order may change depending on your compiler version and settings, and possibly other things, since the documentation makes no promises about layout at all.

§So what is an ABI

ABI stands for Applictaion Binary Interface, and it’s really the sum of all the choices made regarding the type layouts and calling conventions used in your binary.

Just like you can say that an API (Application Programming Interface) is stable from one library version to the next if no code needs to be updated in order to keep using it, you could say that a shared library’s ABI is stable if no host program loading it needs to be re-compiled in order to use its new version.

As we’ve just explained, Rust’s default type layouts and calling conventions are not guaranteed to stay the same, even using the same version of the compiler.

Since this instability is contageous, that means that no type embedding a type that uses Rust’s representation, or a function pointer without an explicit calling convention, is guaranteed to have the same layout from one build to the next.

Hence, most of Rust’s standard library, as well as its trait-objects, should be kept away from your shared library’s interface unless you want to risk undefined behaviour… Disappointing, isn’t it?

§What stabby offers

stabby provides the tools to solve all of these issues with dynamic linkage at the smallest cost possible.

Its core goal is to ensure that even forgetful souls can define dynamic libraries that don’t expose their dependents to undefined behaviour, while helping them dodge certain performance pitfalls:

  • Compiler-change-proof ABI-stability is proven statically through the type system.
  • stabby also provides an alternative to the standard library’s most commonly used types so you don’t have to rewrite everything like you do in C.
  • stabby will deny compilation when a type is poorly laid out in memory, letting you worry about more important things instead.
  • Expoting functions embeds reports in the produced binaries to allow them to identify mismatching API/ABIs.
  • Importing functions checks these type reports, solving all the problems listed above.

stabby also ensures that all of Rust’s best features are usable accross dynamic linkage with minimal effort, including trait objects, closures and futures.

§Defining ABI stable types

stabby leverages the trait system to find niches in your types that can be exploited when defining sum-types (enums).

Most of the magic comes from the IStable trait which acts as a proof that a given type’s representation is stable, as well as carries information about said representation (size, alignment, available niches, whether contains indirections, what its fields are…).

Lucky for you, you’re never expected to implement it yourself, relying instead on the stabby attribute macro to implement it for you!

If you’re experienced in Rust, you may now wonder why this is an attribute macro and not a derive macro: the answer is that this macro does not limit itself to generating a trait implementation…

§Product types (or structs)

When you annotate a structure with stabby like so

#[stabby::stabby]
pub struct MyStruct {
	hi: u8,
	there: u16,
}

stabby will actually turn it into the following

#[repr(C)] // guarantees that the layout stays fixed
pub struct MyStruct {
	hi: u8,
	there: u16,
}
impl stabby::IStable for MyStruct { 
	// Dark magiks that compute MyStruct's layout,
	// Taking note that an entire byte is unused at offset 1 from the structure's start.
	...
}
const _: () = {	assert!(MyStruct::has_optimal_layout()) };

This last const here will prevent your code from compiling unless you’ve laid out your fields in an order that doesn’t introduce unnecessary padding. If you’re not familiar with the concept of alignment padding, I’ve explained more about it in the talk linked above.

The core thing to retain about alignment padding is that it’s necessary to uphold alignment constraints, which CPUs care about a lot: a value of a type must always be at an address in memory that’s a multiple of its alignment. In structures, padding will be inserted between fields to ensure this constraint in respected. For example, here’s MyStruct’s memory layout (considering u8 and u16’s respective alignments to be 1 and 2 bytes, as is the case on all architectures I know of):

bytes	|0       |1       |2       |3       |
fields	|hi      |--------|there            |

The dashes here are padding. Padding is also inserted at the end of structures to ensure that their size is a multiple of their alignment (the maximum of its fields’ alignments).

If we were to add a hello: u8 field to MyStruct, here are the layouts you’d get depending on where you insert it:

if hello is inserted in first position
bytes	|0       |1       |2       |3       |
fields	|hello   |hi      |there            |
if hello is appended at the end of MyStruct
bytes	|0       |1       |2       |3       |4       |5       |
fields	|hi      |--------|there            |hello   |--------|

Note how the second representation is 33% padding, while the first is much smaller. Without repr(C), Rust is free to reorder fields to make your type as compact as possible; but repr(C) forces fields to be ordered the same way in memory as in code, which means you have to do the reordering to stay optimal.

Note that stabby can only help you automatically if your structure has no generics that may affect layout. Here’s how this help looks in code:

// This doesn't compile because reordering the fields would yield a better layout.
#[stabby::stabby]
pub struct MyStruct {
	hi: u8,
	there: u16,
	hello: u8,
}
// Here, we have an optimal layout memory-wise.
#[stabby::stabby]
pub struct MyStructProperlyOrdered {
	hi: u8,
	hello: u8,
	there: u16,
}
// You can also opt-out of `stabby`'s help if you need an explicitly sub-memory-optimal layout.
#[stabby::stabby(no_opt)]
pub struct MyStructExplicitlySuboptimal {
	hi: u8,
	there: u16,
	hello: u8,
}

Finally, stabby is perfectly happy to annotate unit and tuple structs.

§Sum types (or enums)

stabby’s core reason for wanting to compute things about your types is so that it can do enum layout optimizations.

However, doing these layout optimizations in an ABI-stable manner does change how your code looks, and the tradeoffs between type-size and speed are very close, meaning you might prefer to only use these optimized layouts for certain use cases.

Therefore, stabby leaves you the choice of representation. For the following subsections, we’ll use the following 2 example types to highlight differences between representations.

#[stabby::stabby]
#[repr(stabby)]
pub enum Poll<T> {
	Pending,
	Ready(T),
}

#[stabby::stabby]
#[repr(stabby)]
pub enum AllInts {
	U8(u8),
	U16(u16),
	U32(u32),
	U64(u64),
	I8(i8),
	I16(i16),
	I32(i32),
	I64(i64),
}
§External tagging: repr(uX) and repr(iX)

These are Rust’s standard way of making ABI-stable sum types: an external tag of type uX/iX is added in front of the union of each variant’s data.

This is actually optimal for AllInts, as there isn’t any niche to exploit in the largest variants’ data (u64 and i64 both are types whose range of valid values covers all the values that the memory they occupy can take).

However, Poll<T> could have more efficient representations if T has “niches”: binary patterns that do not correspond to any valid value of T. For example, core::num::NonZeroU64 occupies 64 bits in memory, but is never allowed to be 0, which means that we could set all 64 bits to 0 as a way of indicating Pending. This is generally refered to as “niche optimizations” in Rust, and is something that normal Rust enums perform, but not repr(uX/iX/C).

§Stable niche optimizations: repr(stabby)

stabby was originally created as PoC that through type system shenanigans, one could keep track of a type’s layout at compile time, and use that information to define niche optimized layouts in a deterministic way, granting ABI-stability without sacrificing memory-efficiency.

The way stabby does this is by having its own stabby::Result<Ok, Err> type which serves as the basis for its sum types. Through dark magiks, stabby::Result can find a niche to encode the determinant between Ok and Err without adding an external tag if that niche exists. When you define a repr(stabby) enum (or just mark your enum with stabby::stabby without specifying your desired repr), stabby will represent it as a binary tree of Results:

struct AllInts(
	Result<
		Result<
			Result<
				u8, 
				u16,
			>,
			Result<
				u32,
				u64,
			>,
		>,
		Result<
			Result<
				i8,
				i16,
			>,
			Result<
				i32,
				i64,
			>,
		>,
	>
);

Of course, you don’t have to deal with stabby’s exactions: stabby will also define a lot of accessors and constructors to let you interract with AllInts in ways that mostly resemble how you would interract with normal enums, with a few exceptions:

  • Due to the current limitations of const fn and traits, the constructors for each variant cannot be const.
  • Pattern matching is no longer available, but is instead emulated using the match_ref, match_mut, match_owned and their _ctx variants which require you to provide one closure for each variant of your enum.

Note that in the AllInts example, you should definitely not use repr(stabby) in hopes of getting better performance, as it will not be able to provide any layout optimization benefits; and contrary to repr(u8), will not be able to have its matches be optimized to lookup tables. stabby will notably force you to explicitly pick your representation explicitly in this case, as it will realize that its default representation will not provide you with the benefits it was designed to give you.

#[stabby::stabby]
// without an explicit `repr`, this doesn't compile,
// as the default `repr(stabby)` is found not to be beneficial.
pub enum AllInts {
	U8(u8),
	U16(u16),
	U32(u32),
	U64(u64),
	I8(i8),
	I16(i16),
	I32(i32),
	I64(i64),
}

§ABI-stability must go all the way down…

For a type to be ABI-stable, not only must its components be assembled in stable ways, but these components must also be ABI-stable.

The IStable trait is already implemented for most ABI-stable types in Rust’s core, including all integers*, non-zero integers*, floats, thin pointers, thin references, and transparent types (*u128 and i128 are considered ABI-stable, despite their ABI having changed between 1.76 and 1.77 due to LLVM changing their alignment on x86. stabby is able to tell appart these types when they’ve been compiled with either alignment).

On top of this, stabby can tell when core::option::Option<T> is ABI-stable (when T is ABI-stable and known to have only one possible niche, like thin references and NonZero types).

stabby also provides ABI-stable equivalents to a few of the core allocated types from alloc, as none of alloc’s types are ABI-stable, notably because their default allocator is not guaranteed to stay the same (it has changed before), despite the allocator being a type invariant for any type containing owning pointers.

Allocator choice is a great excuse to introduce another important concept in ABI-stability: type invariants are part of your ABI. This is important because this means that if you decide to include (or not to include) and invariant in your type at any point, changing your mind on this is an ABI breakage, as passing memory from a binary that doesn’t uphold the invariant to one that expects it to be upheld may lead to undefined behaviour.

§… Unless you opacify types to their dependents

Sometimes, you might decide that you’d rather not commit to a given representation for your type, or for a given set of invariants. That’s perfectly normal, especially if that type is expected to allow complex behaviour.

Lucky for you, a solution for that has existed since times immemorial: opaque types.

The core principle with opaque types is to make consumer code completely unaware of their internal representation, limiting interraction to functions that return and accept pointers to them. The FILE and socketAPIs in C are prime examples of this.

Opaque types are typically used when only one implementation of their API is expected to be loaded at any given time. The moment you expect more, what you probably want is trait objects.

While this isn’t yet implemented, as I’m still looking for ways to do it both conveniently and reliably, stabby will eventually try to provide a way to define opaque types with minimal boilerplate such that their binary code is only included when built as a shared object, while dependents on them would instead get bindings to interract with said shared objects as if they were standard Rust code. In the meantime, trait objects can fulfill the same role at a slightly higher runtime cost, but with greater flexibility yet.

§Defining ABI stable traits

stabby::stabby can also be applied to traits! Doing so will let you use these traits in ABI-stable trait objects using a rather familiar syntax.

use stabby::boxed::Box;
#[stabby::stabby(checked)]
// `checked` verifies that all function signatures are ABI-stable.
// A following example will show why it is disabled by default.
pub trait Volume {
	extern "C" fn in_liters(&self) -> f32;
}
#[stabby::export]
pub extern "C" fn teaspoons(n: f32) -> stabby::dynptr!(Box<dyn Volume>) {
	struct Teaspoon(f32);
	impl Volume for Teaspoon {
		extern "C" fn in_liters(&self) -> f32 { 0.004928 * self.0 }
	}
	Box::new(Teaspoon(n)).into()
}

stabby::dynptr allows you to obtain the type of the stabby-defined trait object with the familiar trait object syntax. Here, it actually expands to the pretty horrid

stabby::abi::Dyn<
	'static,
	Box<()>,
	stabby::abi::VTable<VtVolume, VtDrop>
>

§Common pitfalls

An error you may quickly run into when working with multiple trait objects is that you can’t just use stabby::dynptr (or any other macro, for that matter) in the function signatures, because stabby cannot see through macros:

#[stabby::stabby(checked)]
pub trait Engine {
	extern "C" fn volume(&self) -> stabby::dynptr!(Box<dyn Volume>);
}

This is, however, easy to circumvent using a type alias.

type BoxedVolume = stabby::dynptr!(Box<dyn Volume>);
#[stabby::stabby(checked)]
pub trait Engine {
	extern "C" fn volume(&self) -> BoxedVolume;
}

stabby originally checked all traits for ABI-stability by default. This behaviour was changed in 1.0.1 as it would lead to the cargo check looping forever when trying to evaluate the trait’s stability when one of its methods mentioned it:

type RefVolume<'a> = stabby::dynptr!(&'a dyn Volume);
#[stabby::stabby] // Adding `checked` here would provoke an infinite loop.
pub trait Volume {
	extern "C" fn in_liters(&self) -> f32;
	extern "C" fn cmp(&self, rhs: RefVolume<'_>) -> core::cmp::Ordering;
}

Note however that until I wrote this example, stabby didn’t know that core::cmp::Ordering was actually ABI-stable, yet accepted it as such because the checks were bypassed. I would therefore advise to enable the check whenever possible, as leaving it disabled does punch a hole in stabby’s safety net.

Alternatively, if you need to disable checks to prevent a loop, but still want to guarantee stability, you could add the following lines to the previous example:

const _: () = {
	stabby::abi::assert_stable::<f32>();
	stabby::abi::assert_stable::<core::cmp::Ordering>();
};

§Multi-trait objects

stabby’s trait objects differ from Rust’s in two points:

  • While Rust’s trait objects automatically take on all of their supertrait’s methods into their v-table, stabby’s trait objects don’t.
  • Contrary to Rust, stabby allows your trait objects to refer to multiple traits.

Let’s imagine you wanted a trait object with all of Volume and Engine’s methods. Rust’s way of handling trait objects would mean you’d need to create a trait that inherits them both:

pub trait EngineAndVolume: Engine + Volume {}
impl<T: Engine + Volume> EngineAndVolume for T {}
type BoxedEngineAndVolume = Box<dyn EngineAndVolume>;

while in stabby, you would instead to the following:

type BoxedEngineAndVolume = stabby::dynptr!(Box<dyn Engine + Volume>);

§Standard traits

stabby already ensures that some of core’s traits (notably Send, Sync, closures and Future) have an ABI-stable equivalent ready to use to minimize boilerplate.

If you feel like some of them are missing, please let me know, and I’ll consider adding it.

§Creating shared libraries

Now that we have ways to define types that will work accross the shared library boundary, let’s see how we can put them to good use!

If you’d rather just look at code, these examples exist solely to show you how to make a shared library, and how to link it at load-time or at runtime with libloading.

§Exporting functions in shared objects

Before we can load functions from shared objects, we need to create them. To do so, we need to ask cargo to produce one by adding the cdylib crate type to our library:

[lib]
crate-type = ["cdylib"]

By doing so, cargo will now build an appropriately named artifact for your system, lib<crate_name>.so on Linux, which you’ll be able to find in the target directory corresponding to your build profile. If you start inspecting it with nm, you’ll find that it’s home to a multitude of oddly named symbols: that’s because unless you explicitly ask Rust not to do so, it will mangle symbol names so that symbols that share the same identifier, but were defined in different modules or with different generic parameters, can coexist in the binary and be told appart.

If you’ve done any C before, you might be used to “manually mangling” your function names: prefixing them with a prefix systematically to avoid your intvec_push from clashing with someone else’s. That’s because C doesn’t do mangling (leaving you to mangle your mind and fingers doing it yourself), but that’s also what makes it easy to work with shared objects in C.

I just said “unless you explicitly ask Rust not to do so”, though. Here’s how that looks:

#[no_mangle]
pub extern "C" fn my_self_mangled_function(param1: u8, param2: u16) {}

#[no_mangle] is really explicit: the symbol it annotates will not have its name mangled, meaning it will appear as-is in the list of symbols nm will now give you.

You might also have spotted that extern "C" here, and possibly earlier when we were talking about traits. extern "X" is how you tell Rust to use the X calling convention for a givent function. Calling conventions are complicated, but the gist of them is this:

  • A calling convention defines how a function should be called: where its arguments will be in memory/registers; how the caller should recover their return value; which of the caller and callee is supposed to save which registers to prevent caller’s intermediate results from getting erased by callee doing its own job.
  • Rust’s default calling convention is not “stable”: it may change depending on which version of the compiler you’re using, or which settings you used for it. This means that calling a function from a different binary that wasn’t explicitly annotated with a stable calling convention may result in undefined behaviour.
  • The C calling convention is one of the most commonly used stable calling conventions. This is also the calling convention you should use when importing symbols from a binary produced by C when no explicit calling conventions have been specified.

Rust will also take that as enough reason not to “tree shake” your function out of the final shared object: even if nothing calls your function in your object’s code, it will still be included in the resulting binary. Tree shaking is a common feature in compiled languages where code that’s unused will be removed from the final binary (possibly very early in the compilation process to avoid wasting time building that code at all).

§Exporting functions with checks

The previous example is the default way of exporting symbols in Rust when you’re planning on dynamically linking to them in other binaries.

However, stabby attempts to provide a better way: #[stabby::export], which comes in two flavours:

  • The default flavour will export an additional symbol which lets the importer inquire on the exported function’s signature, allowing the detection of incompatibilities between the exported function’s signature and the signature you’re trying to import it as. It is by far stabby’s favoured way of exporting symbols, but does require all function parameters to be proven ABI-stable with the IStable trait. This means that this will only compile if your function’s ABI is indeed entirely stable.
  • The canaries flavour, will export additional symbols which let the importer inquire on the version and settings of the compiler used to compile the shared object. You can use this exporter when you want your function to use types that aren’t provably stable in their signature. Doing so is still risky, but the canaries make it less risky than just straight up ignoring the risks. From my previous experience with Zenoh’s plugin system, we’ve never detected any undefined behaviour in doing things without guaranteeing ABI-stability as long as we did check the parameters checked by these canaries.

In either case, #[stabby::export] will automatically imply #[no_mangle], and will force you to pick a stable calling convention (which, suprisingly, #[no_mangle] doesn’t warn you about forgetting).

§Loading code from shared libraries

§Importing functions at runtime

To import functions at runtime, you will first need to link the shared object that contains them at runtime.

No need to panic, it’s not that hard: the most common way to do that in Rust is to rely on the libloading crate to interface with whatever your OS provides to load libraries (dlopen on POSIX compliant OSes, LoadLibrary on Windows).

extern crate libloading;
let lib = unsafe { libloading::Library::new("my_library").unwrap() };

Once you’ve loaded your shared object (or Library), you’ll have to actually import the symbols you want from it:

let my_imported_function = unsafe { 
		lib.get::<extern "C" fn(u8, u16)>(b"my_self_mangled_function").unwrap()
	};

And from this point on, you can use your function! Hurray!

Keep in mind that nothing prevents you from doing the following:

let my_imported_function = unsafe { 
		lib.get::<extern "C" fn(u32)->u64>(b"my_self_mangled_function").unwrap()
	};

Thich will happily return a wrongly typed function pointer (hence the unsafe).

§Importing functions at runtime with checks

But wait, how was `stabby` involved in the last example?

Very observant of you, imaginary reader: the previous example shows how you’d do it without stabby, but then you wouldn’t take any advantage from using #[stabby::export] instead of #[no_mangle].

stabby offers two alternative ways to get a symbol from a libloading::Library, provided through the StabbyLibrary trait.

The trait adds the get_stabbied and get_canaried methods to libloading::Library. These can be used to load symbols that were exported with #[stabby::export] and #[stabby::export(canaries)] respectively.

get_canaried will return an error if the symbol is missing from the library, or if the canaries associated to it are missing or don’t match those expected for the loader’s compiler settings; it doesn’t check that the symbol you’re importing is typed as expected.

get_stabbied will return an error if the symbol is missing, or if the report associated to it indicates that the API or ABI of the symbol isn’t the same as expected.

To my knowledge, stabby is the only crate that provides a systematic way in Rust to check that the symbols you load from a shared object are indeed typed as you expect them to be.

§Importing functions at load time

Loading shared libraries at runtime like we’ve studdied above is more typical when implementing a plugin system, as the main advamtage of doing so is that this lets you load any number of shared objects that provide an intersecting set of symbols (including 0).

However, if you want to avoid linking some functions statically in your own binary, but still need these functions in order to run, load-time linkage may be more relevant.

Load-time linkage is basically politely asking the OS to link your binary with a given set of shared object before calling your main. These shared objects will likely have been installed in directories the OS knows about before or while installing your own binary.

To import a set of functions, all it takes in an extern block listing the functions you wish to import:

#[link(name = "my_library")]
extern "C" {
	pub fn my_self_mangled_function(param1: u8, param2: u16);
	pub fn other_fn() -> i32;
}

The block’s calling convention must match that of the imported functions, and the names and signature must match.

The link attribute tells Rust what library these symbols should be looked up in, and is further documented here.

Finaly, you may notice that the load-time linkage example has a build.rs.

For an external block to compile, the library it should dynamically link to must be visible by the compiler so that it can check that the expected symbols are indeed available in the library.

The build.rs ensures that the path where the dynamic library example gets built to is part of the search path so that it gets found.

§Importing functions at load time with checks

By simply replacing the link attribute with stabby::import, you get stabby to check the reports to prove that using your functions is safe before the first time it gets run.

#[stabby::import(name = "my_library")]
extern "C" {
	pub fn my_self_mangled_function(param1: u8, param2: u16);
	pub fn other_fn() -> i32;
}

If the reports mismatch, then the loader will panic instead of running the function, as running it may lead to undefined behaviour.

And if you exported some functions with canaries instead of the default export, you should let the attribute know so that i this import

extern "C" {
	pub fn yet_another(unstable_param: &[u8]);
}

If any of the canaries don’t match, or if the reports for non-canaried imports are missing from the loaded library, linkage will simply fail, preventing your program from running (into potential undefined behaviour) altogether.

§Some use cases for stabby

§Developping plugins

I’ve been rather vocal that I consider Inter Process Communications-based plugins to be a much better approach to a modern plugin system, notably because by spawning plugins in distinct processes, you can ensure that they don’t crash the host process, nor cause the host process to misbehave. Not only that, but they get you ready to export your plugins to separate machines, and allow plugins to be developped in any language that supports your IPC of choice.

Still, IPC plugins require the messages between host and plugins to be serialized and passed over some form of IPC, both of which are going to cause overhead. This overhead tends to scale with the size of exchanged messages, and can get high if your plugins need to work on very large chunks of memory that can’t be otherwise shared between processes.

I consider stabby to be your best pick if you plan on developping dynamically linked plugins written in Rust.

If that is your plan, my advice is to create a project-plugin-core crate where you define your plugins API as a trait:

#[stabby::stabby]
#[repr(u8)]
pub enum CloseResponse {
	/// Your plugin accepts that the file will be closed
	Acknowledge,
	/// Your plugin requests that the file be kept open
	Refuse,
}
use stabby::slice::Slice;
#[stabby::stabby(checked)]
pub trait MyTextEditorPlugin {
	extern "C" fn on_editor_opened(&mut self, path: Slice<'_, u8>);
	extern "C" fn on_editor_closing(&mut self, path: Slice<'_, u8>) -> CloseResponse;
}
#[stabby::stabby(checked)]
pub trait MyTextEditorHost {
	extern "C" fn move_cursor(&self, path: Slice<'_, u8>, line: u32, column: u32);
}
type Host = stabby::dynptr!(stabby::sync::Weak<dyn MyTextEditorHost>);
type Plugin = stabby::dynptr!(stabby::boxed::Box<dyn MyTextEditorPlugin>);

You can then specify that your host expects plugins to be shared libraries that expose an init function with a given name and signature:

use stabby::{boxed::Box, result::Result, string::String}
use project_plugin_core::{Host, Plugin};
struct MyPlugin(Host);
impl MyTextEditorPlugin for MyPlugin { ... }

#[stabby::export]
pub extern "C" fn my_text_editor_init_plugin(host: Host) -> Result<Plugin, String> {
	Result::Ok(Box::new(MyPlugin(Host)).into())
}

Meanwhile, your host can simply use what we learned in the Importing functions at runtime with checks section to load plugin libraries, get the my_text_editor_init_plugin symbol, and instanciate it.

§Developping no-serialization protocols

A rather common (though often decried) practice in C to send data over the network or save it in files is to simply copy the structure itself on that IO stream, as it appeared in memory.

While this is not a practice that can be used in general, either because the type may contain indirections (in which case the copy will contain an address which won’t make sense in any other process’s conext); or because the copy may be read from a different machine with a different architecture (in which case alignment and endianness differences could cause the data to get corrupted).

The IPod trait, standing for Plain Old Data, acts as a proof that the types it’s implemented for don’t contain indirrections, while also providing a hash of its representation (including the machine’s architecture), allowing to detect both architecture mismatch and type mismatches.

This means that you can design your types to be IPod and copy them happily to other processes, and be safe in the knowledge that nothing wonky will happen as long as the identifiers match.

§Conclusion

This concludes our tour of stabby.

If you feel like something was unclear, or that something is missing from stabby, don’t hesitate to reach out.