relib

Module docs

Source
Expand description

§Terminology

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.

§Getting started

If you don’t want to repeat all these steps, you can use ready-made template (TODO: update template dependencies after publication).

  • 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

  • Add the following to main.rs of host:

fn main() {
  // replace "?" with your file name, for example if you named module crate as "module"
  // on linux the path will be "target/debug/libmodule.so", on windows it will be "target/debug/module.dll"
  let path_to_dylib = "target/debug/?";

  // `()` means empty imports and exports, here module doesn't import or export anything
  let module = relib_host::load_module::<()>(path_to_dylib, ()).unwrap();

  // 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().unwrap_or_else(|e| {
    panic!("module unloading failed: {e:#}");
  });
}
  • Add relib_module dependency to module crate:
    cargo add relib_module --package module

  • Add the following to lib.rs of module:

#[relib_module::export]
fn main() {
  println!("hello world");
}
  • Now you can build host and module: cargo build --workspace

  • And run host cargo run, which will load and execute module

§Communication between host and module

To communicate between host and module relib provides convenient API for declaring imports and exports and implementing them using Rust traits.

§Preparations for imports and exports

(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 incremental compilation)
    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;

// 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/src/exports.rs",
    "shared::exports::Exports",
    "../shared/src/imports.rs",
    "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/src/exports.rs",
    "shared::exports::Exports",
    "../shared/src/imports.rs",
    "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, it should give you a few warnings

§Module imports

  • 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"

§Module exports

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);

§before_unload

Module can define callback which will be called when it’s is unloaded by host (something similar to Rust Drop).

#[relib_module::export]
fn before_unload() {
  // ...
}

§Module alloc tracker

All heap allocations made in the module are tracked and leaked ones are deallocated on module unload by default. It’s done using #[global_allocator] so if you want to set your own global allocator you need to disable “global_alloc_tracker” feature of relib_module crate and define yours using relib_module::AllocTracker, see “Custom global allocator” example.

§Feature support matrix

FeatureLinuxWindows
Memory deallocation (?)
Panic handling (?)
Thread-locals🟡 (?)
Background threads check (?)
Final unload check (?)

§Memory deallocation

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 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).

§Background threads check

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 an error (TODO: add link to source code or docs with error) if so.

note: module can register “before_unload” export using relib_module::export proc-macro (TODO: add link)

§Thread-locals on Windows

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

§Panic handling

When any export (main, before_unload and ModuleExportsImpl) of module panics it will be handled and returned as None to host:

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”

§Final unload check

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.