Wasmex (wasmex v0.9.1)
Wasmex is a fast and secure WebAssembly and WASI runtime for Elixir. It enables lightweight WebAssembly containers to be run in your Elixir backend. This is the main module, providing most of the needed API to run Wasm binaries.
It uses wasmtime to execute Wasm binaries through a Rust NIF.
Each Wasm module must be compiled from a .wasm
or '.wat' file.
A compiled Wasm module can be instantiated which usually happens in a GenServer.
To start the GenServer, start_link/1
is used:
iex> bytes = File.read!(TestHelper.wasm_test_file_path())
iex> {:ok, instance_pid} = Wasmex.start_link(%{bytes: bytes})
iex> Wasmex.call_function(instance_pid, "sum", [50, -8])
{:ok, [42]}
Memory of a Wasm instance can be read/written using Wasmex.Memory
:
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, store} = Wasmex.store(pid)
iex> {:ok, memory} = Wasmex.memory(pid)
iex> index = 4
iex> Wasmex.Memory.set_byte(store, memory, index, 42)
iex> Wasmex.Memory.get_byte(store, memory, index)
42
See start_link/1
for starting and configuring a Wasm instance and call_function/3
for details about calling Wasm functions.
Summary
Functions
Calls a function with the given name
and params
on the Wasm instance
and returns its results.
Returns a specification to start this module under a supervisor.
Returns whether a function export with the given name
exists in the Wasm instance.
Returns the exported Wasmex.Memory
of the given Wasm instance.
Returns the Wasmex.Module
of the Wasm instance.
Starts a GenServer which compiles and instantiates a Wasm module from the given .wasm
or .wat
bytes.
Returns the Wasmex.Store
of the Wasm instance.
Functions
call_function(pid, name, params, timeout \\ 5000)
@spec call_function(pid(), String.t() | atom(), [number()], pos_integer()) :: {:ok, [number()]} | {:error, any()}
Calls a function with the given name
and params
on the Wasm instance
and returns its results.
Example
iex> wat = "(module
...> (func $helloWorld (result i32) (i32.const 42))
...> (export \"hello_world\" (func $helloWorld))
...> )"
iex> {:ok, pid} = Wasmex.start_link(%{bytes: wat})
iex> Wasmex.call_function(pid, "hello_world", [])
{:ok, [42]}
String Handling
Strings are common candidates for function parameters and return values. However, they can not be used directly when calling Wasm functions, because Wasm only knows number data types. Since Strings are just "a bunch of bytes", we can write these bytes into memory and give our Wasm function a pointer to that memory location.
Strings as Function Parameters
Given we have the following Rust function that returns the first byte of a string input compiled to Wasm:
#[no_mangle]
pub extern "C" fn string_first_byte(bytes: *const u8, length: usize) -> u8 {
let slice = unsafe { slice::from_raw_parts(bytes, length) };
match slice.first() {
Some(&i) => i,
None => 0,
}
}
This Wasm function can be called from Elixir:
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, store} = Wasmex.store(pid)
iex> {:ok, memory} = Wasmex.memory(pid)
iex> index = 42
iex> string = "hello, world"
iex> Wasmex.Memory.write_binary(store, memory, index, string)
iex> Wasmex.call_function(pid, "string_first_byte", [index, String.length(string)])
{:ok, [104]} # 104 is the letter "h" in ASCII/UTF-8 encoding
Please note that Elixir and Rust assume Strings to be valid UTF-8. Take care when handling other encodings.
Strings as Function Return Values
Given we have the following Rust function compiled to Wasm (again, copied from our test code):
#[no_mangle]
pub extern "C" fn string() -> *const u8 {
b"Hello, World!".as_ptr()
}
This function returns a pointer to its memory. This memory location contains the String "Hello, World!".
This is how we would receive this String in Elixir:
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, store} = Wasmex.store(pid)
iex> {:ok, memory} = Wasmex.memory(pid)
iex> {:ok, [pointer]} = Wasmex.call_function(pid, "string", [])
iex> Wasmex.Memory.read_string(store, memory, pointer, 13)
"Hello, World!"
Specifying a timeout
The default timeout for call_function
is 5 seconds, or 5000 milliseconds.
When calling a long-running function, you can specify a timeout value (in milliseconds) for this call.
iex> wat = "(module
...> (func $helloWorld (result i32) (i32.const 42))
...> (export \"hello_world\" (func $helloWorld))
...> )"
iex> {:ok, pid} = Wasmex.start_link(%{bytes: wat})
iex> Wasmex.call_function(pid, "hello_world", [], 10_000)
{:ok, [42]}
In the example above, we specify a timeout of 10 seconds.
child_spec(init_arg)
Returns a specification to start this module under a supervisor.
See Supervisor
.
function_exists(pid, name)
Returns whether a function export with the given name
exists in the Wasm instance.
Examples
iex> wat = "(module
...> (func $helloWorld (result i32) (i32.const 42))
...> (export \"hello_world\" (func $helloWorld))
...> )"
iex> {:ok, pid} = Wasmex.start_link(%{bytes: wat})
iex> Wasmex.function_exists(pid, "hello_world")
true
iex> Wasmex.function_exists(pid, "something_else")
false
memory(pid)
@spec memory(pid()) :: {:ok, Wasmex.Memory.t()} | {:error, any()}
Returns the exported Wasmex.Memory
of the given Wasm instance.
Example
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, %Wasmex.Memory{}} = Wasmex.memory(pid)
module(pid)
@spec module(pid()) :: {:ok, Wasmex.Module.t()} | {:error, any()}
Returns the Wasmex.Module
of the Wasm instance.
Example
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, %Wasmex.Module{}} = Wasmex.module(pid)
start_link(opts)
Starts a GenServer which compiles and instantiates a Wasm module from the given .wasm
or .wat
bytes.
iex> bytes = File.read!(TestHelper.wasm_test_file_path())
iex> {:ok, _pid} = Wasmex.start_link(%{bytes: bytes})
Alternatively, a precompiled Wasmex.Module
can be passed with its Wasmex.Store
:
iex> {:ok, store} = Wasmex.Store.new()
iex> {:ok, module} = Wasmex.Module.compile(store, "(module)")
iex> {:ok, _pid} = Wasmex.start_link(%{store: store, module: module})
Imports
Wasm imports may be given as an additional option. Imports are a map of namespace-name to namespaces. Each namespace is in turn a map of import-name to import.
iex> wat = "(module
...> (import \"IO\" \"inspect\" (func $log (param i32)))
...> )"
iex> io_inspect = fn (%{_memory: %Wasmex.Memory{}, _caller: %Wasmex.StoreOrCaller{}} = _context, i) ->
...> IO.inspect(i)
...> end
iex> imports = %{
...> IO: %{
...> inspect: {:fn, [:i32], [], io_inspect},
...> }
...> }
iex> {:ok, _pid} = Wasmex.start_link(%{bytes: wat, imports: imports})
In the example above, we import the "IO"
namespace.
That namespace is a map of imports, in this case the inspect
function, which is represented with a tuple of:
- the import type:
:fn
(a function), - the functions parameter types:
[:i32]
, - the functions return types:
[]
, and - the function to be executed:
fn (_context, i) -> IO.inspect(i) end
The first param the function receives is always the call context:
%{
memory: %Wasmex.Memory{},
caller: %Wasmex.StoreOrCaller{}
} = context
The caller
MUST be used instead of a store
in Wasmex API functions.
Wasmex might deadlock if the store
is used instead of the caller
(because running the Wasm instance holds a Mutex lock on the store
so
we cannot use that store again during the execution of an imported function).
The caller, however, MUST NOT be used outside of the imported functions scope.
All other params are regular parameters as specified by the parameter type list.
Valid parameter/return types are:
:i32
a 32 bit integer:i64
a 64 bit integer:v128
a 128 bit unsigned integer:f32
a 32 bit float:f64
a 64 bit float
Linking multiple Wasm modules
Wasm module links
may be given as an additional option.
Links is a map of module names to Wasm modules.
iex> calculator_wasm = File.read!(TestHelper.wasm_link_test_file_path())
iex> utils_wasm = File.read!(TestHelper.wasm_test_file_path())
iex> links = %{utils: %{bytes: utils_wasm}}
iex> {:ok, pid} = Wasmex.start_link(%{bytes: calculator_wasm, links: links})
iex> Wasmex.call_function(pid, "sum_range", [1, 5])
{:ok, [15]}
It is also possible to link an already compiled module. This improves performance if the same module is used many times by compiling it only once.
iex> calculator_wasm = File.read!(TestHelper.wasm_link_test_file_path())
iex> utils_wasm = File.read!(TestHelper.wasm_test_file_path())
iex> {:ok, store} = Wasmex.Store.new()
iex> {:ok, utils_module} = Wasmex.Module.compile(store, utils_wasm)
iex> links = %{utils: %{module: utils_module}}
iex> {:ok, pid} = Wasmex.start_link(%{bytes: calculator_wasm, links: links, store: store})
iex> Wasmex.call_function(pid, "sum_range", [1, 5])
{:ok, [15]}
Important: Make sure to use the same store for the linked modules and the main module.
When linking multiple Wasm modules, it is important to handle their dependencies properly.
This can be achieved by providing a map of module names to their respective Wasm modules in the links
option.
For example, if we have a main module that depends on a calculator module, and the calculator module depends on a utils module, we can link them as follows:
iex> main_wasm = File.read!(TestHelper.wasm_link_dep_test_file_path())
iex> calculator_wasm = File.read!(TestHelper.wasm_link_test_file_path())
iex> utils_wasm = File.read!(TestHelper.wasm_test_file_path())
iex> links = %{
...> calculator: %{
...> bytes: calculator_wasm,
...> links: %{
...> utils: %{bytes: utils_wasm}
...> }
...> }
...> }
iex> {:ok, _pid} = Wasmex.start_link(%{bytes: main_wasm, links: links})
In this example, the links
map specifies that the calculator
module depends on the utils
module.
The links
map is a nested map, where each module name is associated with a map that contains the Wasm module bytes and its dependencies.
The links
map can also be used to link an already compiled module, as shown in the previous examples.
WASI
Optionally, modules can be run with WebAssembly System Interface (WASI) support. WASI functions are provided as native implementations by default but could be overridden with Elixir provided functions.
iex> {:ok, _pid } = Wasmex.start_link(%{bytes: "(module)", wasi: true})
It is possible to overwrite the default WASI functions using the imports map:
iex> imports = %{
...> wasi_snapshot_preview1: %{
...> random_get: {:fn, [:i32, :i32], [:i32],
...> fn %{memory: memory, caller: caller}, address, size ->
...> Enum.each(0..size, fn index ->
...> Wasmex.Memory.set_byte(caller, memory, address + index, 0)
...> end)
...> # We chose `4` as the random number with a fair dice roll
...> Wasmex.Memory.set_byte(caller, memory, address, 4)
...> 0
...> end
...> }
...> }
...> }
iex> {:ok, _pid} = Wasmex.start_link(%{bytes: "(module)", imports: imports})
In the example above, we overwrite the random_get
function which is (as all other WASI functions)
implemented in Rust. This way our Elixir implementation of random_get
is used instead of the
default WASI implementation.
Oftentimes, WASI programs need additional inputs like environment variables, arguments,
or file system access.
These can configured by additional Wasmex.Wasi.WasiOptions
:
iex> wasi_options = %Wasmex.Wasi.WasiOptions{
...> args: ["hello", "from elixir"],
...> env: %{
...> "A_NAME_MAPS" => "to a value",
...> "THE_TEST_WASI_FILE" => "prints all environment variables"
...> },
...> preopen: [%Wasmex.Wasi.PreopenOptions{path: "lib", alias: "src"}]
...> }
iex> {:ok, _pid} = Wasmex.start_link(%{bytes: "(module)", wasi: wasi_options})
It is also possible to capture stdout, stdin, or stderr of a WASI program using pipes:
iex> {:ok, stdin} = Wasmex.Pipe.new()
iex> {:ok, stdout} = Wasmex.Pipe.new()
iex> wasi_options = %Wasmex.Wasi.WasiOptions{
...> args: ["wasmex", "echo"],
...> stdin: stdin,
...> stdout: stdout
...> }
iex> {:ok, pid } = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasi_test_file_path()), wasi: wasi_options})
iex> Wasmex.Pipe.write(stdin, "Hey! It compiles! Ship it!")
iex> Wasmex.Pipe.seek(stdin, 0)
iex> {:ok, _} = Wasmex.call_function(pid, :_start, [])
iex> Wasmex.Pipe.seek(stdout, 0)
iex> Wasmex.Pipe.read(stdout)
"Hey! It compiles! Ship it!\n"
In the example above, we call a WASI program which echoes a line from stdin back to stdout.
store(pid)
@spec store(pid()) :: {:ok, Wasmex.StoreOrCaller.t()} | {:error, any()}
Returns the Wasmex.Store
of the Wasm instance.
Example
iex> {:ok, pid} = Wasmex.start_link(%{bytes: File.read!(TestHelper.wasm_test_file_path())})
iex> {:ok, %Wasmex.StoreOrCaller{}} = Wasmex.store(pid)