A Look Into Creating a Modular WASM Application Outside the Web

Risto Mikkola
WAsDE
Published in
8 min readJun 3, 2020

Ever-more often, adaptivity is required from modern applications. The dynamic linking of WASM would — in contrast to native application platforms — provide means for composing applications and their new features during runtime in a platform-neutral way.

Photo by Glen Carrie on Unsplash

Dynamic linking of modules is one of the features that has been recognized important for WebAssembly. So far, it has been introduced for WebAssembly inside the browser as a part of the key features already at MVP. We decided to experiment with how well the proposed mechanisms work outside the browser so that external modules could be treated like libraries that introduce different functionality.

To demonstrate this, let us introduce a simple chatbot that can load modules dynamically, call specified functions in them, and unload previously loaded modules. The example design consists of three modules, the main module, and two chatbot personality modules, which we call Marvin and Steve. The sequence of operations that the chatbot runs is presented below. In it, Marvin and Steve are loaded dynamically from a command-line interface, and only one of them can be present at a time, clearly calling for dynamically loaded functions. While loaded, both Marvin and Steve respond to the input given by the end-user. The runtime provides the functionality for loading and unloading the modules.

Implementation of the Chatbot

We could not find an existing solution for creating modular applications outside of the web, so we had to create our own system for it. Many runtimes, including Wasm3 and WAMR, run WASM modules in sandboxed environments; therefore, a module cannot directly import another module’s exports including tables, memory and functions. However, all the four runtimes we experimented with (Wasm3, WAMR, Wasmtime and Wasmer) allow importing host defined functions from the host environment to the guest module.

When a module instance calls an imported host function, the function has the option to access the module’s memory through the function’s parameters. To be able to access another module’s functions and memory, a module needs to have access to the other module’s instance. For this reason in our implementation module instances are stored in a globally accessible collection. It is then possible to access an instance of a module through a host function using the collection. The host function can copy memory from one module to another and call functions from the accessed module. This functionality is the foundation around which we start implementing dynamic loading of the modules and function calls between the modules.

#[link(wasm_import_module = "env")]
extern "C" {
pub fn Call(
module_name: *const c_char,
function_name: *const c_char
) -> i32;
pub fn Unload(module_name: *const c_char) -> i32;
pub fn Load(module_name: *const c_char) -> i32;
}

The host defines functions for loading and unloading modules and calling module functions. In the code above, we specify the host defined function signatures inside a module. This will create imports of the functions in the generated WASM module. Calling these functions inside a module will invoke the imported host functions. The function parameters and return values use types that are possible to convert to those currently supported by WASM.

Each module can import the set of host functions as described above, and they all expose a function called run. The modules are also able to import functions of WASI. The main module uses WASI functionality to access the standard input and output which it uses to interact with the user.

fn install_personality(personality: &str) {
println!("Installing personality {}...", personality);

let filename = CString::new(format!("{}.wasm", personality))
.expect("Cant convert personality to c_str");
let function_name = CString::new("run")
.expect("Cant convert function name to c_str");

unsafe {
if Load(filename.as_ptr()) != 0 {
println!(
"Failed to load the personality {}!",
personality
);
return;
}

if Call(filename.as_ptr(), function_name.as_ptr()) != 0 {
println!(
"Error running the personality {}!",
personality
);
}

if Unload(filename.as_ptr()) != 0 {
println!(
"Error unloading the personality {}!",
personality
);
}
}
}

The program provides the user with three options to choose from. Two of them allow loading different personalities and the third exits the program. Whenever a user chooses to load a personality the program calls the install_personality function defined in the above code sample. It constructs the name of the module to search for and a function, which both of the personalities export, named run. The main module then loads the personality using the Load function. Afterward, the Call function calls the run function of the loaded personality where it enters a loop waiting for a user’s input and returning an output. The access to the standard input and standard output is achieved by using WASI functionality. Whenever the run function terminates, the control is returned to the main module and the personality module is unloaded.

Implementation of our System

We created the system using multiple runtimes. The full code can be found in our GitHub repository. Below we illustrate parts of the system using the Wasmer implementation.

fn load(ctx: &mut Ctx, name: WasmPtr<u8, Array>) -> i32 {
// Get the name of the module being loaded
let memory = ctx.memory(0);
let name = name.get_utf8_string_with_nul(memory).unwrap();

// Load the module file into memory and compile it.
let mut file = File::open(name).unwrap();
let mut module_bytes = Vec::new();
file.read_to_end(&mut module_bytes).unwrap();
let module = compile(&module_bytes).unwrap();

// Create the ImportObject with WASI and our host functions.
let import_object = create_import_object(name, &module);
let instance = module.instantiate(&import_object).unwrap();

// Insert the module into the global collection.
let mut instance_map = INSTANCES.lock().unwrap();
(*instance_map).insert(name.to_string(), instance);
0
}

The Load host function above fetches a WASM binary by the name given as a parameter and instantiates it. The module is instantiated with imports which include WASI and our host functions. The instantiated module is placed into the global collection from where it can be accessed later.

fn unload(ctx: &mut Ctx, name: WasmPtr<u8, Array>) -> i32 {
// Get the name of the unloaded module from instance memory
let memory = ctx.memory(0);
let name = name.get_utf8_string_with_nul(memory).unwrap();

// Remove the instance from the global collection.
let mut instance_map = INSTANCES.lock().unwrap();
(*instance_map).remove(name);
0
}

The Unload host function takes a module name as its parameter and uses it to search for that particular module instance inside the global collection. Once found, the instance is taken out of the collection, unloading it and freeing all of the resources it uses. Afterward, the functionality it provided is no longer accessible to the other modules.

fn call(
ctx: &mut Ctx,
module_name: WasmPtr<u8, Array>,
function_name: WasmPtr<u8, Array>
) -> i32 {
// Get the module name from the instance memory
let caller_memory = ctx.memory(0);
let module_name = module_name
.get_utf8_string_with_nul(caller_memory)
.unwrap();

// Get the instance from the collection
let instance: Instance = {
let mut instance_map = INSTANCES.try_lock().unwrap();
(*instance_map)
.remove(module_name)
.expect("module {} is either not loaded or is in use")
};

// Get the function name from memory and invoke the function
let function_name = function_name
.get_utf8_string_with_nul(caller_memory).unwrap();
let function: Func<(), ()> = instance
.func(function_name).unwrap();
let result = function.call();
0
}

The Call host function, similarly to the Unload function, searches for an instance from the global collection. The function then invokes the instance’s exported function by a name given as a parameter.

Issues Communicating With the Host

During the development of the system, we encountered issues regarding the communication between the host and the guest. These include data types, ABI differences and the resulting API complexity. Similarly, many of the runtimes we used do not implement basic functionality to allocate or free memory inside a module. This complicates calling module functions that would use host allocated memory.

Also, currently, the development is not as pleasant as it could be. Instead of having different data representation types at hand, we are limited to only a few primitive types. This increases the complexity of the API and the produced solution is not elegant. For example, the runtime and the WASM module can be implemented in different languages that have different ABI, which requires data passed between them to be converted to a unified format. This could be partially solved by having interface types implemented by runtimes.

Limitations of the Current Implementation

The Call host function can currently only call functions that have no parameters. The called function signature is predetermined, which is a current limitation of our built system. However, it could be possible to call any function by providing its signature as a string along with the parameter data to the Call function. It would then use the signature to decode the parameter data and pass it as arguments to the function call. This is similar to how exported functions are called by runtimes such as Wasm3 and WAMR.

Functions often need to pass around more than just primitive values. Our implementation is currently lacking a way to do so. Some of the runtimes do not currently support shared memory between modules and thus for a module to be able to access a string defined in one module we need to copy it through the host. A host function would take the length and position of the data to copy from the first module and the position of the data to overwrite in the second module. To allocate a block of memory to be overwritten in the second module the modules can export a function that allocates memory within the module. Another function can be exported to free the allocated memory. These two functions could be called with the generic Call host function.

We have omitted error handling from some implementations of the system but reserved the return value of our host functions to signal the occurrence of an error. In a future implementation, the returned value could be interpreted as a boolean value indicating if an error occurred or not. In case of an error, a message could be stored in the runtime environment and a function could be used to fetch it if needed.

Summary and Conclusions

We set out to find a way to create modular applications for WASM outside of the web. The runtimes imposed restrictions on the design of our approach, such as not being able to share memory between two modules. Despite the restrictions, we were able to build a system for dynamically loading modules during runtime and got it working on multiple different runtimes. The current downside of our approach is that we have to sacrifice some of the advantages that could be gained with dynamic linking. This includes the speed of data transmission between modules. Currently, transmission requires copying instead of the modules having direct access to the data. To be able to implement dynamic linking, the runtimes must support intermodule resource sharing.

The experiments we have done highlight some of the limitations currently present in the WASM MVP. WASM is still at the beginning of its development and it is only natural that it will become more mature over time. With the implementation of features, such as interface types and dynamic linking, the development can be made significantly easier and more flexible. The possibilities of WASM would be extended greatly, allowing developers to achieve their envisioned systems. To make this possible, we hope that the existing proposals are finished not too far in the future.

In our view, dynamic linking should emerge as a standard feature in existing runtime projects. Meanwhile, while our approach may not be perfect, it provides a starting point for experimentations with a modular structure outside of the web. Within this scope, we are open to discussions and interested in any feedback or new ideas regarding modules in WebAssembly.

Collaborators

This post and related work was done in collaboration with Paulius Daubaris and Victor Bankowski.

--

--