Skip to content
Learn Rust
Theme

Rust and WebAssembly

WebAssembly (Wasm) is, in the words of the Wasmtime docs, “a binary instruction format that is designed to be a portable compilation target for programming languages”. A host (a browser, Node, or a runtime such as Wasmtime) loads a .wasm file and calls into it. Rust gets there by choosing a target. This page builds one small library, loads it from Node 24 with only WebAssembly.instantiate, then adds wasm-bindgen, wasm-pack and WASI.

Everything ran in the image behind this site (Linux ARM64, Rust 1.99.1). The image ships only the native Rust target and none of the Wasm tools, so sessions start with rustup target add and some download a pinned tool; those lines say needs network. The image has no browser, so nothing here ran in one.

RememberA WebAssembly function takes and returns numbers. Text and arrays travel as bytes in the module’s memory plus an address, and either you write that glue or a tool such as wasm-bindgen writes it.

Project layout (a Cargo workspace)
wasm-lab/
├── adder/                      exports and one import, plain extern "C"
│   ├── src/
│   │   └── lib.rs
│   └── Cargo.toml
├── assemblyscript/
│   ├── add.ts
│   └── package.json
├── bindgen/                    the same ideas with wasm-bindgen
│   ├── src/
│   │   └── lib.rs
│   └── Cargo.toml
├── go/
│   ├── go.mod
│   └── main.go
├── goexport/
│   ├── go.mod
│   └── main.go
├── gotcha/                     the gotcha
│   ├── src/
│   │   └── main.rs
│   └── Cargo.toml
├── tiny/                       just add, for the size table
│   ├── src/
│   │   └── lib.rs
│   └── Cargo.toml
├── wasi/                       a program with main
│   ├── src/
│   │   └── main.rs
│   └── Cargo.toml
├── callAdd.mjs
├── Cargo.lock
├── Cargo.toml
├── run.mjs                     the Node host for adder
├── setup.sh                    downloads one pinned tool
├── use.js
└── wasi.mjs

wasm32-unknown-unknown assumes nothing about the host; the rustc book says it “does not import any functions from the host for the standard library”. wasm32-wasip1 assumes the WASI preview 1 system calls (files, arguments, environment, clocks). wasm32-wasip2 is built on the component model; it appears once, in the WASI section. The cfg lines show 32-bit pointers, so usize is 4 bytes.

terminal
$ rustup target add wasm32-unknown-unknown wasm32-wasip1   # needs network
$ rustup target list --installed   # at this point: the wasip2 target is added in a later session
aarch64-unknown-linux-gnu
wasm32-unknown-unknown
wasm32-wasip1
$ rustc --print cfg --target wasm32-unknown-unknown | grep -E 'target_(arch|os|family|pointer_width)'
target_arch="wasm32"
target_family="wasm"
target_os="unknown"
target_pointer_width="32"

A crate with crate-type = ["cdylib"] has no main. Each #[unsafe(no_mangle)] pub extern "C" fn becomes an export under its own name; edition 2024 writes no_mangle inside unsafe(...) (unsafe, and C and C++ for the same attribute on native code). A function declared in an unsafe extern "C" block is an import: something the host must supply.

Rust
[package]
name = "adder"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
JavaScript (Node 24)run.mjs
import { readFileSync } from 'node:fs'
const file = process.argv[2] ?? 'target/wasm32-unknown-unknown/release/adder.wasm'
let memory // set after instantiation; host_log reads the text from it
const imports = {
env: {
host_log(ptr, len) {
console.log(' [from wasm]', new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len)))
},
},
}
const module = await WebAssembly.compile(readFileSync(file))
console.log('imports:', WebAssembly.Module.imports(module).map(i => `${i.module}.${i.name} (${i.kind})`).join(', '))
console.log('exports:', WebAssembly.Module.exports(module).map(e => `${e.name} (${e.kind})`).join(', '))
const { exports: w } = await WebAssembly.instantiate(module, imports)
memory = w.memory
// Numbers cross as numbers. An i64 arrives in JavaScript as a BigInt.
console.log('add(2, 3) =', w.add(2, 3))
console.log('fib(50) =', w.fib(50), typeof w.fib(50))
console.log('add(2147483647, 1) =', w.add(2147483647, 1))
// Text: copy the UTF-8 bytes into memory the module gave us, call, copy the answer out, free both.
function shout(text) {
const input = new TextEncoder().encode(text)
const inPtr = w.alloc(input.length)
new Uint8Array(memory.buffer, inPtr, input.length).set(input)
const lenPtr = w.alloc(4) // room for one usize (4 bytes on wasm32)
const outPtr = w.shout(inPtr, input.length, lenPtr)
const outLen = new Uint32Array(memory.buffer, lenPtr, 1)[0]
const out = new TextDecoder().decode(new Uint8Array(memory.buffer, outPtr, outLen))
w.dealloc(outPtr, outLen)
w.dealloc(lenPtr, 4)
w.dealloc(inPtr, input.length)
return out
}
console.log(shout('straße'))
// An array: write four i32 values into memory, pass the address.
const nums = Int32Array.of(10, 20, 30, 40)
const numsPtr = w.alloc(nums.byteLength)
new Int32Array(memory.buffer, numsPtr, nums.length).set(nums)
console.log('sum =', w.sum(numsPtr, nums.length))
w.dealloc(numsPtr, nums.byteLength)
// A panic in Rust is a trap: the call throws.
try {
w.divide(1, 0)
} catch (e) {
console.log(e.name + ':', e.message)
}
// A view onto memory goes stale when the module's memory grows (MDN: grow() detaches the old buffer).
const view = new Uint8Array(memory.buffer)
console.log('view covers all of memory:', view.length === memory.buffer.byteLength)
const big = w.alloc(8 * 1024 * 1024)
console.log('old view length after alloc(8 MiB):', view.length)
console.log('a fresh view covers the new memory:', new Uint8Array(memory.buffer).length > 8 * 1024 * 1024)
w.dealloc(big, 8 * 1024 * 1024)
$ rustup target add wasm32-unknown-unknown # needs network
$ cargo build -p adder --target wasm32-unknown-unknown --release
$ node run.mjs
imports: env.host_log (function)
exports: memory (memory), add (function), alloc (function), dealloc (function), divide (function), fib (function), shout (function), sum (function)
add(2, 3) = 5
fib(50) = 12586269025n bigint
add(2147483647, 1) = -2147483648
  [from wasm] shout got 7 bytes
STRASSE!
sum = 100n
RuntimeError: unreachable
view covers all of memory: true
old view length after alloc(8 MiB): 0
a fresh view covers the new memory: true
  • Numbers cross, nothing else does. add and fib take and return numbers. The u64 from fib arrived in JavaScript as a bigint.
  • Text is bytes in memory. shout cannot take a &str. The host asks the module for room with alloc, writes UTF-8 into memory.buffer, and passes an address and a length. The answer comes back as an address, with its length written through a second pointer. The host must dealloc all three: the module built them with Box::into_raw, so its own dealloc has to free them (Smart pointers). The length slot is 4 bytes because usize is 32 bits here.
  • An import is a function you hand in. host_log is looked up in the env object passed to instantiate. The #[link(wasm_import_module = "env")] line names that module.
  • A panic is a trap. divide(1, 0) threw RuntimeError: unreachable. The message is that one word, with no Rust panic text. The rustc book page above says that on this target “println! does nothing”.
  • Views of memory go stale. MDN says Memory.grow() “detaches the previous buffer”. The last lines show the old Uint8Array reporting length 0 after the module allocated 8 MiB. Make a new view after any call that may allocate.
  • Release wraps, debug traps. add(2147483647, 1) gave -2147483648 in this release build. Try it, task 1, runs the debug build (Types has the same rule on native code).

Two crates, three builds each. tiny exports just add; adder also has the allocator and the text code. small is the profile in the workspace Cargo.toml (opt-level = "z", lto, codegen-units = 1, strip; Performance).

terminal
$ stat -c '%s bytes  %n' target/wasm32-unknown-unknown/*/*.wasm   # Linux ARM64, rustc 1.99.1
  1862903 bytes  target/wasm32-unknown-unknown/debug/adder.wasm
    32617 bytes  target/wasm32-unknown-unknown/release/adder.wasm
    23795 bytes  target/wasm32-unknown-unknown/small/adder.wasm
  1693973 bytes  target/wasm32-unknown-unknown/debug/tiny.wasm
      319 bytes  target/wasm32-unknown-unknown/release/tiny.wasm
       62 bytes  target/wasm32-unknown-unknown/small/tiny.wasm
$ cargo build -p adder -p tiny --target wasm32-unknown-unknown --config profile.dev.debug=0   # debug build again, without debug information
$ stat -c '%s bytes  %n' target/wasm32-unknown-unknown/debug/*.wasm
    53091 bytes  target/wasm32-unknown-unknown/debug/adder.wasm
    20294 bytes  target/wasm32-unknown-unknown/debug/tiny.wasm
$ sh setup.sh wasm-opt   # needs network: downloads binaryen version_133
$ wasm-opt -Oz -o adder-opt.wasm target/wasm32-unknown-unknown/small/adder.wasm   # first line of the output
[wasm-validator error in function 2] unexpected false: memory.fill operations require bulk memory [--enable-bulk-memory-opt], on 
$ cargo build -p adder --target wasm32-unknown-unknown --profile small --config profile.small.strip=false --target-dir unstripped   # the same profile without strip
$ wasm-opt -Oz -o unstripped-opt.wasm unstripped/wasm32-unknown-unknown/small/adder.wasm   # no --all-features this time
wasm-opt finished with no error
$ rustc --print cfg --target wasm32-unknown-unknown | grep target_feature
target_feature="bulk-memory"
target_feature="multivalue"
target_feature="mutable-globals"
target_feature="nontrapping-fptoint"
target_feature="reference-types"
target_feature="sign-ext"
$ wasm-opt -Oz --all-features -o adder-opt.wasm target/wasm32-unknown-unknown/small/adder.wasm
$ stat -c '%9s bytes  %n' adder-opt.wasm
    20461 bytes  adder-opt.wasm
$ node run.mjs adder-opt.wasm   # keeping the add and sum lines
add(2, 3) = 5
add(2147483647, 1) = -2147483648
sum = 100n

This output changes from run to run. This is one real run.

  • The debug builds are by far the largest, about 1.7 MB for one function. With profile.dev.debug=0 it was about 20 KB, so most of that was debug information.
  • wasm-opt (from Binaryen) rejected the small module. The cause is strip in that profile: it removes the section that records which features the module uses, so wasm-opt does not know bulk memory is allowed. The same profile built with strip=false passes with no flag. For the stripped file, --all-features was the shortest fix (the six features printed are the ones rustc enables for this target), and the result still ran.
  • These counts come from one toolchain (rustc 1.99.1, Binaryen version_133). The debug counts also change with the directory you build in, and every size changes with the tool versions. What a size costs depends on where the file is served from, which I did not run.

wasm-bindgen is a tool that, its guide says, “allows JS/wasm to communicate with strings, JS objects, classes, etc, as opposed to purely integers and floats”. It has two halves: the wasm-bindgen crate, whose macros you write, and a command that reads the compiled module and writes the JavaScript glue and a .d.ts file. The docs install the command with cargo install; the session downloads the prebuilt release of the same version instead (0.2.129, matching the crate pinned in Cargo.toml).

Rust
[package]
name = "greeter"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "=0.2.129"
JavaScript (Node 24)use.js
const { shout, sum, parse, Counter } = require('./bindgen/pkg/greeter.js')
console.log(shout('straße'))
console.log('sum =', sum(Int32Array.of(10, 20, 30, 40)))
console.log('parse(" 42 ") =', parse(' 42 '))
try {
parse('4x2')
} catch (e) {
console.log(`${e.name}: ${e.message}`)
}
const c = new Counter()
console.log(c.bump(), c.bump())
c.free() // Rust memory is not garbage collected until you free it (or a FinalizationRegistry runs)
terminal
$ sh setup.sh wasm-bindgen   # needs network: downloads the prebuilt 0.2.129 command
$ wasm-bindgen --version
wasm-bindgen 0.2.129
$ cargo build -p greeter --target wasm32-unknown-unknown --release   # needs network: downloads the wasm-bindgen crates
$ wasm-bindgen --target nodejs --out-dir bindgen/pkg target/wasm32-unknown-unknown/release/greeter.wasm
$ stat -c '%s bytes  %n' bindgen/pkg/*
      297 bytes  bindgen/pkg/greeter.d.ts
     5977 bytes  bindgen/pkg/greeter.js
    35932 bytes  bindgen/pkg/greeter_bg.wasm
      813 bytes  bindgen/pkg/greeter_bg.wasm.d.ts
$ cat bindgen/pkg/greeter.d.ts
/* tslint:disable */
/* eslint-disable */

export class Counter {
    free(): void;
    [Symbol.dispose](): void;
    bump(): number;
    constructor();
}

export function parse(text: string): number;

export function shout(text: string): string;

export function sum(values: Int32Array): bigint;
$ node use.js
shout got 7 bytes
STRASSE!
sum = 100n
parse(" 42 ") = 42
Error: invalid digit found in string
1 2

The .d.ts file is what a TypeScript caller sees: shout(text: string): string, and sum returns bigint because the Rust side returns i64. The Result<i32, JsError> became a thrown Error carrying Rust’s own message. Counter became a class, and use.js calls its free() to release the Rust value; I did not test skipping it. --target nodejs made a CommonJS module; the deployment page lists the others, including web and bundler for browsers, which I did not run.

wasm-pack wraps the same steps. It built the crate, fetched wasm-bindgen, ran wasm-opt and wrote a package.json. Its greeter_bg.wasm is 26,286 bytes against 35,932 from the command above; the extra step in its output is wasm-opt, and wasm-pack build --no-opt gave 35,932 again.

wasm-pack uses a wasm-bindgen or wasm-opt it finds on your PATH instead of downloading its own. The session hides one step: it first ran rm -f /usr/local/bin/wasm-opt /usr/local/bin/wasm-bindgen, so wasm-pack downloaded both. In a check outside the page, with Binaryen version_133 and wasm-bindgen on the PATH, the Installing line was missing and the file was 26,191 bytes: 26,286 belongs to wasm-pack’s own wasm-opt.

terminal
$ rustup target add wasm32-unknown-unknown   # needs network
$ sh setup.sh wasm-pack   # needs network: downloads the prebuilt 0.15.0 command
$ wasm-pack build bindgen --target nodejs --release   # needs network (with no wasm-bindgen or wasm-opt on the PATH it downloads its own); the 'Done in' line is left out because its time varies
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...

[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: 📦   Your wasm pkg is ready to publish at bindgen/pkg.
$ stat -c '%s bytes  %n' bindgen/pkg/*
      297 bytes  bindgen/pkg/greeter.d.ts
     5977 bytes  bindgen/pkg/greeter.js
    26286 bytes  bindgen/pkg/greeter_bg.wasm
      813 bytes  bindgen/pkg/greeter_bg.wasm.d.ts
      175 bytes  bindgen/pkg/package.json
$ ls ~/.cache/.wasm-pack   # where wasm-pack keeps the tools it downloaded
wasm-bindgen-b32d3a9b2bb775a3  wasm-opt-e5a53a595abae3fc

A WASI module is a program: it has main, arguments, an environment, stdin and stdout, and files. wasm32-wasip1 imports those from wasi_snapshot_preview1. The rustc book says the standard library works there in part: “spawning a thread will always return an error” and so will spawning a process, while “opening a file” calls WASI functions.

Rustwasi/src/main.rs
use std::io::Read;
use std::{env, fs, io};
fn main() {
// A WASI module is a program: it has main, arguments, environment, stdin and stdout.
println!("args: {:?}", env::args().skip(1).collect::<Vec<_>>());
println!("GREETING = {:?}", env::var("GREETING"));
let mut input = String::new();
io::stdin().read_to_string(&mut input).unwrap();
println!("stdin had {} bytes", input.len());
// Files: only the directories the host hands over ("preopens") exist for the module.
println!("write sandbox/note.txt: {:?}", fs::write("sandbox/note.txt", input.to_uppercase()));
println!("read sandbox/note.txt: {:?}", fs::read_to_string("sandbox/note.txt"));
println!("read /etc/hostname: {:?}", fs::read_to_string("/etc/hostname").map_err(|e| e.kind()));
}
terminal
$ rustup target add wasm32-wasip1   # needs network
$ cargo build -p wasi-demo --target wasm32-wasip1 --release
$ sh setup.sh wasmtime   # needs network: downloads the prebuilt v50.0.0 command
$ mkdir sandbox
$ echo "hello wasi" | wasmtime run --dir sandbox --env GREETING=hi target/wasm32-wasip1/release/wasi-demo.wasm one two
args: ["one", "two"]
GREETING = Ok("hi")
stdin had 11 bytes
write sandbox/note.txt: Ok(())
read  sandbox/note.txt: Ok("HELLO WASI\n")
read  /etc/hostname:    Err(NotFound)
$ cat sandbox/note.txt
HELLO WASI
$ echo "hello wasi" | wasmtime run target/wasm32-wasip1/release/wasi-demo.wasm
args: []
GREETING = Err(NotPresent)
stdin had 11 bytes
write sandbox/note.txt: Err(Os { code: 44, kind: NotFound, message: "No such file or directory" })
read  sandbox/note.txt: Err(Os { code: 44, kind: NotFound, message: "No such file or directory" })
read  /etc/hostname:    Err(NotFound)
$ sh setup.sh wasmer   # needs network: downloads the prebuilt v7.5.0 command
$ echo "hello wasi" | wasmer run --volume sandbox:/sandbox target/wasm32-wasip1/release/wasi-demo.wasm -- one two
args: ["one", "two"]
GREETING = Err(NotPresent)
stdin had 11 bytes
write sandbox/note.txt: Ok(())
read  sandbox/note.txt: Ok("HELLO WASI\n")
read  /etc/hostname:    Err(NotFound)
$ echo "hello wasi" | node --no-warnings wasi.mjs target/wasm32-wasip1/release/wasi-demo.wasm
args: ["one", "two"]
GREETING = Ok("hi")
stdin had 11 bytes
write sandbox/note.txt: Ok(())
read  sandbox/note.txt: Ok("HELLO WASI\n")
read  /etc/hostname:    Err(NotFound)
$ stat -c '%s bytes  %n' target/wasm32-wasip1/release/wasi-demo.wasm
   109682 bytes  target/wasm32-wasip1/release/wasi-demo.wasm

The same .wasm file ran under three hosts. The first run gave the module a directory (--dir sandbox) and a variable. The second gave it neither, so the module saw no variable and could not open sandbox/note.txt: a WASI module gets what the host hands over. Wasmer printed the same lines except GREETING, which I did not pass. Node’s node:wasi printed the same lines as Wasmtime; its docs mark it “Stability: 1 - Experimental” and say not to rely on it to run untrusted code, so the session runs it with --no-warnings, which hides the experimental warning.

The source also builds for wasm32-wasip2 and ran under Wasmtime with the same kinds of lines as the Wasmer run (argument x, no variable). I did not look inside the file; the rustc book says the WASI standard effort was “rebased” on the Component Model and released as 0.2.0 (“WASIp2”).

terminal
$ cargo build -p wasi-demo --target wasm32-wasip2 --release
$ echo "hello wasi" | wasmtime run --dir sandbox target/wasm32-wasip2/release/wasi-demo.wasm x
args: ["x"]
GREETING = Err(NotPresent)
stdin had 11 bytes
write sandbox/note.txt: Ok(())
read  sandbox/note.txt: Ok("HELLO WASI\n")
read  /etc/hostname:    Err(NotFound)

Each row says how a language reaches Wasm and what ran here. Where the image has the tool, I built an add function and loaded it with one Node script (callAdd.mjs).

Language Way to WebAssembly What ran here
Rust rustup target add, then cargo build --target everything above
Go GOOS=js GOARCH=wasm (the Node script wasm_exec_node.js ships with Go), or GOOS=wasip1 GOARCH=wasm; //go:wasmexport since Go 1.24 (blog). TinyGo has its own Wasm guide (docs) all three of Go’s; TinyGo not run
TypeScript the host side here is JavaScript (run.mjs); a TypeScript host was not run. AssemblyScript is a language with TypeScript-like syntax that compiles to Wasm AssemblyScript 0.28.20 add, called from Node
Kotlin Kotlin/Wasm (docs) not run
C# Blazor WebAssembly runs on “a WebAssembly-based .NET runtime” (docs) not run
C and C++ Emscripten, “a complete Open Source compiler toolchain to WebAssembly” (docs); for wasm32-wasip1 the rustc book says the target is meant to interoperate with C and C++, and recommends the wasi-sdk if you build a staticlib or use an external -Clinker not run: the image’s gcc builds only for this machine
Python, Java not covered on this page not run
Rust, Go, AssemblyScript
// Only `add`: no text, no allocation, no imports.
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
$ rustup target add wasm32-unknown-unknown # needs network
$ cargo build -p tiny --target wasm32-unknown-unknown --profile small   # Rust
$ GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o goexport.wasm .   # Go
$ npm install   # needs network: AssemblyScript 0.28.20, pinned in package.json
$ npx asc add.ts -o add.wasm -O3 --runtime stub   # AssemblyScript
$ node callAdd.mjs tiny.wasm goexport.wasm add.wasm   # one script calls all three
target/wasm32-unknown-unknown/small/tiny.wasm: 62 bytes, 0 imports (none), add(2, 3) = 5
goexport/goexport.wasm: 1908098 bytes, 10 imports (wasi_snapshot_preview1), add(2, 3) = 5
assemblyscript/add.wasm: 55 bytes, 0 imports (none), add(2, 3) = 5

The output has Rust’s 62-byte add, Go’s module with 10 WASI imports, and AssemblyScript’s 55 bytes. Each module holds different things, so these compare three builds, not three languages. Go’s GOOS=js hello world, run by wasm_exec_node.js:

terminal
$ GOOS=js GOARCH=wasm go build -o main.wasm .
$ node $(go env GOROOT)/lib/wasm/wasm_exec_node.js main.wasm one two
hello from Go, args: [one two]
$ stat -c '%s bytes  %n' main.wasm
  2501067 bytes  main.wasm

On Linux or macOS (here, in the image) the probe below prints Ok; I did not run it on Windows, where echo is a shell built-in:

terminal
$ cargo run -q -p probe
run echo: Ok(true)
spawn thread: Ok(2)
Gotcha

std compiles for the target, then the call fails

Looks fine, and it compiles in Rust too. But the two give different results.

Not run in Go on this page.Not run in Kotlin on this page.Not run in Python on this page.Not run in TypeScript on this page.Not run in Java on this page.Not run in C# on this page.Not run in C on this page.Not run in C++ on this page.
Rustsrc/main.rs✓ compiles · different result
use std::process::Command;
use std::thread;
fn main() {
let echo = Command::new("echo").arg("hi").output();
println!("run echo: {:?}", echo.map(|out| out.status.success()));
let worker = thread::Builder::new().spawn(|| 1 + 1);
println!("spawn thread: {:?}", worker.map(|handle| handle.join().unwrap()));
}

There is no Go version of this one.

There is no Kotlin version of this one.

There is no Python version of this one.

There is no TypeScript version of this one.

There is no Java version of this one.

There is no C# version of this one.

There is no C version of this one.

There is no C++ version of this one.

$ wasmtime run target/wasm32-wasip1/debug/probe.wasm
run echo: Err(Error { kind: Unsupported, message: "operation not supported on this platform" })
spawn thread: Err(Os { code: 58, kind: Unsupported, message: "Not supported" })
Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

Why it looks fine

Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.

What Rust sees

The standard library is present on wasm32-wasip1, but there the rustc book says spawning a thread or a process always returns an error (a separate wasm32-wasip1-threads target exists for threads; I did not run it). Both functions return Err (ErrorKind::Unsupported) instead of failing to compile, because their signatures already allow an error. The compiler checked the types, not what the target can do.

Why Rust says no

The rustc book's page for the target says that "spawning a thread will always return an error" and "spawning a process will always return an error", while operations such as opening a file call the WASI functions. The page for wasm32-unknown-unknown says std::thread::spawn will panic there. The fix is to treat the error as an answer for that target: check for ErrorKind::Unsupported, and do the work on the calling thread or ask the host.

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
use std::io::ErrorKind;
use std::process::Command;
use std::thread;
fn main() {
// Treat "this target cannot do that" as an answer, not a surprise.
match Command::new("echo").arg("hi").output() {
Ok(out) => print!("echo said: {}", String::from_utf8_lossy(&out.stdout)),
Err(e) if e.kind() == ErrorKind::Unsupported => println!("no processes on this target"),
Err(e) => println!("echo failed: {e}"),
}
let work = || 1 + 1;
let answer = match thread::Builder::new().spawn(work) {
Ok(handle) => handle.join().unwrap(),
Err(_) => work(), // no threads here: do the work on the calling thread
};
println!("answer: {answer}");
}
$ wasmtime run target/wasm32-wasip1/debug/probe.wasm
no processes on this target
answer: 2
  1. Build adder without --release and run node run.mjs on the debug file. After the first two lines comes RuntimeError: unreachable, and the stack names panic_const_add_overflow: the overflow check is compiled in.
  2. Pass {} as the imports to WebAssembly.instantiate. Node refuses with a TypeError that names import #0, "env".
  3. Delete the #[link(wasm_import_module = "env")] line and build. The linker fails with undefined symbol: host_log.
terminal
$ cargo build -p adder --target wasm32-unknown-unknown
$ node run.mjs target/wasm32-unknown-unknown/debug/adder.wasm   # keeping the lines that name the trap
add(2, 3) = 5
fib(50) = 12586269025n bigint
RuntimeError: unreachable
panic_const_add_overflow
terminal
$ node -e 'WebAssembly.instantiate(require("fs").readFileSync("target/wasm32-unknown-unknown/release/adder.wasm"), {}).catch(e => console.log(e.name + ": " + e.message))'
TypeError: WebAssembly.instantiate(): Import #0 "env": module is not an object or function
terminal
$ sed -i '/wasm_import_module/d' adder/src/lib.rs; cargo build -p adder --target wasm32-unknown-unknown --release   # the line of the linker error that names the problem
undefined symbol: host_log

Tool versions, all pinned in setup.sh or the manifests: wasm-bindgen crate and command 0.2.129, wasm-pack 0.15.0, Wasmtime v50.0.0, Wasmer 7.5.0, Binaryen version_133, AssemblyScript 0.28.20.