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,263 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.2 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.
Using a non-Zig language? zwasm also exposes a C API (
libzwasm) that works with any FFI-capable language — C, Python, Rust, Go, and more. See C API & Cross-Language Integration.
C API & Cross-Language Integration
The Embedding Guide showed how to use zwasm from Zig. But zwasm is also a C library — any language with a C FFI can load and run WebAssembly modules through it. This chapter covers the C API: building the shared library, calling it from C and Python, and working with host functions, WASI, and memory.
Building the library
zig build lib # Build libzwasm (.dylib / .so / .a)
zig build lib -Doptimize=ReleaseSafe # Optimized build
This produces:
| Output | Path |
|---|---|
| Shared library | zig-out/lib/libzwasm.dylib (macOS) or libzwasm.so (Linux) |
| Static library | zig-out/lib/libzwasm.a |
| C header | include/zwasm.h |
The header file include/zwasm.h is the single source of truth for the C API. All types are opaque pointers; all functions use the zwasm_ prefix.
Quickstart: C
Load a module, invoke an exported function, and read the result:
#include <stdio.h>
#include "zwasm.h"
/* Wasm module: (func (export "f") (result i32) (i32.const 42)) */
static const uint8_t WASM[] = {
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
0x03, 0x02, 0x01, 0x00,
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b
};
int main(void) {
zwasm_module_t *mod = zwasm_module_new(WASM, sizeof(WASM));
if (!mod) {
fprintf(stderr, "Error: %s\n", zwasm_last_error_message());
return 1;
}
uint64_t results[1] = {0};
if (!zwasm_module_invoke(mod, "f", NULL, 0, results, 1)) {
fprintf(stderr, "Invoke error: %s\n", zwasm_last_error_message());
zwasm_module_delete(mod);
return 1;
}
printf("f() = %llu\n", (unsigned long long)results[0]);
zwasm_module_delete(mod);
return 0;
}
Build and run:
zig build lib && zig build c-test
./zig-out/bin/example_c_hello
# f() = 42
Quickstart: Python (ctypes)
The same workflow using Python’s built-in ctypes module — no compiled bindings required:
import ctypes, os
lib = ctypes.CDLL("zig-out/lib/libzwasm.dylib") # or .so on Linux
# Declare function signatures
lib.zwasm_module_new.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
lib.zwasm_module_new.restype = ctypes.c_void_p
lib.zwasm_module_delete.argtypes = [ctypes.c_void_p]
lib.zwasm_module_delete.restype = None
lib.zwasm_module_invoke.argtypes = [
ctypes.c_void_p, ctypes.c_char_p,
ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint32,
]
lib.zwasm_module_invoke.restype = ctypes.c_bool
lib.zwasm_last_error_message.argtypes = []
lib.zwasm_last_error_message.restype = ctypes.c_char_p
# Same Wasm bytes as the C example
wasm = bytes([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
0x03, 0x02, 0x01, 0x00,
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b,
])
mod = lib.zwasm_module_new(wasm, len(wasm))
assert mod, f"Error: {lib.zwasm_last_error_message().decode()}"
results = (ctypes.c_uint64 * 1)(0)
ok = lib.zwasm_module_invoke(mod, b"f", None, 0, results, 1)
assert ok, f"Invoke error: {lib.zwasm_last_error_message().decode()}"
print(f"f() = {results[0]}") # f() = 42
lib.zwasm_module_delete(mod)
Run:
zig build lib
python3 examples/python/basic.py
Quickstart: Rust (FFI)
Rust can call the same C API via extern "C" bindings:
#![allow(unused)]
fn main() {
#[link(name = "zwasm")]
unsafe extern "C" {
fn zwasm_module_new(wasm_ptr: *const u8, len: usize) -> *mut zwasm_module_t;
fn zwasm_module_invoke(
module: *mut zwasm_module_t, name: *const std::ffi::c_char,
args: *const u64, nargs: u32, results: *mut u64, nresults: u32,
) -> bool;
fn zwasm_module_delete(module: *mut zwasm_module_t);
}
}
Build and run (requires Rust 1.85+ for edition 2024):
zig build shared-lib
cd examples/rust && cargo run
# f() = 42
See examples/rust/ for the full working example.
API reference
Functions are grouped by domain. All signatures live in include/zwasm.h.
Error handling
| Function | Description |
|---|---|
zwasm_last_error_message() | Last error as a null-terminated string. Returns "" if no error. Thread-local. |
Module lifecycle
| Function | Description |
|---|---|
zwasm_module_new(wasm_ptr, len) | Create module from binary bytes. Returns NULL on error. |
zwasm_module_new_wasi(wasm_ptr, len) | Create WASI module with default capabilities. |
zwasm_module_new_wasi_configured(wasm_ptr, len, config) | Create WASI module with custom config. |
zwasm_module_new_with_imports(wasm_ptr, len, imports) | Create module with host function imports. |
zwasm_module_delete(module) | Free all module resources. |
zwasm_module_validate(wasm_ptr, len) | Validate binary without instantiation. |
Function invocation
| Function | Description |
|---|---|
zwasm_module_invoke(module, name, args, nargs, results, nresults) | Invoke an exported function by name. |
zwasm_module_invoke_start(module) | Invoke _start (WASI entry point). |
Export introspection
| Function | Description |
|---|---|
zwasm_module_export_count(module) | Number of exported functions. |
zwasm_module_export_name(module, idx) | Name of the idx-th export. |
zwasm_module_export_param_count(module, idx) | Parameter count of an export. |
zwasm_module_export_result_count(module, idx) | Result count of an export. |
Memory access
| Function | Description |
|---|---|
zwasm_module_memory_data(module) | Direct pointer to linear memory. Invalidated by growth. |
zwasm_module_memory_size(module) | Current memory size in bytes. |
zwasm_module_memory_read(module, offset, len, out_buf) | Safe bounded read. |
zwasm_module_memory_write(module, offset, data, len) | Safe bounded write. |
WASI configuration
| Function | Description |
|---|---|
zwasm_wasi_config_new() | Create a config handle. |
zwasm_wasi_config_delete(config) | Free a config handle. |
zwasm_wasi_config_set_argv(config, argc, argv) | Set command-line arguments. |
zwasm_wasi_config_set_env(config, count, keys, key_lens, vals, val_lens) | Set environment variables. |
zwasm_wasi_config_preopen_dir(config, host_path, host_len, guest_path, guest_len) | Map a host directory. |
Host function imports
| Function | Description |
|---|---|
zwasm_import_new() | Create an import collection. |
zwasm_import_delete(imports) | Free an import collection. |
zwasm_import_add_fn(imports, module, name, callback, env, params, results) | Register a host function. |
Value encoding
Wasm values are passed as uint64_t arrays. The encoding matches the raw Wasm value representation:
| Wasm type | C encoding | Notes |
|---|---|---|
i32 | Zero-extended to uint64_t | Upper 32 bits are zero |
i64 | Direct uint64_t | No conversion needed |
f32 | IEEE 754 bits, zero-extended | Use memcpy to a float, not a cast |
f64 | IEEE 754 bits as uint64_t | Use memcpy to a double, not a cast |
Example — passing an f64 argument:
double val = 3.14;
uint64_t arg;
memcpy(&arg, &val, sizeof(arg));
uint64_t result[1];
zwasm_module_invoke(mod, "sqrt", &arg, 1, result, 1);
double out;
memcpy(&out, &result[0], sizeof(out));
Host functions
A host function is a C callback that the Wasm module can call as an import.
Callback signature:
typedef bool (*zwasm_host_fn_callback_t)(
void *env, /* User context pointer */
const uint64_t *args, /* Input parameters */
uint64_t *results /* Output buffer */
);
Working example — a print_i32 host function:
#include <stdio.h>
#include "zwasm.h"
static bool print_i32(void *env, const uint64_t *args, uint64_t *results) {
(void)env;
(void)results;
printf("wasm says: %d\n", (int32_t)args[0]);
return true;
}
int main(void) {
zwasm_imports_t *imports = zwasm_import_new();
zwasm_import_add_fn(imports, "env", "print_i32", print_i32, NULL, 1, 0);
zwasm_module_t *mod = zwasm_module_new_with_imports(wasm_bytes, wasm_len, imports);
/* ... invoke, then cleanup ... */
zwasm_module_delete(mod);
zwasm_import_delete(imports);
}
The env pointer lets you pass arbitrary context (a struct, file handle, etc.) to the callback without globals.
WASI programs
Use the config builder pattern to run WASI programs with custom settings:
/* Create and configure WASI */
zwasm_wasi_config_t *config = zwasm_wasi_config_new();
const char *argv[] = {"myapp", "--verbose"};
zwasm_wasi_config_set_argv(config, 2, argv);
zwasm_wasi_config_preopen_dir(config, "/tmp/data", 9, "/data", 5);
/* Create module with WASI config */
zwasm_module_t *mod = zwasm_module_new_wasi_configured(wasm_bytes, wasm_len, config);
/* Run the program */
zwasm_module_invoke_start(mod);
/* Cleanup */
zwasm_module_delete(mod);
zwasm_wasi_config_delete(config);
For simple WASI programs that only need default capabilities (stdio, clock, random):
zwasm_module_t *mod = zwasm_module_new_wasi(wasm_bytes, wasm_len);
zwasm_module_invoke_start(mod);
zwasm_module_delete(mod);
Thread safety
- Error buffer:
zwasm_last_error_message()returns a thread-local buffer. Safe to call from multiple threads. - Modules: A
zwasm_module_tis not thread-safe. Do not invoke functions on the same module from multiple threads concurrently. Create separate module instances per thread instead.
Next steps
- Build Configuration — customize which features are compiled in
examples/c/,examples/python/, andexamples/rust/— working examples in the repositoryinclude/zwasm.h— the complete C header with doc comments
Build Configuration
zwasm builds all features by default. For size-constrained environments — embedded systems, edge functions, minimal containers — you can strip features you do not need at compile time.
Feature flags
Pass flags to zig build to enable or disable features:
| Flag | Description | Default |
|---|---|---|
-Djit=false | Disable JIT compiler (ARM64/x86_64). Interpreter only. | true |
-Dcomponent=false | Disable Component Model (WIT, Canon ABI, WASI P2). | true |
-Dwat=false | Disable WAT text format parser. Binary-only loading. | true |
-Dsimd=false | Disable SIMD opcodes (v128 operations). | true |
-Dgc=false | Disable GC proposal (struct/array types). | true |
-Dthreads=false | Disable threads and atomics. | true |
Example:
zig build -Doptimize=ReleaseSafe -Djit=false -Dwat=false
Size impact
Measured on Linux x86_64, ReleaseSafe, stripped:
| Variant | Flags | Size (approx.) | Delta |
|---|---|---|---|
| Full (default) | (none) | ~1.23 MB | — |
| No JIT | -Djit=false | ~1.03 MB | −16% |
| No Component Model | -Dcomponent=false | ~1.13 MB | −8% |
| No WAT | -Dwat=false | ~1.15 MB | −6% |
| Minimal | -Djit=false -Dcomponent=false -Dwat=false | ~940 KB | −24% |
The minimal configuration still passes all non-JIT spec tests and supports the full Wasm 3.0 instruction set (interpreted).
Common profiles
Interpreter-only
Smallest binary. Suitable when startup latency matters more than peak throughput:
zig build -Doptimize=ReleaseSafe -Djit=false
Minimal CLI
Strip everything not needed for running core Wasm binaries:
zig build -Doptimize=ReleaseSafe -Djit=false -Dcomponent=false -Dwat=false
Full (default)
All features enabled. Recommended for general use:
zig build -Doptimize=ReleaseSafe
How it works
Feature flags are defined in build.zig as b.option(bool, ...) values, then passed to the Zig module as compile-time options. Source files check them with @import("build_options"):
const build_options = @import("build_options");
if (build_options.enable_jit) {
// JIT compilation path
} else {
// Interpreter-only path
}
When a feature is disabled, Zig’s dead code elimination removes all related code from the binary. There is no runtime overhead — disabled features simply do not exist in the output.
Library builds with flags
Feature flags work with the library target too:
# Build a minimal shared library (no JIT, no component model)
zig build lib -Doptimize=ReleaseSafe -Djit=false -Dcomponent=false
The resulting libzwasm.so / .dylib will be smaller but still expose the full C API. Functions that depend on disabled features will return an error when called (e.g., loading a component binary with -Dcomponent=false returns an error via zwasm_last_error_message()).
CI size matrix
The CI pipeline includes a size-matrix job that builds five variants (full, no-jit, no-component, no-wat, minimal) and reports their stripped sizes. This catches unexpected size regressions when new code is added.
See .github/workflows/ci.yml for the full configuration.
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 from C, Python, or other languages?
Yes. zwasm provides a C API (libzwasm) that any FFI-capable language can use. Build with zig build lib to produce the shared library, then call zwasm_* functions via your language’s FFI mechanism (e.g., Python ctypes, Rust extern "C", Go cgo). See C API & Cross-Language Integration.
Can I reduce the binary size?
Yes. Use build-time feature flags to strip features you do not need: -Djit=false (no JIT, −16%), -Dcomponent=false (no Component Model, −8%), -Dwat=false (no WAT parser, −6%). Combining all three produces a ~940 KB minimal binary (−24%). See Build Configuration.
Can I use zwasm without JIT?
Yes. The interpreter handles all functions by default. JIT is only triggered for hot functions. To build without JIT entirely, use -Djit=false — this removes the JIT compiler from the binary and reduces its size by ~16%. Functions that are called fewer than ~8 times will always use the interpreter regardless.
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 | ~2.1K |
validate.zig | Type checker, operand stack simulation | ~2.4K |
predecode.zig | Stack IR → register IR conversion | ~0.7K |
regalloc.zig | Virtual → physical register allocation | ~2.3K |
vm.zig | Interpreter, execution engine, store | ~10.2K |
jit.zig | ARM64 JIT backend (incl. NEON SIMD) | ~7.8K |
x86.zig | x86_64 JIT backend (incl. SSE SIMD) | ~7.2K |
types.zig | Core type definitions, value types | ~1.4K |
opcode.zig | Opcode definitions (581+ total) | ~1.3K |
wasi.zig | WASI Preview 1 (46 syscalls) | ~3.1K |
gc.zig | GC proposal: heap, struct/array types | ~1.4K |
wat.zig | WAT text format parser | ~6.0K |
cli.zig | CLI frontend | ~2.3K |
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 (253/256 NEON native)
- x86_64: Full support — same coverage, SIMD (244/256 SSE native, SSE4.1 minimum)
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,263 / 62,263 (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,263 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.2 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 (v128) operations are JIT-compiled to native NEON (ARM64, 253/256 opcodes) and SSE (x86_64, 244/256 opcodes). v128 values are stored as split 64-bit halves in the register file.
| Benchmark | zwasm scalar | zwasm SIMD | wasmtime SIMD | SIMD speedup |
|---|---|---|---|---|
| image_blend (128x128) | 73 ms | 16 ms | 12 ms | 4.7x |
| matrix_mul (16x16) | 10 ms | 6 ms | 8 ms | 1.6x |
| byte_search (64KB) | 52 ms | 43 ms | 5 ms | 1.2x |
| dot_product (4096) | 142 ms | 190 ms | 15 ms | 0.75x |
matrix_mul beats wasmtime. image_blend within 1.3x. dot_product is slower due to v128.load-heavy inner loop and split storage overhead.
Compiler-generated SIMD code (C -msimd128) shows larger gaps due to patterns like
i16x8.replace_lane that are expensive with split v128 storage. Future work: contiguous
v128 register storage (W37) to eliminate this overhead.
Full data: bench/simd_comparison.yaml
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 |
| SIMD | 10 | WAT microbench (4) + C -msimd128 real-world (5), scalar/SIMD |
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.2 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, Windows | 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. SIMD microbenchmarks are competitive (matrix_mul beats wasmtime), but compiler-generated SIMD code shows larger gaps due to split v128 storage overhead.
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,263 tests)
- E2E test pass (792 assertions)
- Binary size <= 1.5 MB
- No benchmark regression > 20%
- ReleaseSafe build success