How to crash your software with Rust and wasm-bindgen

2025-01-20

The Rust WebAssembly tooling is something of a miracle. I can write normal Rust code, sprinkle in a few binding annotations and, for the most part, it just works™. It even integrates quite nicely with TypeScript types, Web APIs and Promises/async.

However, just as you’re settling in with a slice of cake and glass of lemonade to write some Rust code for your web application, one class of problem is waiting to come and stomp all over your nice, type-safe picnic.

The issue is that Rust has strict rules around ownership and borrowing of values. In contrast, JavaScript:

JavaScript thus has no notion of an ‘owned’ value, or ‘borrowing’.

The key library used for generating JavaScript bindings, wasm-bindgen, papers over this difference. Whether you use owned values or references, they look identical from JavaScript-land. However, many rules are enforced at runtime - the worst time to discover that your code is wrong.

Let’s look at a couple of common pitfalls. The full code for these examples can be found in the rust-wasm-pitfalls repository.

Pitfall 1: Accidental loss of ownership

Since Rust doesn’t have a garbage collector, it needs other techniques for knowing when to clean things up. If a value is owned, it can be in one of two states at the end of the function:

In the second case, it is now safe to clean up the owned value. (If you’re familiar with RAII in C++, it’s the same thing.)

This can pose a problem when calling Rust functions from JavaScript. Consider this Rust code:

use wasm_bindgen::prelude::*;
use web_sys::console;

#[wasm_bindgen]
pub struct Foo;

#[wasm_bindgen]
impl Foo {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Foo {
        Foo
    }
}

#[wasm_bindgen(js_name = "borrowFoo")]
pub fn borrow_foo(_foo: &Foo) {
    console::log_1(&"Rust: borrowed a Foo".into());
}

#[wasm_bindgen(js_name = "consumeFoo")]
pub fn consume_foo(_foo: Foo) {
    console::log_1(&"Rust: consumed a Foo".into());
}

Here we have two functions that can take a Foo: borrow_foo by reference and consume_foo by move (ownership).

Our TypeScript code looks like this:

import { consumeFoo, borrowFoo, Foo } from "lib";

const foo = new Foo();

// Borrow foo (works)
borrowFoo(foo);

// Consume foo (works)
consumeFoo(foo); // <- transfers ownership of foo to the Rust function
// foo is dropped when the Rust function returns

// Borrow foo again (fails)
borrowFoo(foo); // <- foo is not valid anymore

First, we call borrowFoo(foo) which borrows foo. When the function finishes executing Rust does nothing - it doesn’t own the value.

Next, we call consumeFoo(foo). This function assumes ownership of the foo. When the function reaches its end, it still owns the value foo. The value will not be returned to the caller, so it may leak heap memory if not cleaned up now. Rust calls Drop::drop to destroy the object.

Finally, we call borrowFoo(foo) again. At this point, foo is already invalid. An exception is thrown immediately:

Rust: borrowed a Foo
Rust: consumed a Foo
error: Uncaught (in promise) Error: null pointer passed to rust
    throw new Error(getStringFromWasm0(arg0, arg1));
          ^
    at __wbindgen_throw (rust-wasm-pitfalls/lib-js/lib_bg.js:319:11)
    at <anonymous> (wasm://wasm/000288f6:1:34448)
    at <anonymous> (wasm://wasm/000288f6:1:34422)
    at <anonymous> (wasm://wasm/000288f6:1:24106)
    at borrowFoo (rust-wasm-pitfalls/lib-js/lib_bg.js:87:10)
    at rust-wasm-pitfalls/examples/losing-ownership.ts:13:1

Avoiding the error

It would be nice if we could solve this at the type-system level. Unfortunately for us, TypeScript cannot express this idea that a function can consume a value, which would require support for linear/affine types. That leaves us with two options:

Could static analysis help us here? It seems very tricky to implement something that detects ownership transfer and prevents later use of the object in JavaScript. However, it seems plausible to implement a lint (with clippy, dylint or even wasm-bindgen itself) that warns you when a function with JavaScript bindings takes non-Copy owned values.

Pitfall 2: Holding mutable references across await

By installing wasm-bindgen-futures, we get a bridge between async Rust and async JavaScript. You can expose bindings for an async fn foo() and await it in JavaScript with await foo().

In normal Rust, imagine that we write this:

use std::time::Duration;
use async_std::task::sleep;

pub struct Bar;

pub async fn use_mut_bar(_bar: &mut Bar) -> () {
    sleep(Duration::from_secs(1)).await;
}

pub fn use_bar(_bar: &Bar) -> () {
}


pub async fn will_compile() {
    let mut bar = Bar;

    use_mut_bar(&mut bar).await;
    use_bar(&bar);
}

This works just fine. use_mut_bar takes a mutable reference to bar. We await use_mut_bar until it completes and relinquishes the mutable borrow. Then we can call use_bar with an immutable reference to the same bar.

This order of operations strictly preserves the condition that a value cannot be mutably and immutably borrowed at the same time. However, look what happens if we change the implementation a little:

pub async fn wont_compile() {
    let mut bar = Bar;

    let fut = use_mut_bar(&mut bar);
    use_bar(&bar);
}

In this example, we call the same functions in the same order but we do not await use_mut_bar. Instead, we store its future in the local variable fut. The compiler will complain about use_bar(&bar): you cannot borrow bar as immutable.

This is because the incomplete future fut still owns the mutable reference to bar, and we might continue to execute it. The compiler will not allow us to take another immutable reference while this is still the case.

This is great: the Rust compiler is protecting us from a potential hazard. However, no such protection mechanism exists when calling use_mut_bar and use_bar from JavaScript.

Let’s look now at the same code, marked up for wasm-bindgen:

use std::time::Duration;

use async_std::task::sleep;
use wasm_bindgen::prelude::*;
use web_sys::console;

#[wasm_bindgen]
pub struct Bar;

#[wasm_bindgen]
impl Bar {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Bar {
        Bar
    }
}

#[wasm_bindgen(js_name = "useMutBar")]
pub async fn use_mut_bar(_bar: &mut Bar) -> () {
    console::log_1(&"Rust: use_mut_bar".into());

    sleep(Duration::from_secs(1)).await;

    console::log_1(&"Rust: use_mut_bar done".into());
}

#[wasm_bindgen(js_name = "useBar")]
pub fn use_bar(_bar: &Bar) -> () {
    console::log_1(&"Rust: use_bar".into());
}

And here is an example of how we can cause a hazardous situation by calling these functions from JavaScript:

import { Bar, useBar, useMutBar } from "lib";

const bar = new Bar();

// Schedule `useBar` to run when the current task yields
// `useBar` takes an immutable reference to `bar`
setTimeout(() => useBar(bar), 0);

// Start running `useMutBar` immediately
// `useMutBar` takes a mutable reference to `bar`
await useMutBar(bar);

This code crashes with a somewhat cryptic error. Why?

Rust: use_mut_bar
error: Uncaught Error: recursive use of an object detected which would lead to unsafe aliasing in rust
    throw new Error(getStringFromWasm0(arg0, arg1));
          ^
    at __wbindgen_throw (rust-wasm-pitfalls/lib-js/lib_bg.js:317:11)
    at <anonymous> (wasm://wasm/00026f8a:1:32920)
    at <anonymous> (wasm://wasm/00026f8a:1:32909)
    at <anonymous> (wasm://wasm/00026f8a:1:24196)
    at useBar (rust-wasm-pitfalls/lib-js/lib_bg.js:114:10)
    at rust-wasm-pitfalls/examples/mut-async.ts:7:18
    at callback (ext:deno_web/02_timers.js:58:7)
    at eventLoopTick (ext:core/01_core.js:210:13)

This is a (somewhat contrived) example of how use_bar can get called while use_mut_bar is still in-progress and holding the mutable reference to bar.

By calling setTimeout(() => useBar(bar), 0), we push the execution of useBar(bar) onto the async task queue. It will start running when all the tasks ahead of it have yielded for some reason. In normal applications this will happen because they are waiting on something (e.g. a network request).

We call use_mut_bar(bar), which itself calls sleep and yields. use_bar(bar) can then start executing. The wasm-bindgen runtime keeps track of borrows and detects that we are attempting to create an immutable reference while a mutable reference already exists. It then throws the ‘recursive use of an object’ exception.

A real-world example

Initially, it seems that it would be difficult to accidentally trigger this scenario. But it is not. Imagine that you have a React application. One of your React components is rendered from a Rust object. There are getters like fn get_foo(&self) which retrieve data to be displayed on the screen.

The user clicks a button, which starts an update of the Rust object by calling async fn update_bar(&mut self). At some point, this function yields. While the async function is paused, React decides to start re-rendering the component. It calls get_foo again and bam! Exception thrown.

Avoiding the error

This is a tricky one. Async functions taking mutable references are obviously very useful but, if we do not very carefully manage our calling pattern, crashes are almost inevitable. If you are using a framework (e.g. React) that ‘calls you’, it seems near-impossible to avoid the bad cases.

My recommendation would be: if a Rust object is visible to JavaScript, never hold a mutable reference to it across an await. Instead, find a way to separate the async operation from the mutation logic, and call two separate functions sequentially instead. You may be better off avoiding mutation entirely and using copyable, immutable Rust objects.

Conclusion

In this post, we looked at a couple of ownership-related situations that can lead to crashes when using a Rust object from JavaScript. There is no obvious silver bullet for these problems, and a proper fix would require some heavyweight static analysis tooling.

I’m interested to hear about any other pitfalls you’ve encountered while using wasm-bindgen. Do you have ideas for avoiding the situations outlined above? Let me know below! I’d like to add more scenarios to the examples repo.

Thanks to my colleague Bouke for his feedback on this post.