Welcome to the second article of my “Plugins in Rust” series! Here I will try to actually write some simple code of what a plugin system might look like, and try to answer any questions that may arise while doing so.
Even though the project is specific to Tremor, I try to keep it relatively generic so that it’s useful to future rustaceans interested in plugin systems. So if you don’t really care about Tremor’s specific case, you can skip to the next section.
1. About Tremor
1.1. Getting to know the team
My first official meeting with the Tremor team took place in the beginnings of August. I had already been lurking in their Discord server before, and I had talked with them a few times, so I more or less knew what to expect. But I was still surprised by their welcomingness and how refreshing their dynamics were.
Starting to work on Tremor for the first time with such a large task at first sounds daunting. I was somewhat overwhelmed by that and felt quite some pressure on me. But their words helped me de-stress about it; finishing the project would of course be hard, but no need to worry about things you can’t control yet.
I particularly liked the three “rules” of their workflow:
Have fun
Take good notes
Don’t worry
My mentors were Matthias, Darach, and Heinz. They seemed like very different kinds of people; something they knew and took advantage of. Decision-taking is hard; it requires good communication and an open mind. I also took note of this quote (roughly) from them:
When I have an idea about something, I just post it on the Discord server and wait for it to be destroyed by someone else. If it doesn’t, maybe it’s worth considering.
Also note that while writing this post I quite literally broke my leg (tibia & fibula). The team was very supportive, and I took some rest until I went through surgery. Which is another reason as to why this article has taken me longer than usual to release. Thanks a lot to all of them ❤️.
1.2. Getting to know other mentees
In the first reunion I was suggested to meet the other LFX mentee at that moment, @aryan9600 on Discord, which seemed like a good idea. I’d seen him a couple of times and knew he’d been contributing to Tremor even before the mentorship.
Aryan turned out to be a very nice colleague! He was very enthusiastic about the project, and he explained me his progress in detail. In summary, he was starting to add gRPC support to Tremor, and wasn’t sure how to approach it so that new interfaces could be added outside the compilation time. He had multiple ideas on how to implement this, but the one that interested me the most was via Wasm or dynamic linking, which was exactly what I was trying to do as well for the system. This meant I’d probably spend more time with him, as our projects were more interconnected than I thought.
1.3. Learning about the internals of Tremor
I’ve always known that the original intent for the system was the typical architecture via dynamic linking, but I wanted to explore the viability of Wasm or other alternatives before ruling it out. Would this idea be destroyed by someone in the end, or does it stand a chance? In order to know that I’d first need to find more specific requisites by defining the basic interfaces of the system. Which is very hard at first because you need to know more about the internals of Tremor and how it works.
Luckily, Matthias spent the second meeting explaining to me how Tremor works,
with the help of some drawings, taking a look at the code, and running it. He
also taught me the novel concept of a connector, which is just an abstraction
over both sources and sinks. The thing was that connectors were still Work In
Progress, and Matthias needed to know how the system would work to modify the
Connector
interface. Mainly how to combine plugins and generics, which is what
I’ll try to figure out in this article.
Back when I talked to Aryan we ended up asking ourselves the same exact things.
Turns out that the generated code with tonic
for gRPC uses generics, which
might not be compatible with dynamic loading.
2. The first steps
Up until now I had mostly investigated about the technologies that could be used for the system, but I was suggested that I should first take a look at how the interfaces would actually look like. That way it would be easier to take the final decision for the system’s technology, according to the requirements for these interfaces. In summary, the following questions arose:
What’s the performance hit of the system?
Can we avoid serialization?
Is it possible to use generics in the system?
Otherwise, is it possible to use
dyn
in the system?If all else fails, what can be done to represent a “generic” trait object in the interface Tremor exports for plugins?
This meant I had to do more research. I spent a few days reading different articles and forum discussions, but it’s a rabbit hole 🐰! A few links I recommend checking out:
Michael F. Bryan has put a lot of effort into teaching how dynamic loading and Wasm work in Rust. Thanks a lot to him!
3. Experimenting with dynamic loading
3.1. ABI unstability, it’s much worse than it seems
I recently had this very late realization about ABI stability in Rust. Up until
this point I thought that even though Rust’s ABI is unstable, one could
dynamically load a library safely as long as both the library and the main
binary were compiled with the same exact compiler/std
/etc version. I had read
this many times in forums like
this
one on Reddit and in blogposts such as
"Plugins in Rust",
so I assumed it was true.
But turns out that the ABI may not only break between compiler versions, but
also compiler runs. rustc
doesn’t guarantee that a layout is going to be the
same in different executions. This is proved by
rust-lang/compiler-team#457,
the new unstable -Z randomize-layout
flag. It’s pretty much self-explanatory:
it randomly reorders repr(rust)
layouts for testing purposes. The same thing
could happen in the future without an explicit flag; an optimization may cause
the repr(rust)
layouts to change between compilation runs. It’s briefly
mentioned in
the Rust
reference as well:
Type layout can be changed with each compilation. Instead of trying to document exactly what is done, we only document what is guaranteed today.
Props to the devs at the #black-magic
channel in
Rust’s Discord server, who helped me understand
this. Specially Yandros and Kixiron, both of them very respectable contributors
to the Rust compiler/community.
This basically means that we are forced to stick to the C ABI with #[repr(C)]
,
and that we should use abi_stable
in order to have access to a
stable std
library as well, instead of re-implementing everything ourselves
from scratch. On the positive side, it means that plugins could be implemented
in any language, but that wasn’t important for Tremor’s case since the beginning
anyway.
3.2. Getting a simple example running
I’ve created the
pdk-experiments repository,
where I’ll write various examples of how the plugin system might work. The first
experiment is in the
dynamic-simple
directory, with a small example of how to dynamically load plugins with Rust.
We first need a crate called common
, which defines the interface shared by the
plugin and the runtime in the main binary. In this case it’s just a pointer to a
function with the C ABI. We can specify the ABI with extern "C"
, or simply
extern
, as "C"
is already its default value [1]. To keep it simple
it’ll just compute the minimum between two integers:
common/src/lib.rs
pub type MinFunction = unsafe extern fn(i32, i32) -> i32;
With it, the plugin crate may export its own implementation. In this case I’ll
declare a static
variable, but the example showcases how extern
may work as
well. Since we want to use the C ABI, we’ll have to specify crate-type
as
cdylib
in our Cargo.toml
. Note that #[no_mangle]
is necessary so that the
variable’s name isn’t mangled and
we can access it when dynamically loading the library.
plugin-sample/src/lib.rs
#[no_mangle]
pub static with_static: MinFunction = min;
pub extern fn min(a: i32, b: i32) -> i32 {
a.min(b)
}
Finally, the main binary can load the library with libloading
,
which requires a bit of unsafe
. I was looking forward to using a different
library because of how easy it seems to end up with undefined behaviour in that
case. I found out sharedlib
was abandoned, as no commits had been
made since 2017, leaving dlopen
as the only alternative. Which was
updated two years ago as well, but their GitHub repo seemed somewhat active in
comparison.
For now, I’ll just use libloading
for being the most popular crate, and
perhaps I’ll consider using dlopen
in the future. In terms of relevant
features and performance they’re pretty close anyway [2]. Here’s
what the code looks like:
src/main.rs
fn run_plugin(path: &str) -> Result<(), libloading::Error> {
unsafe {
let library = Library::new(path)?;
let min = library.get::<*mut MinFunction>(b"plugin_function\0")?.read();
println!("Running plugin:");
println!(" min(1, 2): {}", min(1, 2));
println!(" min(-10, 10): {}", min(-10, 10));
println!(" min(2000, 2000): {}", min(2000, 2000));
}
Ok(())
}
We can run it with the following commands (though the Makefile
in the repo
will do everything for you):
$ cd plugin-sample
$ cargo build --release
$ cd ..
$ cargo run --release -- plugin-sample/target/release/libplugin_sample.so
Running plugin:
min(1, 2): 1
min(-10, 10): -10
min(2000, 2000): 2000
Cool! This raises a few questions that I should learn more about:
Since we’re using the C ABI, is it perhaps best to declare the bindings in C? The
common
crate I introduced earlier could just be a header.There are many options to configure
crate-type
as a dynamic library. What are they and which one should I choose?I faintly remember that
rlib
files are Rust-only objects with additional metadata for things like generics. Could that possibly work at runtime? As in, is there an equivalent to COM in Rust, or maybe like JAR files in Java?
3.3. Generating bindings
The public interface for the plugins can be written either in Rust (thanks to
extern "C"
) or directly in C. There are two commonly used projects when
writing bindings:
rust-bindgen
generates Rust bindings from C codecbindgen
is the opposite; it generates C bindings from Rust code.
Some examples of its usage:
hyper
is a crate completely written in Rust that exposes C headers for compatibility, so it usescbindgen
to generate them automatically.pipewire_rs
exposes the interface of PipeWire, written in C, so that it’s also available from Rust, thanks torust-bindgen
.
Since we’re going to write the plugin system in Rust, the most appropiate choice
would be to use Rust for the interface as well. And if we wanted to make the
plugin interface available to other languages — which is not a concern right
now — it’d be as “easy” as setting up cbindgen
.
3.4. crate-type
values
There are two ways to configure
dynamic linking with the crate-type
field in the crate’s Cargo.toml
:
dylib
cdylib
Once again, this difference has to do with the ABIs in the dynamic library
[3]. cdylib
is meant for linking into C/C++ programs (so it strips away
all functions that aren’t publicly exported), and dylib
is meant for Rust
libraries.
When compiling the previous example with dylib
, the resulting shared object
for the plugin has a size of 4.8Mb, whereas with cdylib
it’s just 2.9Mb. So
while both of these will work for our C ABI, cdylib
is clearly the more
appropiate choice.
3.5. rlib
files
rlib
is another value for crate-type
to generate Rust static libraries,
which can then be imported with extern crate crate_name
[3]. But since
rlib
files are static libraries, they can’t be loaded at runtime, so they’re
of no use in a plugin system.
Here’s a crazy idea though: What if the rlib
files were dynamically loaded as
plugins with the help of MIRI? I recently
learned about it, and quoting its official documentation:
[MIRI is] an experimental interpreter for Rust’s mid-level intermediate representation (MIR). It can run binaries and test suites of cargo projects and detect certain classes of undefined behavior.
You can use Miri to emulate programs on other targets, e.g., to ensure that byte-level data manipulation works correctly both on little-endian and big-endian systems.
Hmm. Could it possibly be used to interpret Rust code? In some way this would be very similar to using WebAssembly, but theoretically with less friction, as MIR is specific to Rust and plugin development would be as easy as in the case of dynamic loading with Rust-to-Rust FFI. A few things to consider:
Is this even possible?
The Rust compiler itself uses MIRI to evaluate constant expressions [4] via the
rustc_mir
crate. But taking a quick look it seems to be created specifically for the compiler, at a very low level, and without that much documentation. Plus, it’s nightly-only. It does seem possible, but I wasn’t able to get a simple example working.Is MIR stable?
MIR is unfortunately unstable [5], so we’d have the same incompatibility problems between plugins and the main binary.
Is the overhead of MIRI worth it?
Considering the previous answers, no, but it was cool to consider and learn about :)
4. Can we use WebAssembly for this?
I also tried to write a simple example of how plugins would work with
WebAssembly. They are available in the
wasmer-simple
and
wasmtime-simple
directories. It took me considerably more effort to understand and get running
than with dynamic linking, even following
Free Masen’s guide and
Wasmer’s official documentation. But at least I didn’t
have to write that much unsafe
(it’s still needed to load or store data from
Wasm’s virtual memory, which I’ll explain later on).
The following snippet is what the plugin would look like. Note that this time we
use pub
without even considering static
to export a pointer to the function.
Wasm does have support for globals, but since handling complex types — those
other than numbers, a function in this case — isn’t trivial, it’s not worth it.
plugin-sample/src/lib.rs
#[no_mangle]
pub fn with_extern(a: i32, b: i32) -> i32 {
a.min(b)
}
For the runtime, since it’s a simple example I’ll try both
Wasmer and Wasmtime. For more complex
things I’ll just use Wasmer, since in an
earlier post it seemed like the best
alternative. I also wanted to try
wasm_plugin
, which includes
procedural macros to help reduce the overall boilerplate (which will be more
important later on), but it seems to be abandoned since 2019. It’s interesting
to try the lower-level interface of the runtimes myself to learn more about it
anyway, so I’ll just use that for now. If we ended up using Wasm for Tremor I’d
try updating and maintaining wasm_plugin
to keep the code boilerplate-free.
src/main.rs
pub fn run_plugin(path: &str) -> Result<(), Box<dyn Error>> {
// For reference, Feather also reads the plugins with `fs::read`:
// https://github.com/feather-rs/feather/blob/07c64678f80ff77be3dbd3d99fbe5558b4e72c97/quill/cargo-quill/src/main.rs#L107
let module_wat = fs::read(&path)?;
let store = Store::default();
let module = Module::new(&store, &module_wat)?;
let import_object = imports! {};
let instance = Instance::new(&module, &import_object)?;
println!("Running plugin:");
let min_extern: NativeFunc<(i32, i32), i32> = instance.exports.get_native_function("with_extern")?;
println!(" min(1, 2): {}", min_extern.call(1, 2)?);
println!(" min(-10, 10): {}", min_extern.call(-10, 10)?);
println!(" min(2000, 2000): {}", min_extern.call(2000, 2000)?);
Ok(())
}
src/main.rs
pub fn run_plugin(path: &str) -> Result<(), Box<dyn Error>> {
let engine = Engine::default();
let module = Module::from_file(&engine, &path)?;
let mut store = Store::new(
&engine,
WasmState {},
);
let imports = [];
let instance = Instance::new(&mut store, &module, &imports)?;
println!("Running plugin:");
let min_extern = instance.get_typed_func::<(i32, i32), i32, _>(&mut store, "with_extern")?;
println!(" min(1, 2): {}", min_extern.call(&mut store, (1, 2))?);
println!(" min(-10, 10): {}", min_extern.call(&mut store, (-10, 10))?);
println!(" min(2000, 2000): {}", min_extern.call(&mut store, (2000, 2000))?);
Ok(())
}
For a simple example they’re pretty much the same. Running them:
$ rustup target add wasm32-wasi
$ cd plugin-simple
$ cargo build --target wasm32-wasi --release
$ cd ..
$ cargo run --release -- plugin-sample/target/wasm32-wasi/release/plugin_sample.wasm
Running plugin:
min(1, 2): 1
min(-10, 10): -10
min(2000, 2000): 2000
AFAIK Rust has multiple compilation targets for Wasm. Which one should I be using?
What’s the difference between the
wasmer
crate andwasmer_runtime
?What about exporting types more complex than an
i32
? Is it possible to export a struct that implements a specific trait?
4.1. WebAssembly targets
Wasmer docs don’t mention this much because targets are related to the plugin, rather than the runtime. But Wasmtime’s book does include a section about compiling Rust to WebAssembly:
wasm32-wasi
- when using wasmtime this is likely what you’ll be using. The WASI target is integrated into the standard library and is intended on producing standalone binaries.
wasm32-unknown-unknown
- this target, like the WASI one, is focused on producing single *.wasm binaries. The standard library, however, is largely stubbed out since the "unknown" part of the target means libstd can’t assume anything. This means that while binaries will likely work in wasmtime, common conveniences like println! or panic! won’t work.
wasm32-unknown-emscripten
- this target is intended to work in a web browser and produces a *.wasm file coupled with a *.js file, and it is not compatible with wasmtime.
So basically what we need is wasm32-wasi
. By the way, even though Wasmer was
my initial choice, to be honest I found Wasmtime’s docs to be much more detailed
and well-organized.
4.2. wasmer
vs wasmer_runtime
This was confusing for me at first, since both crates seem to have a very
similar interface and almost the same set of authors. Some tutorials used
wasmer
, others wasmer_runtime
.
The difference seems to be that wasmer_runtime
was updated about a year ago,
while wasmer
got bumped to v2.0.0 just two months ago. The last release of
wasmer_runtime
is v0.17 (v0.18 seems to be yanked), and the first one of
wasmer
is v0.17 as well, so my bet is that wasmer_runtime
is the name of the
crate their team used previously, and they eventually deprecated it in favor of
wasmer
.
I’ve opened an issue upstream so that this is hopefully more clear to future users.
4.3. More complex types
The WebAssembly specification only defines integers and floating point as its supported types [6]. There are a few ways to handle non-trivial types like structs or enums:
4.3.1. The Interface Types Proposal
This proposal for WebAssembly defines the binary format for encoding and decoding the newly supported types, and specifies a set of instructions to transform the data between WebAssembly and the outside world. Note that this proposal is not meant to define a fixed representation of e.g., a string in Wasm, it attempts to allow representation-agnostic high-level value types.
These new high-level value types are called interface types. The current proposal defines them as:
Floating point of 32 and 64 bits
Signed and unsigned integers of up to 64 bits
Single characters
Lists (a string would be a list of characters)
Records (basically like Rust structs)
Variants (basically like Rust enums or unions, it would enable
Option
,Result
, etc)
Additionally, language-neutral interfaces for WASI can be defined with the
experimental witx
files [8], which makes it easy to define a common ABI
in order to interact from different programming languages [9]
[10]. They look like this:
(use "errno.witx")
;;; Add two integers
(module $calculator
(@interface func (export "add")
(param $lh s32)
(param $rh s32)
(result $error $errno)
(result $res s32)
)
)
Please refer to the proposal itself for more detailed information; it’s very well explained.
The main problem is that this proposal is still at Phase 1. It’s still actively being worked on, and its specification is far from stable.
At the plugin level the
wasm_bindgen
crate seems to be ideal, and it already works well. It’s a very simple procedural macro that can be added to the exported functions in the plugin in order to automatically add support for Interface Types.The
wiggle
crate can be used to generate Rust code fromwitx
files. For the previous snippet of code, wiggle’s macro will generate aCalculator
trait and the defined types in another module namedtypes
.The runtimes, however, don’t work with Interface types:
Wasmtime did support this in the past until their implementation was removed after being considered outdated. As this issue indicates, it still hasn’t been updated.
Wasmer has the
wasmer_interface_types
crate, but with a similar story; it’s outdated. There’s this issue as a continuation of Wasmtime’s, which explains the situation.
In the end I wasn’t able to get Interface Types working, nor I considered them worth my time, as it’s still too early.
4.3.2. The hacky-but-working way
Via pointers and a shared memory. The user has to first construct and serialize the complex types, and then save them into Wasm’s memory, which can be accessed directly by the runtime or the plugin with pointers. This is what Feather or Veloren do, in case you want more details.
Not only does this require a serialization and deserialization step and
writing/reading all the data from memory, but also it’s very cumbersome to use
and easy to mess up. It’s somewhat trivial though, so a procedural macro like
the now outdated wasmer-plugin
could simplify it. For now, bincode
can be used for the serialization steps manually.
I did try this and got it working by following Free Masen’s guide, but it wasn’t worth exploring in depth because it requires a [de]serialization step Tremor can’t afford.
4.3.3. Separate executables
Another way I’ve seen to work around this is by using Wasm as an executable instead of a library. Zellij does this, and communicates with the plugins via the standard input and standard output streams.
Unfortunately, this also requires [de]serializing and has the overhead of writing/reading all the data from stdin and stdout.
4.3.4. Conclusion
Ultimately, this has forced me to discard WebAssembly for Tremor’s Plugin Development Kit. Performance is really important in this specific case, and Wasm doesn’t offer a solution to efficiently pass data between the host and the plugin. For most use cases Wasm still may be of interest, so hopefully I’ve included enough links and ideas so that you can get started if it’s the way you want to go.
5. Wrapping up
Today we’ve learned a lot of things both about dynamic loading and WebAssembly. Unfortunately, I’ve had to discard the latter for Tremor’s case, but it may be of interest to projects that aren’t so focused on performance. Or maybe by the time you, the reader, have finished this article, the issues I found are already stabilized and broadly used, which would make Wasm an even more appealing choice.
In the next post, I will try to get a more advanced example of a plugin system with dynamic loading. The goal is to simulate the end-product by writing it as close as possible to how it would be used in real life. This way, we will also be able to benchmark it against a statically linked implementation and analyze the performance overhead we’ll end up getting.
I did write a few benchmarks for the examples provided in this article, but they
aren’t fair at all nor representative of a real-life situation. They simply load
the plugin and run the min
function once, or in the case of dynamic loading
twice (one with static
and another with extern
). And since they’re very
simple examples, the Wasm ones don’t include the [de]serialization part that may
introduce even more overhead. Knowing that, the results are the following, which
is more or less what I was expecting:
test test::dynamic_simple ... bench: 139,702 ns/iter (+/- 34,699)
test test::wasmer_setup ... bench: 967,633 ns/iter (+/- 203,933)
test test::wasmtime_setup ... bench: 988,500 ns/iter (+/- 363,244)
I hope you enjoyed reading this post and that you learned something from it! You can leave any suggestions in the comments below.
References
[2] Compare with other libraries — GitHub szymonwieloch/rust-dlopen
[3] What is the Difference Between
dylib
andcdylib
— users.rust-lang.org[7] Support for Interface Types in wasmtime API bytecodealliance/wasmtime#677
[9] Defining language-neutral interfaces like WASI (revisited) — GitHub WebAssembly/interface-types
[10] Writing a simple WASM API layer using interface types and Wasmtime — radu’s blog
You can leave a comment for this article on GitHub.