zwasm
A standalone WebAssembly runtime written in Zig. Runs Wasm modules as a CLI tool or embeds as a Zig library.
Features
- Full Wasm 3.0 support: Core spec + 9 proposals (GC, exception handling, tail calls, SIMD, threads, and more)
- 62,158 spec tests passing: 100% on macOS ARM64 and Linux x86_64
- 4-tier execution: Interpreter with register IR and ARM64/x86_64 JIT compilation
- WASI Preview 1: 46 syscalls with deny-by-default capability model
- Small footprint: ~1.4 MB binary, ~3.5 MB runtime memory
- Library and CLI: Use as a
zig builddependency or run modules from the command line - WAT support: Run
.wattext format files directly
Quick Start
# Run a WebAssembly module
zwasm hello.wasm
# Invoke a specific function
zwasm math.wasm --invoke add 2 3
# Run a WAT text file
zwasm program.wat
See Getting Started for installation instructions.
Getting Started
This guide gets you from zero to running a WebAssembly module in under 5 minutes.
Prerequisites
- Zig 0.15.2 or later
Install
Build from source
git clone https://github.com/clojurewasm/zwasm.git
cd zwasm
zig build -Doptimize=ReleaseSafe
The binary is at zig-out/bin/zwasm. Copy it to your PATH:
cp zig-out/bin/zwasm ~/.local/bin/
Install script
curl -fsSL https://raw.githubusercontent.com/clojurewasm/zwasm/main/install.sh | bash
Homebrew (macOS/Linux) — coming soon
brew install clojurewasm/tap/zwasm # not yet available
Verify installation
zwasm version
Run your first module
1. From a WAT file
Create hello.wat:
(module
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
Run it:
zwasm hello.wat --invoke add 2 3
# Output: 5
2. WASI module
For modules that use WASI (filesystem, stdout, etc.):
zwasm hello_wasi.wasm --allow-all
Grant only the capabilities you need:
zwasm hello_wasi.wasm --allow-read --dir ./data
3. Inspect a module
See what a module exports and imports:
zwasm inspect hello.wasm
4. Validate a module
Check if a module is valid without running it:
zwasm validate hello.wasm
Use as a Zig library
Add zwasm as a dependency in your build.zig.zon:
.dependencies = .{
.zwasm = .{
.url = "https://github.com/clojurewasm/zwasm/archive/refs/tags/v1.1.0.tar.gz",
.hash = "...", // zig build will tell you the correct hash
},
},
Then in build.zig:
const zwasm_dep = b.dependency("zwasm", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zwasm", zwasm_dep.module("zwasm"));
See the Embedding Guide for API usage.
More examples
The repository includes 33 numbered WAT examples in examples/wat/, ordered from beginner to advanced:
zwasm examples/wat/01_hello_add.wat --invoke add 2 3 # basics
zwasm examples/wat/02_if_else.wat --invoke abs -7 # if/else
zwasm examples/wat/03_loop.wat --invoke sum 100 # loops → 5050
zwasm examples/wat/05_fibonacci.wat --invoke fib 10 # recursion → 55
zwasm examples/wat/24_call_indirect.wat --invoke apply 0 10 3 # tables → 13
zwasm examples/wat/25_return_call.wat --invoke sum 1000000 # tail calls
zwasm examples/wat/30_wasi_hello.wat --allow-all # WASI → Hi!
zwasm examples/wat/32_wasi_args.wat --allow-all -- hi # WASI args
Each file includes run instructions in its header comment. Zig embedding examples are in examples/zig/.
Next steps
- CLI Reference — all commands and flags
- Embedding Guide — use zwasm as a Zig library
- Spec Coverage — supported Wasm proposals
CLI Reference
Commands
zwasm run / zwasm <file>
Execute a WebAssembly module. The run subcommand is optional — zwasm file.wasm is equivalent to zwasm run file.wasm.
zwasm <file.wasm|.wat> [options] [args...]
zwasm run <file.wasm|.wat> [options] [args...]
By default, calls _start (WASI entry point). Use --invoke to call a specific exported function.
Examples:
# Run a WASI module (calls _start)
zwasm hello.wasm --allow-all
# Run a WAT text format file (no compilation needed)
zwasm program.wat
# Call a specific exported function
zwasm math.wasm --invoke add 2 3
Argument types
Function arguments are type-aware: zwasm uses the function’s type signature to parse integers, floats, and negative numbers correctly.
# Integers
zwasm math.wat --invoke add 2 3 # → 5
# Negative numbers (no -- needed)
zwasm math.wat --invoke negate -5 # → -5
zwasm math.wat --invoke abs -42 # → 42
# Floating-point
zwasm math.wat --invoke double 3.14 # → 6.28
zwasm math.wat --invoke half -6.28 # → -3.14
# 64-bit integers
zwasm math.wat --invoke fib 50 # → 12586269025
Results are displayed in their natural format:
- i32/i64: signed decimal (e.g.
-1, not4294967295) - f32/f64: decimal (e.g.
3.14, not raw bits)
Argument count is validated against the function signature:
zwasm math.wat --invoke add 2 # error: 'add' expects 2 arguments, got 1
WASI modules
WASI modules use _start and receive string arguments via args_get.
Use -- to separate WASI args from zwasm options:
# String args passed to the WASI module
zwasm app.wasm --allow-all -- hello world
zwasm app.wasm --allow-read --dir ./data -- input.txt
# Environment variables (injected vars accessible without --allow-env)
zwasm app.wasm --env HOME=/tmp --env USER=alice
# Sandbox mode: deny all + fuel 1B + memory 256MB
zwasm untrusted.wasm --sandbox
zwasm untrusted.wasm --sandbox --allow-read --dir ./data
Multi-module linking
# Link an import module and call a function
zwasm app.wasm --link math=math.wasm --invoke compute 42
Resource limits
# Limit instructions (fuel metering) and memory
zwasm untrusted.wasm --fuel 1000000 --max-memory 16777216
zwasm inspect
Show a module’s imports and exports.
zwasm inspect [--json] <file.wasm|.wat>
# Human-readable
zwasm inspect examples/wat/01_hello_add.wat
# JSON output (for scripting)
zwasm inspect --json math.wasm
Options:
--json— Output in JSON format
zwasm validate
Check if a module is valid without executing it.
zwasm validate <file.wasm|.wat>
zwasm features
List supported WebAssembly proposals.
zwasm features [--json]
zwasm version
Print the version string.
zwasm help
Show usage information.
Run options
Execution
| Flag | Description |
|---|---|
--invoke <func> | Call <func> instead of _start |
--batch | Batch mode: read invocations from stdin |
--link name=file | Link a module as import source (repeatable) |
WASI capabilities
| Flag | Description |
|---|---|
--sandbox | Deny all capabilities + fuel 1B + memory 256MB |
--allow-all | Grant all WASI capabilities |
--allow-read | Grant filesystem read |
--allow-write | Grant filesystem write |
--allow-env | Grant environment variable access |
--allow-path | Grant path operations (open, mkdir, unlink) |
--dir <path> | Preopen a host directory (repeatable) |
--env KEY=VALUE | Set a WASI environment variable (always accessible) |
Resource limits
| Flag | Description |
|---|---|
--max-memory <N> | Memory ceiling in bytes (limits memory.grow) |
--fuel <N> | Instruction fuel limit (traps when exhausted) |
Debugging
| Flag | Description |
|---|---|
--profile | Print execution profile (opcode frequency, call counts) |
--trace=CATS | Trace categories: jit,regir,exec,mem,call (comma-separated) |
--dump-regir=N | Dump register IR for function index N |
--dump-jit=N | Dump JIT disassembly for function index N |
Batch mode
With --batch, zwasm reads invocation commands from stdin, one per line:
add 2 3
mul 4 5
fib 10
echo -e "add 2 3\nmul 4 5" | zwasm math.wasm --batch --invoke add
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime error (trap, stack overflow, etc.) |
| 2 | Invalid module or validation error |
| 126 | File not found |
Embedding Guide
Use zwasm as a Zig library to load and execute WebAssembly modules in your application.
Setup
Add zwasm to your build.zig.zon:
.dependencies = .{
.zwasm = .{
.url = "https://github.com/clojurewasm/zwasm/archive/refs/tags/v1.1.0.tar.gz",
.hash = "...", // zig build will provide the correct hash
},
},
In build.zig:
const zwasm_dep = b.dependency("zwasm", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zwasm", zwasm_dep.module("zwasm"));
Basic usage
const zwasm = @import("zwasm");
const WasmModule = zwasm.WasmModule;
// Load a module from bytes
const mod = try WasmModule.load(allocator, wasm_bytes);
defer mod.deinit();
// Call an exported function
var args = [_]u64{ 10, 20 };
var results = [_]u64{0};
try mod.invoke("add", &args, &results);
const sum: i32 = @bitCast(@as(u32, @truncate(results[0])));
Loading variants
| Method | Use case |
|---|---|
load(alloc, bytes) | Basic module, no WASI |
loadFromWat(alloc, wat_src) | Load from WAT text format |
loadWasi(alloc, bytes) | Module with WASI (cli_default caps) |
loadWasiWithOptions(alloc, bytes, opts) | WASI with custom config |
loadWithImports(alloc, bytes, imports) | Module with host functions |
loadWasiWithImports(alloc, bytes, imports, opts) | Both WASI and host functions |
loadWithFuel(alloc, bytes, fuel) | With instruction fuel limit |
Host functions
Provide native Zig functions as Wasm imports:
const zwasm = @import("zwasm");
fn hostLog(ctx_ptr: *anyopaque, context: usize) anyerror!void {
_ = context;
const vm: *zwasm.Vm = @ptrCast(@alignCast(ctx_ptr));
// Pop argument from operand stack
const value = vm.popOperandI32();
std.debug.print("log: {}\n", .{value});
// Push return value (if function returns one)
// try vm.pushOperand(@bitCast(@as(i32, result)));
}
const imports = [_]zwasm.ImportEntry{
.{
.module = "env",
.source = .{ .host_fns = &.{
.{ .name = "log", .callback = hostLog, .context = 0 },
}},
},
};
const mod = try WasmModule.loadWithImports(allocator, wasm_bytes, &imports);
WASI configuration
// loadWasi() defaults to cli_default caps (stdio, clock, random, proc_exit).
// Use loadWasiWithOptions for full access or custom capabilities:
const opts = zwasm.WasiOptions{
.args = &.{ "my-app", "--verbose" },
.env_keys = &.{"HOME"},
.env_vals = &.{"/tmp"},
.preopen_paths = &.{"./data"},
.caps = zwasm.Capabilities.all,
};
const mod = try WasmModule.loadWasiWithOptions(allocator, wasm_bytes, opts);
Memory access
Read from and write to the module’s linear memory:
// Read 100 bytes starting at offset 0
const data = try mod.memoryRead(allocator, 0, 100);
defer allocator.free(data);
// Write data at offset 256
try mod.memoryWrite(256, &.{ 0x48, 0x65, 0x6C, 0x6C, 0x6F });
Module linking
Link multiple modules together:
// Load the "math" module and register its exports
const math_mod = try WasmModule.load(allocator, math_bytes);
defer math_mod.deinit();
try math_mod.registerExports("math");
// Load another module that imports from "math"
const imports = [_]zwasm.ImportEntry{
.{ .module = "math", .source = .{ .wasm_module = math_mod } },
};
const app_mod = try WasmModule.loadWithImports(allocator, app_bytes, &imports);
defer app_mod.deinit();
Inspecting imports
Check what a module needs before instantiation:
const import_infos = try zwasm.inspectImportFunctions(allocator, wasm_bytes);
defer allocator.free(import_infos);
for (import_infos) |info| {
std.debug.print("{s}.{s}: {d} params, {d} results\n", .{
info.module, info.name, info.param_count, info.result_count,
});
}
Resource limits
Control resource usage:
// Fuel limit: traps after N instructions
const mod = try WasmModule.loadWithFuel(allocator, wasm_bytes, 1_000_000);
// Memory limit: via WASI options or direct Vm access
Error handling
All loading and execution methods return error unions. Key error types:
error.InvalidWasm— Binary format is invaliderror.ImportNotFound— Required import not providederror.Trap— Unreachable instruction executederror.StackOverflow— Call depth exceeded 1024error.OutOfBoundsMemoryAccess— Memory access out of boundserror.OutOfMemory— Allocator failederror.FuelExhausted— Instruction fuel limit hit
See Error Reference for the complete list.
Allocator control
zwasm takes a std.mem.Allocator at load time and uses it for all internal allocations. You control the allocator:
// Use the general purpose allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const mod = try WasmModule.load(gpa.allocator(), wasm_bytes);
// Or use an arena for batch cleanup
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const mod = try WasmModule.load(arena.allocator(), wasm_bytes);
API stability
Types and functions are classified into three stability levels:
-
Stable: Covered by SemVer. Will not break in minor/patch releases. Includes:
WasmModule,WasmFn,WasmValType,ExportInfo,ImportEntry,HostFnEntry,WasiOptions, and all their public methods. -
Experimental: May change in minor releases. Includes:
runtime.Store,runtime.Module,runtime.Instance,loadLinked, WIT-related functions. -
Internal: Not accessible to library consumers. All types in source files other than
types.zig.
See docs/api-boundary.md for the complete list.
FAQ & Troubleshooting
General
What Wasm proposals does zwasm support?
All 9 Wasm 3.0 proposals plus threads, wide arithmetic, and custom page sizes. See Spec Coverage for details.
Does zwasm support Windows?
Not currently. zwasm runs on macOS (ARM64) and Linux (x86_64, aarch64). The JIT and memory guard pages use POSIX APIs (mmap, mprotect, signal handlers).
Can I use zwasm without JIT?
Yes. The interpreter handles all functions by default. JIT is only triggered for hot functions. There is no way to disable JIT for specific functions, but functions that are called fewer than ~8 times will always use the interpreter.
What is the WAT parser?
zwasm can run .wat text format files directly: zwasm run program.wat. The WAT parser can be disabled at compile time with -Dwat=false to reduce binary size.
Troubleshooting
“trap: out-of-bounds memory access”
The Wasm module tried to read or write memory outside its linear memory bounds. This is a bug in the Wasm module, not in zwasm. Check that the module’s memory is large enough for its data.
“trap: call stack overflow (depth > 1024)”
Recursive function calls exceeded the 1024 depth limit. This is typically caused by infinite recursion in the Wasm module.
“required import not found”
The module requires an import that was not provided. Use zwasm inspect to see what imports the module needs, then provide them with --link or host functions.
“invalid wasm binary”
The file is not a valid WebAssembly binary. Check that it starts with the magic bytes \0asm and version \01\00\00\00. WAT files should use the .wat extension.
Slow performance
- Make sure you build with
zig build -Doptimize=ReleaseSafe. Debug builds are 5-10x slower. - Hot functions (called many times) are JIT-compiled automatically. Short-running programs may not benefit from JIT.
- Use
--profileto see opcode frequency and call counts.
High memory usage
- Every Wasm module with linear memory allocates guard pages (~4 GiB virtual, not physical). This is normal and shows up as large VSIZE but small RSS.
- Use
--max-memoryto cap the actual memory a module can allocate.
Architecture
zwasm processes a WebAssembly module through a multi-stage pipeline: decode, validate, predecode to register IR, and execute via interpreter or JIT.
Pipeline
.wasm binary
|
v
+-----------+
| Decode | module.zig -- parse binary format, sections, types
+-----+-----+
|
v
+-----------+
| Validate | validate.zig -- type checking, operand stack simulation
+-----+-----+
|
v
+-----------+
| Predecode | predecode.zig -- stack machine -> register IR
+-----+-----+
|
v
+-----------+
| Regalloc | regalloc.zig -- virtual -> physical register assignment
+-----+-----+
|
v
+----------------------+
| Execution |
| +----------------+ |
| | Interpreter | | vm.zig -- register IR dispatch loop
| +-------+--------+ |
| | hot path |
| +-------v--------+ |
| | JIT Compiler | | jit.zig (ARM64), x86.zig (x86_64)
| +----------------+ |
+----------------------+
Execution tiers
zwasm uses tiered execution with automatic promotion:
- Interpreter — Executes register IR instructions directly. All functions start here.
- JIT (ARM64/x86_64) — When a function’s call count or back-edge count exceeds a threshold, the register IR is compiled to native machine code. Subsequent calls execute the native code directly.
The JIT threshold is adaptive: hot loops trigger compilation faster via back-edge counting.
Source map
| File | Role | LOC |
|---|---|---|
module.zig | Binary decoder, section parsing, LEB128 | ~2K |
validate.zig | Type checker, operand stack simulation | ~1.7K |
predecode.zig | Stack IR → register IR conversion | ~0.7K |
regalloc.zig | Virtual → physical register allocation | ~2K |
vm.zig | Interpreter, execution engine, store | ~8K |
jit.zig | ARM64 JIT backend | ~5.9K |
x86.zig | x86_64 JIT backend | ~4.7K |
types.zig | Core type definitions, value types | ~1.3K |
opcode.zig | Opcode definitions (581+ total) | ~1.3K |
wasi.zig | WASI Preview 1 (46 syscalls) | ~2.6K |
gc.zig | GC proposal: heap, struct/array types | ~1.4K |
wat.zig | WAT text format parser | ~5.9K |
cli.zig | CLI frontend | ~2.1K |
instance.zig | Module instantiation, linking | ~0.9K |
component.zig | Component Model decoder | ~1.9K |
wit.zig | WIT parser | ~2.1K |
canon_abi.zig | Canonical ABI | ~1.2K |
Register IR
Instead of interpreting the WebAssembly stack machine directly, zwasm converts each function body to a register-based intermediate representation (IR) during predecode. This eliminates operand stack bookkeeping at runtime:
- Stack IR:
local.get 0/local.get 1/i32.add(3 stack operations) - Register IR:
add r2, r0, r1(1 instruction)
The register IR uses virtual registers, which are then mapped to physical registers by the register allocator. Functions with few locals map directly; functions with many locals spill to memory.
JIT compilation
The JIT compiler translates register IR to native machine code:
- ARM64: Full support — arithmetic, control flow, floating point, memory, call_indirect, SIMD
- x86_64: Full support — same coverage as ARM64
Key JIT optimizations:
- Inline self-calls (recursive functions call themselves without trampoline overhead)
- Smart spill/reload (only spill registers that are live across calls)
- Direct function calls (bypass function table lookup for known targets)
- Depth guard caching (call depth check in register instead of memory)
The JIT uses W^X memory protection: code is written to RW pages, then switched to RX before execution. A signal handler converts memory faults in JIT code back to Wasm traps.
Module instantiation
WasmModule.load(bytes) -> decode + validate + predecode
|
v
Instance.instantiate(store) -> link imports, init memory/tables/globals
|
v
Vm.invoke(func_name, args) -> execute via interpreter or JIT
The Store holds all runtime state: memories, tables, globals, function instances. Multiple module instances can share a store for cross-module linking.
Spec Coverage
zwasm targets full WebAssembly 3.0 compliance. All spec tests pass on macOS ARM64 and Linux x86_64.
Test results: 62,158 / 62,158 (100.0%)
Core specification
| Feature | Opcodes | Status |
|---|---|---|
| MVP (core) | 172 | Complete |
| Sign extension | 7 | Complete |
| Non-trapping float-to-int | 8 | Complete |
| Bulk memory | 9 | Complete |
| Reference types | 5 | Complete |
| Multi-value | - | Complete |
| Total core | 201+ | 100% |
SIMD
| Feature | Opcodes | Status |
|---|---|---|
| SIMD (v128) | 236 | Complete |
| Relaxed SIMD | 20 | Complete |
| Total SIMD | 256 | 100% |
Wasm 3.0 proposals
All 9 Wasm 3.0 proposals are fully implemented:
| Proposal | Opcodes | Spec tests | Status |
|---|---|---|---|
| Memory64 | extends existing | Pass | Complete |
| Tail calls | 2 | Pass | Complete |
| Extended const | extends existing | Pass | Complete |
| Branch hinting | metadata section | Pass | Complete |
| Multi-memory | extends existing | Pass | Complete |
| Relaxed SIMD | 20 | 85/85 | Complete |
| Exception handling | 3 | Pass | Complete |
| Function references | 5 | 104/106 | Complete |
| GC | 31 | Pass | Complete |
Additional proposals
| Proposal | Opcodes | Status |
|---|---|---|
| Threads | 79 (0xFE prefix) | Complete (310/310 spec) |
| Wide arithmetic | 4 | Complete (99/99 e2e) |
| Custom page sizes | - | Complete (18/18 e2e) |
WASI Preview 1
46 / 46 syscalls implemented (100%):
| Category | Count | Functions |
|---|---|---|
| args | 2 | args_get, args_sizes_get |
| environ | 2 | environ_get, environ_sizes_get |
| clock | 2 | clock_time_get, clock_res_get |
| fd | 14 | read, write, close, seek, stat, prestat, readdir, … |
| path | 8 | open, create_directory, remove, rename, symlink, … |
| proc | 2 | exit, raise |
| random | 1 | random_get |
| poll | 1 | poll_oneoff |
| sock | 4 | NOSYS stubs |
Component Model
| Feature | Status |
|---|---|
| WIT parser | Complete |
| Binary decoder | Complete |
| Canonical ABI | Complete |
| WASI P2 adapter | Complete |
| CLI support | Complete |
121 Component Model tests pass.
WAT parser
The text format parser supports:
- All value types including v128
- Named locals, globals, functions, types
- Inline exports and imports
- S-expression and flat syntax
- Data and element sections
- All prefix opcodes: 0xFC (bulk memory, trunc_sat), 0xFD (SIMD + lane ops), 0xFE (atomics)
- Wasm 3.0 opcodes: try_table, call_ref, br_on_null, throw_ref, etc.
- GC prefix (0xFB): GC type annotations and struct/array encoding
- 100% WAT roundtrip: 62,156/62,156 spec test modules parse and re-encode correctly
Total opcode count
| Category | Count |
|---|---|
| Core | 201+ |
| SIMD | 256 |
| GC | 31 |
| Threads | 79 |
| Others | 14+ |
| Total | 581+ |
Security Model
zwasm enforces a clear boundary between guest (WebAssembly module) and host (embedding application or CLI).
Trust boundary
+-------------------+ WASI capabilities +------------------+
| Guest (Wasm) | <-- deny-by-default --> | Host (Zig/CLI) |
| | | |
| Linear memory | Imports/exports | Native memory |
| Table entries | <-- validated types --> | Filesystem, env |
| Global variables | | Network, OS APIs |
+-------------------+ +------------------+
A valid Wasm module, no matter how adversarial, cannot:
- Read or write host memory outside its own linear memory
- Call host functions not explicitly imported
- Bypass WASI capability restrictions
- Execute code outside its validated instruction stream
- Overflow the call stack or value stack without trapping
Defense layers
Module decoding
All binary input is bounds-checked. Resource limits prevent excessive allocation:
- Section counts: 100-100,000 per section type
- Per-function locals: 50,000 max (saturating arithmetic for overflow)
- Block nesting depth: 500
- LEB128 reads bounds-checked against binary slice
Validation
Full Wasm 3.0 type checking before any code executes. 62,158 spec tests verify correctness.
Linear memory isolation
- Every load/store uses u33 arithmetic (address + offset) to prevent 32-bit overflow
- Guard pages: 4 GiB + 64 KiB PROT_NONE region catches all out-of-bounds access
- Signal handler converts memory faults to Wasm traps
JIT security
- W^X: Code pages are RW during compilation, then switched to RX before execution. Never simultaneously writable and executable.
- All branch targets validated against the register IR
- Signal handler translates faults in JIT code to Wasm traps
WASI capabilities
Deny-by-default model with 8 capability flags:
| Flag | Controls |
|---|---|
allow-read | Filesystem read |
allow-write | Filesystem write |
allow-env | Environment variables |
allow-path | Path operations (open, mkdir, unlink) |
allow-clock | Clock access |
allow-random | Random number generation |
allow-proc | Process operations |
allow-all | All of the above |
32 of 46 WASI functions check capabilities before executing. The remaining 14 are safe operations (args size queries, fd_close, etc.).
Library API defaults (loadWasi()): cli_default — only stdio, clock, random, and proc_exit. Embedders needing full access use loadWasiWithOptions(.{ .caps = .all }).
--sandbox mode: Denies all capabilities, sets fuel to 1 billion instructions and memory ceiling to 256MB. Combine with --allow-* flags for selective access:
zwasm untrusted.wasm --sandbox --allow-read --dir ./data
--env KEY=VALUE: Injected environment variables are always accessible to the guest, even without --allow-env. The --allow-env flag controls access to host environment passthrough.
Stack protection
- Call depth limit: 1024 (checked on every call)
- Operand stack: fixed-size array, bounds-checked
- Label stack: bounds-checked
What zwasm does NOT protect against
- Timing side channels: No constant-time guarantees
- Resource exhaustion: A module can loop forever (use
--fuelto mitigate) - Host function bugs: If your host functions have vulnerabilities, Wasm code can trigger them
- Spectre/Meltdown: No hardware-level mitigations
- Information leakage via timing: JIT compilation time may vary with code structure
Recommendations
- Build with
ReleaseSafefor production (Zig’s bounds checks + overflow detection) - Use
--fuelfor untrusted modules to prevent infinite loops - Use
--max-memoryto cap memory usage - Grant only the WASI capabilities the module needs
- See SECURITY.md for vulnerability reporting
Performance
Execution tiers
zwasm uses tiered execution:
- Interpreter: All functions start as register IR, executed by a dispatch loop. Fast startup, no compilation overhead.
- JIT (ARM64/x86_64): Hot functions are compiled to native code when call count or back-edge count exceeds a threshold.
When JIT kicks in
- Call threshold: After ~8 calls to the same function
- Back-edge counting: Hot loops trigger JIT faster (loop iterations count toward the threshold)
- Adaptive: The threshold adjusts based on function characteristics
Once JIT-compiled, all subsequent calls to that function execute native machine code directly, bypassing the interpreter.
Binary size and memory
| Metric | Value |
|---|---|
| Binary size (ReleaseSafe) | ~1.4 MB |
| Runtime memory (fib benchmark) | ~3.5 MB RSS |
| wasmtime binary for comparison | 56.3 MB |
zwasm is ~40x smaller than wasmtime.
Benchmark results
Representative benchmarks comparing zwasm against wasmtime 41.0.1, Bun 1.3.8, and Node v24.13.0 on Apple M4 Pro. 16 of 29 benchmarks match or beat wasmtime. 25/29 within 1.5x.
| Benchmark | zwasm | wasmtime | Bun | Node |
|---|---|---|---|---|
| nqueens(8) | 2 ms | 5 ms | 14 ms | 23 ms |
| nbody(1M) | 22 ms | 22 ms | 32 ms | 36 ms |
| gcd(12K,67K) | 2 ms | 5 ms | 14 ms | 23 ms |
| tak(24,16,8) | 5 ms | 9 ms | 17 ms | 29 ms |
| sieve(1M) | 5 ms | 7 ms | 17 ms | 29 ms |
| fib(35) | 46 ms | 51 ms | 36 ms | 52 ms |
| st_fib2 | 900 ms | 674 ms | 353 ms | 389 ms |
zwasm uses 3-4x less memory than wasmtime and 8-10x less than Bun/Node.
Full results (29 benchmarks): bench/runtime_comparison.yaml
SIMD performance
SIMD operations are functionally complete (256 opcodes, 100% spec) but run on the stack interpreter, not the register IR or JIT. This results in ~22x slower SIMD execution vs wasmtime. Planned improvement: extend register IR for v128, then selective JIT NEON/SSE.
Benchmark methodology
All measurements use hyperfine with ReleaseSafe builds:
# Quick check (1 run, no warmup)
bash bench/run_bench.sh --quick
# Full measurement (3 runs, 1 warmup)
bash bench/run_bench.sh
# Record to history
bash bench/record.sh --id="X" --reason="description"
Benchmark layers
| Layer | Count | Description |
|---|---|---|
| WAT micro | 5 | Hand-written: fib, tak, sieve, nbody, nqueens |
| TinyGo | 11 | TinyGo compiler output: same algorithms + string ops |
| Shootout | 5 | Sightglass shootout suite (WASI) |
| Real-world | 6 | Rust, C, C++ compiled to Wasm (matrix, math, string, sort) |
| GC | 2 | GC proposal: struct allocation, tree traversal |
CI regression detection
PRs are automatically checked for performance regressions:
- 6 representative benchmarks run on both base and PR branch
- Fails if any benchmark regresses by more than 20%
- Same runner ensures fair comparison
Performance tips
- ReleaseSafe: Always use for production. Debug is 5-10x slower.
- Hot functions: Functions called frequently will be JIT-compiled automatically.
- Fuel limit:
--fueladds overhead per instruction. Only use for untrusted code. - Memory: Wasm modules with linear memory allocate guard pages. Initial RSS is ~3.5 MB regardless of module size.
Memory Model
Linear memory
Each Wasm module instance has up to one linear memory (or multiple with the multi-memory proposal). Linear memory is a contiguous byte array accessed by Wasm load/store instructions.
Allocation
- Initial size specified in the module (e.g., 1 page = 64 KiB)
memory.growextends the memory by the requested number of pages- Maximum size can be specified in the module or limited by
--max-memory
Guard pages
zwasm allocates a 4 GiB + 64 KiB PROT_NONE region beyond the linear memory. Any out-of-bounds access lands in this guard region and is caught by the signal handler, which converts the fault to a Wasm trap. This avoids per-instruction bounds checks for most memory operations.
Addressing
All memory addresses use u33 arithmetic (32-bit address + 32-bit offset) to prevent overflow. This ensures that address + offset never wraps around to access valid memory.
GC heap
The GC proposal introduces managed heap objects (structs, arrays, i31ref). These live in a separate arena managed by zwasm:
- Arena allocator: Objects are allocated from a pre-allocated arena
- Adaptive threshold: GC collection triggers based on allocation pressure
- Reference encoding: GC references on the operand stack use tagged u64 values
GC objects are not accessible from linear memory and vice versa. They exist in a separate address space.
Allocator parameterization
zwasm takes a std.mem.Allocator at load time. All internal allocations (module metadata, register IR, tables, etc.) go through this allocator. The linear memory itself uses mmap directly for guard page support.
This means you can:
- Use a general-purpose allocator for normal usage
- Use an arena allocator for batch processing (load, run, free everything)
- Use a tracking allocator to monitor memory usage
- Use a fixed-buffer allocator for embedded/constrained environments
Memory limits
| Resource | Default limit | CLI flag |
|---|---|---|
| Linear memory | Module-defined max | --max-memory <bytes> |
| Call stack depth | 1024 | Not configurable |
| Operand stack | Fixed size | Not configurable |
| GC heap | Unbounded (arena) | Not configurable |
Comparison
How zwasm compares to other WebAssembly runtimes.
Overview
| Feature | zwasm | wasmtime | wasm3 | wasmer |
|---|---|---|---|---|
| Language | Zig | Rust | C | Rust/C |
| Binary size | ~1.4 MB | 56 MB | ~100 KB | 30+ MB |
| Memory (fib) | 3.5 MB | 12 MB | ~1 MB | 15+ MB |
| Execution | Interp + JIT | AOT/JIT | Interpreter | AOT/JIT |
| Wasm 3.0 | Full | Full | Partial | Partial |
| GC proposal | Yes | Yes | No | No |
| SIMD | Full (256 ops) | Full | Partial | Full |
| WASI | P1 (46 syscalls) | P1 + P2 | P1 (partial) | P1 + P2 |
| Platforms | macOS, Linux | macOS, Linux, Windows | Many (no JIT) | macOS, Linux, Windows |
When to choose zwasm
Small footprint: When binary size and memory usage matter. zwasm is ~40x smaller than wasmtime.
Zig ecosystem: When embedding in a Zig application. zwasm integrates as a native zig build dependency with zero C dependencies.
Spec completeness: When you need full Wasm 3.0 support including GC, SIMD, threads, and exception handling in a small runtime.
Fast startup: The interpreter starts executing immediately. JIT compilation happens in the background for hot functions.
When to choose alternatives
Maximum throughput: wasmtime’s Cranelift AOT compiler produces highly optimized native code. For long-running compute-heavy workloads, wasmtime may be faster. In particular, SIMD-heavy workloads are currently ~22x slower on zwasm (stack interpreter, no SIMD JIT yet).
Windows support: zwasm currently supports macOS and Linux. For Windows, use wasmtime or wasmer.
Minimal size: wasm3 is ~100 KB and runs on microcontrollers. If you need the absolute smallest runtime without JIT, wasm3 may be a better fit.
WASI Preview 2: wasmtime has the most complete WASI P2 implementation. zwasm’s P2 support is via a P1 adapter layer.
Contributor Guide
Build and test
git clone https://github.com/clojurewasm/zwasm.git
cd zwasm
# Build
zig build
# Run unit tests
zig build test
# Run a specific test
zig build test -- "Module — rejects excessive locals"
# Run spec tests (requires wasm-tools)
python3 test/spec/run_spec.py --build --summary
# Run benchmarks
bash bench/run_bench.sh --quick
Requirements
- Zig 0.15.2
- Python 3 (for spec test runner)
- wasm-tools (for spec test conversion)
- hyperfine (for benchmarks)
Code structure
src/
types.zig Public API (WasmModule, WasmFn, etc.)
module.zig Binary decoder
validate.zig Type checker
predecode.zig Stack → register IR
regalloc.zig Register allocation
vm.zig Interpreter + execution engine
jit.zig ARM64 JIT backend
x86.zig x86_64 JIT backend
opcode.zig Opcode definitions
wasi.zig WASI Preview 1
gc.zig GC proposal
wat.zig WAT text format parser
cli.zig CLI frontend
instance.zig Module instantiation
test/
spec/ WebAssembly spec tests
e2e/ End-to-end tests (wasmtime misc_testsuite, 792 assertions)
fuzz/ Fuzz testing infrastructure
realworld/ Real-world compatibility tests (30 programs)
bench/
run_bench.sh Benchmark runner
record.sh Record results to history.yaml
wasm/ Benchmark wasm modules
Development workflow
- Create a feature branch:
git checkout -b feature/my-change - Write a failing test first (TDD)
- Implement the minimum code to pass
- Run tests:
zig build test - If you changed the interpreter or opcodes, run spec tests
- Commit with a descriptive message
- Open a PR against
main
Commit guidelines
- One logical change per commit
- Commit message: imperative mood, concise subject line
- Include test changes in the same commit as the code they test
CI checks
PRs are automatically checked for:
- Unit test pass (macOS + Ubuntu)
- Spec test pass (62,158 tests)
- E2E test pass (792 assertions)
- Binary size <= 1.5 MB
- No benchmark regression > 20%
- ReleaseSafe build success