Skip to content

Latest commit

 

History

History
executable file
·
395 lines (299 loc) · 13 KB

docs.md

File metadata and controls

executable file
·
395 lines (299 loc) · 13 KB

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

  • 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

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

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 (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()!");
}

Usage without unloading

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

How to turn off module unloading

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.

Module alloc tracker

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 support matrix

Feature Linux Windows
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 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).

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 ThreadsStillRunning error if so.

note: module can register before_unload function to join threads when host triggers module unload

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

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.