relib
uses similar to WASM terminology:
- Host: Rust program (it can be executable or dynamic library) which controls modules.
- Module: Rust dynamic library, which can import and export functions to host.
If you don't want to repeat all these steps, you can use ready-made template
- Create Rust workspace: create empty directory with the following
Cargo.toml
(at the time of writing there is no cargo command to create workspace):
[workspace]
resolver = "2" # edition "2021" implies resolver "2"
[workspace.package]
version = "0.1.0"
edition = "2021" # or set a later one
-
Create host crate: (
--vcs none
to not create unneeded git stuff)
cargo new host --vcs none
-
Create module crate:
cargo new --lib module --vcs none
-
Configure module crate to compile as dynamic library, add the following to the
module/Cargo.toml
:
[lib]
crate-type = ["cdylib"]
-
Add relib_host dependency to host crate:
cargo add relib_host --package host
-
Configure "unloading" feature in
host/Cargo.toml
(see also "Usage without unloading")
[features]
unloading = ["relib_host/unloading"]
- Add the following to
main.rs
of host:
fn main() {
let path_to_dylib = if cfg!(target_os = "linux") {
"target/debug/libmodule.so"
} else {
"target/debug/module.dll"
};
// `()` means empty imports and exports, here module doesn't import or export anything
let module = relib_host::load_module::<()>(path_to_dylib, ()).unwrap_or_else(|e| {
panic!("module loading failed: {e:#}");
});
// main function is unsafe to call (as well as any other module export) because these preconditions are not checked by relib:
// 1. returned value must be actually `R` at runtime, for example you called this function with type bool but module returns i32.
// 2. type of return value must be FFI-safe.
// (see "Module exports" section for more info about ModuleValue)
let returned_value: Option<relib_host::ModuleValue<'_, ()>> = unsafe {
module.call_main::<()>()
};
// if module panics while executing any export it returns None
// (panic will be printed by module)
if returned_value.is_none() {
println!("module panicked");
}
// module.unload() is provided when unloading feature of relib_host crate is enabled
#[cfg(feature = "unloading")]
{
println!("unloading feature is enabled, calling module unload");
module.unload().unwrap_or_else(|e| {
panic!("module unloading failed: {e:#}");
});
}
}
-
Add relib_module dependency to module crate:
cargo add relib_module --package module
-
Configure "unloading" feature in
module/Cargo.toml
[features]
unloading = ["relib_module/unloading"]
- Add the following to
lib.rs
of module:
#[relib_module::export]
fn main() {
println!("hello world");
}
- And run host
cargo run --features unloading
(it should also build module crate automatically), which will load and execute module
To communicate between host and module relib
provides convenient API for declaring imports and exports and implementing them using Rust traits.
(make sure you followed "Getting started" guide)
-
Add libloading dependency to host crate:
cargo add libloading --package host
-
Add
relib_interface
dependency with "include" feature to host and module crates:
cargo add relib_interface --package host --features include
cargo add relib_interface --package module --features include
-
Also add it as build-dependency with "build" feature to host and module crates:
cargo add relib_interface --package host --features build --build
cargo add relib_interface --package module --features build --build
-
Create "shared" crate:
cargo new shared --lib --vcs none
-
Add it as dependency to host and module crates:
cargo add --path ./shared --package host
cargo add --path ./shared --package module
-
Add it as build-dependency as well (it's needed for bindings generation in
relib_interface
crate)
cargo add --path ./shared --package host --build
cargo add --path ./shared --package module --build
-
Define modules in shared crate for imports and exports trait:
// shared/src/lib.rs:
pub mod exports;
pub mod imports;
pub const EXPORTS: &str = include_str!("exports.rs");
pub const IMPORTS: &str = include_str!("imports.rs");
// shared/src/exports.rs:
pub trait Exports {}
// shared/src/imports.rs:
pub trait Imports {}
- Create build script in host crate with the following code:
// host/build.rs
fn main() {
// this code assumes that directory and package name of the shared crate are the same
relib_interface::host::generate(
shared::EXPORTS,
"shared::exports::Exports",
shared::IMPORTS,
"shared::imports::Imports",
);
}
- In module crate as well:
// module/build.rs
fn main() {
// this code assumes that directory and package name of the shared crate are the same
relib_interface::module::generate(
shared::EXPORTS,
"shared::exports::Exports",
shared::IMPORTS,
"shared::imports::Imports",
);
}
- Include bindings which will be generated by build.rs:
// in host/src/main.rs and module/src/lib.rs:
// in top level
relib_interface::include_exports!();
relib_interface::include_imports!();
// these macros expand into:
// mod gen_imports {
// include!(concat!(env!("OUT_DIR"), "/generated_module_imports.rs"));
// }
// mod gen_exports {
// include!(concat!(env!("OUT_DIR"), "/generated_module_exports.rs"));
// }
- Now try to build everything:
cargo build --workspace --features unloading
, it should give you a few warnings
- Now we can add any function we want to exports and imports, let's add an import:
// in shared/src/imports.rs:
pub trait Imports {
fn foo() -> u8;
}
// and implement it in host/src/main.rs:
// gen_imports module is defined by relib_interface::include_imports!()
impl shared::imports::Imports for gen_imports::ModuleImportsImpl {
fn foo() -> u8 {
10
}
}
- After that we need to modify
load_module
call in the host crate:
let module = relib_host::load_module::<()>(
path_to_dylib,
gen_imports::init_imports
).unwrap();
- And now we can call "foo" from module/src/lib.rs:
// both imports and exports are unsafe to call since these preconditions are not checked by relib:
// 1. types of arguments and return value must be FFI-safe
// (you can use abi_stable or stabby crate for it, see "abi_stable_usage" example).
// 2. host and module crates must be compiled with same shared crate code.
let value = unsafe { gen_imports::foo() }; // gen_imports is defined by relib_interface::include_imports!()
dbg!(value); // prints "value = 10"
Exports work in a similar way to imports.
// in shared/src/exports.rs:
pub trait Exports {
fn foo() -> u8;
}
// implement it in module/src/lib.rs:
// gen_exports module is defined by relib_interface::include_exports!()
impl shared::exports::Exports for gen_exports::ModuleExportsImpl {
fn bar() -> u8 {
15
}
}
// in host/src/main.rs:
let module = relib_host::load_module::<gen_exports::ModuleExports>(
path_to_dylib,
gen_imports::init_imports
).unwrap();
Except one thing, return value:
// returns None if module export panics
let value: Option<ModuleValue<'_, u8>> = unsafe { module.exports().bar() };
What is ModuleValue
?
relib
tracks all heap allocations in the module and deallocates all leaked ones when module is unloaded (see "Module alloc tracker"), that's why ModuleValue
is needed, it acts like a reference bound to the module instance.
// a slice of memory owned by module
#[repr(C)]
#[derive(Debug)]
struct SomeMemory {
ptr: *const u8,
len: usize,
}
let slice: ModuleValue<'_, SomeMemory> = module.call_main().unwrap();
// .unload() frees memory of the module
module.unload().unwrap();
// compile error, this memory slice is deallocated by .unload()
dbg!(slice);
Module can define callback which will be called when it's is unloaded by host (similar to Rust Drop
).
note: it's only needed when unloading is enabled (see also "Usage without unloading").
#[cfg(feature = "unloading")]
#[relib_module::export]
fn before_unload() {
println!("seems like host called module.unload()!");
}
When you need to unload modules relib
provides memory deallocation, background threads check, etc.
But relib
can also be used without these features. For example, you probably don't want to reload modules in production since it can be dangerous.
Even without unloading relib
provides some useful features: imports/exports, panic handling in exports, and some checks in module loading (see LoadError
).
Disable "unloading" feature in relib_host, relib_module and relib_interface crates (no features are enabled by default). If you followed "Getting started" guide or if you use ready-made template you can simply run
cargo build --workspace
(without --features unloading
) to build host and module without unloading feature.
All heap allocations made in the module are tracked and leaked ones are deallocated on module unload (if unloading feature is enabled).
It's done using #[global_allocator]
so if you want to set your own global allocator you need to disable all features of relib_module crate, enable "unloading_core" and define your allocator using relib_module::AllocTracker
. See "Custom global allocator" example.
Feature | Linux | Windows |
---|---|---|
Memory deallocation (?) | ✅ | ✅ |
Panic handling (?) | ✅ | ✅ |
Thread-locals | ✅ | 🟡 (?) |
Background threads check (?) | ✅ | ❌ |
Final unload check (?) | ✅ | ❌ |
Active allocations are freed when module is unloaded by host. For example:
let string = String::from("leak");
// leaked, but will be deallocated when unloaded by host
std::mem::forget(string);
static mut STRING: String = String::new();
// same, Rust statics do not have destructors
// so it will be deallocated by host
unsafe {
STRING = String::from("leak");
}
note: keep in mind that only Rust allocations are deallocated, so if you call some C library which has memory leak it won't be freed on module unload (you can use valgrind
or heaptrack
to debug such cases).
Dynamic library cannot be unloaded safely if background threads spawned by it are still running at the time of unloading, so host checks them and returns ThreadsStillRunning
error if so.
note: module can register before_unload
function to join threads when host triggers module unload
Temporary limitation: destructors of thread-locals must not allocate on Windows.
struct DropWithAlloc;
impl Drop for DropWithAlloc {
fn drop(&mut self) {
// will abort entire process (host) with error
vec![1];
}
}
thread_local! {
static D: DropWithAlloc = DropWithAlloc;
}
DropWithAlloc.with(|_| {}); // initialize it
When any export (main
, before_unload
and ModuleExportsImpl
) of module panics it will return None
to host and panic message will be printed by the module:
let module = relib::load_module::<ModuleExports>("...")?;
let value = module.call_main::<()>();
if value.is_none() {
// module panicked
}
let value = module.exports().foo();
if value.is_none() {
// same, module panicked
}
note: not all panics are handled, see a "double panic"
After host called library.close()
(close
from libloading) it will check if library has indeed been unloaded. On Linux it's done via reading /proc/self/maps
.