Loading WebAssembly modules in Swift

Designing and initializing a Wasm interpreter in Swift | Written by Anthony

As I mentioned in a previous post, to help achieve our goal of building the easiest and fastest way to share the things that matter to you, we are trying to save time by compiling a lot of our mission-critical code to WebAssembly (Wasm) and then executing those WebAssembly binaries on all of the platforms we support. One of the first platforms we are supporting is iOS, which means, of course, we need to get WebAssembly running quickly and stably on iOS.

In that previous post, I wrote about how we wrapped the Wasm3 C library in a Swift package called CWasm3. Using CWasm3, we were able to execute WebAssembly binaries on iOS. However, it required a lot of boilerplate code and awkward conversions between safe and unsafe Swift types.

func testCanCallAndReceiveReturnValueFromAdd() throws {
	let environment = m3_NewEnvironment()
	defer { m3_FreeEnvironment(environment) }

	let runtime = m3_NewRuntime(environment, 512, nil)
	defer { m3_FreeRuntime(runtime) }

	var addBytes = try addWasm()
	defer { addBytes.removeAll() }

	var module: IM3Module?
	XCTAssertNil(m3_ParseModule(environment, &module, addBytes, UInt32(addBytes.count)))
	XCTAssertNil(m3_LoadModule(runtime, module))

	var addFunction: IM3Function?
	XCTAssertNil(m3_FindFunction(&addFunction, runtime, "add"))

	let result = ["3", "12345"].withCStrings { (args) -> Int32 in
		let size = UnsafeMutablePointer<Int>.allocate(capacity: 1)
		let output = UnsafeMutablePointer<Int32>.allocate(capacity: 1)
		XCTAssertNil(wasm3_CallWithArgs(addFunction, UInt32(2), args, size, output))
		XCTAssertEqual(MemoryLayout<Int32>.size, size.pointee)
		return output.pointee
	}

	XCTAssertEqual(12348, result)
}

Although it was nice to see this working and to know we could execute WebAssembly in our iOS apps, obviously we needed to do a better job abstracting away this boilerplate and unsafe code. We did so in WasmInterpreter, a Swift package we’ve open-sourced. I’ll spend the rest of this blog post and a few follow-up posts describing the design and implementation of WasmInterpreter.

Designing the Wasm interpreter’s API

One of the biggest advantages of WebAssembly is its security model. Each WebAssembly module executes within a sandboxed environment. Therefore, we designed the WasmInterpreter class to be a (very fancy) wrapper around a single Wasm module. WasmInterpreter parses and loads the Wasm module during init (throwing an error if the module cannot be loaded) and frees the module in deinit. The public interface of WasmInterpreter is limited to methods for calling the Wasm module’s functions, supplying imported (“native”) functions to the module, or accessing the interpreter’s heap.

Ultimately, WasmInterpreter was designed to be a relatively short-lived object. It should live long enough to complete a single task and then be discarded. For example, in the Shareup iOS app, we use WebAssembly to handle the encryption and decryption of files. We create a new instance of WasmInterpreter each time we want to encrypt or decrypt a file. We don’t reuse instances when encrypting or decrypting multiple files. We made this decision for multiple reasons. First, the time required to create a new instance of WasmInterpreter and parse and load a Wasm module paled in comparison to the time it took to actually encrypt or decrypt a file. Second, starting each encryption or decryption operation with a clean WebAssembly environment reduced our exposure to bugs or security vulnerabilities. Fastly made the same decision when designing Compute@Edge.

Initialization

Since we designed WasmInterpreter to wrap a single WebAssembly module, its init() function needed to accomplish a few different tasks.

  1. create new Wasm environment
  2. create new Wasm runtime
  3. parse the Wasm module
  4. load the Wasm module

If any of those tasks failed, the instance of WasmInterpreter would be unusable. So, we made WasmInterpreter's initializer failable, and we threw an error if any one those tasks failed.

Additionally, in order to call the module’s functions in the future, we needed to maintain a reference to the environment, runtime, module bytes, and loaded module.

The code is fairly straightforward:

public final class WasmInterpreter {
    private var _environment: IM3Environment
    private var _runtime: IM3Runtime
    private var _moduleAndBytes: (IM3Module, [UInt8])
    
    public init(stackSize: UInt32, module bytes: [UInt8]) throws {
        guard let environment = m3_NewEnvironment() else {
            throw WasmInterpreterError.couldNotLoadEnvironment
        }

        guard let runtime = m3_NewRuntime(environment, stackSize, nil) else {
            throw WasmInterpreterError.couldNotLoadRuntime
        }

        var mod: IM3Module?
        try WasmInterpreter.check(m3_ParseModule(environment, &mod, bytes, UInt32(bytes.count)))
        guard let module = mod else { throw WasmInterpreterError.couldNotParseModule }
        try WasmInterpreter.check(m3_LoadModule(runtime, module))

        _environment = environment
        _runtime = runtime
        _moduleAndBytes = (module, bytes)
    }
}

The only new code is WasmInterpreter.check(), which checks Wasm3 function return values for errors and, if one is found, throws a Swift error. The actual static function is short and simple:

extension WasmInterpreter {
    private static func check(_ block: @autoclosure () throws -> M3Result?) throws {
        if let result = try block() {
            throw WasmInterpreterError.wasm3Error(String(cString: result))
        }
    }
}

This worked because Wasm3 functions return a null-terminated C string in the case of an error. The string describes the error encountered.

We wrapped the calling function in an autoclosure to make the call site more ergonomic. If we had used a standard closure, we would have had to call check() like this:

try WasmInterpreter.check({ m3_LoadModule(runtime, module) })

or

try WasmInterpreter.check() { m3_LoadModule(runtime, module) }

…which didn’t look nearly as nice to us.

Deinitialization

Deinitialization was not difficult. We simply needed to clean up the memory we allocated in init(). That meant we needed to free the Wasm environment and runtime.

deinit {
    m3_FreeRuntime(_runtime)
    m3_FreeEnvironment(_environment)
}

The module’s memory is freed when the runtime is freed.

Creating an instance of WasmInterpreter

The value of even this early version of our WasmInterpreter abstraction became apparent as soon as we tried to use it to parse and load a WebAssembly module.

let moduleBase64 = "AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABags="
let moduleBytes = Array<UInt8>(Data(base64Encoded: moduleBase64)!)

let vm = try WasmInterpreter(stackSize: 512 * 1024, module: moduleBytes)

We replaced around ten lines of tricky, boilerplate-filled code with a single call to init(stackSize:module:) and an implicitly-called deinit function.

Next…

In a follow-up article, I’ll write about how to call functions in WebAssembly modules. If you don’t want to wait, you can look at the code now. Clone WasmInterpreter and start playing around. A good place to start is AddModule, which is a wrapper around a very simple Wasm module that adds numbers together. Be sure to let me know if you have any thoughts or use WasmInterpreter to build something cool.