Skip to content

ooyeku/wick

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wick

A small, dependency-free WebAssembly interpreter for embedding plugin systems in Zig applications.

wick runs wasm 1.0 core modules by direct interpretation — no JIT, no codegen, no libc. An std.mem.Allocator is the only thing it asks of its host. It is built for one job: letting an application execute third-party code where the host controls every doorway (imports are the only way out of the sandbox) and every budget (memory ceiling, call depth, instruction fuel).

wick is a deliberate interpreter, not a JIT: a compiling engine will still beat it by several times on compute-bound guests, and that tradeoff is the point. What you get instead is roughly 800–1550 MIPS and 126M host calls per second from a dependency-free library that compiles into your binary — fast enough that plugin calls belong on per-request and per-frame paths, not just at startup.

Features

  • wasm 1.0 core execution — full control flow, i32/i64/f32/f64 arithmetic and conversions (including trapping and saturating float→int truncations), linear memory with bounded growth, globals, funcref tables with call_indirect (runtime type-checked), active data and element segments, and the bulk-memory ops LLVM toolchains emit (memory.copy, memory.fill, memory.init, data.drop).

  • Typed host bindings — declare host functions with native Zig signatures; comptime codegen does the argument unpacking, including []const u8 parameters resolved through guest memory with bounds checks:

    const Host = struct {
        fn log(call: wick.bindings.Ctx(Host), level: u32, msg: []const u8) void {
            std.log.info("plugin[{d}]: {s}", .{ level, msg });
        }
    };
    const imports = [_]wick.HostImport{
        .{ .module_name = "env", .field_name = "log", .func = wick.bind(Host, Host.log) },
    };
  • Typed guest calls — the mirror image: resolve an export against a Zig function type once and call it with native values. A missing export fails with error.ExportNotFound and a mismatched one with error.SignatureMismatch, both before any guest code runs:

    const add = try plugin.func("add", fn (i32, i32) i32);
    const sum = try add.call(.{ 2, 3 });
  • Resource limits — per-instance Limits: instruction fuel (a runaway module returns error.OutOfFuel instead of hanging your host), call-depth cap, value-stack cap, and a memory ceiling the host imposes on top of whatever the module declares.

  • Hostile-input hardening — decode bounds every count against the bytes backing it, so a forged section can't drive a multi-gigabyte allocation; unimplemented opcodes fail loudly with error.UnsupportedOp rather than miscomputing silently.

Performance

zig build bench on an M-series laptop, best of 5:

workload time MIPS
fib — call-heavy recursion 2.99 ms 851
memsum — linear-memory loops 7.35 ms 1516
pingpong — host-call round trips 2.38 ms 1387 (126M calls/s)
strcount — byte-wise scanning 15.19 ms 1569

Each function body is translated once at decode time into pre-decoded IR — immediates unpacked, branch targets resolved to direct indices, stack heights validated — and executed by a threaded-dispatch loop over preallocated stacks. Nothing inside the interpreter loop allocates.

Fuel metering costs 2–14%, so there is no real speed argument for running untrusted code unmetered. Fuel is charged once per basic block; because a block has no internal branches, the total charged still equals the exact instruction count.

ROADMAP.md records how this was measured, including one optimization that was implemented, measured, and rejected.

Non-goals (for now)

SIMD, threads, multi-memory, reference types beyond funcref tables, and WASI. Gaps surface as typed errors, never as wrong answers.

Quick start

const wick = @import("wick");

var plugin = try wick.Plugin.init(allocator, wasm_bytes, .{
    .imports = &imports,
    .user_data = &host,
    .limits = .{
        .fuel = 10_000_000,     // ~instructions per call
        .max_memory_pages = 64, // 4 MiB ceiling
    },
});
defer plugin.deinit();

// Resolve an export against a Zig signature, then call it with
// native types. A missing or mismatched export fails here, before
// any guest code runs.
const add = try plugin.func("add", fn (i32, i32) i32);
const sum = try add.call(.{ 2, 3 });

Plugin is the one-call path; decode / instantiateWithLimits / invoke are still there underneath and reachable through plugin.module and plugin.instance.

Anything bigger than a scalar crosses as a (ptr, len) pair into guest memory, since that is all wasm itself has. Point wick.GuestAllocator at whatever allocation exports your guest provides:

var ga = try wick.GuestAllocator.init(&plugin.instance, "alloc", "free");
const ptr = try ga.dupe("hello, plugin");
defer ga.free(ptr, 13) catch {};
_ = try greet.call(.{ ptr, 13 });

Instance also has direct slice, writeBytes, read/write (little-endian scalars), and readStruct/writeStruct (for extern structs) — every one bounds-checked against guest memory.

See examples/ for a complete host and a real Zig plugin compiled to wasm; zig build example builds and runs both.

Install

Requires Zig 0.16.0 or newer.

zig fetch --save git+https://github.com/ooyeku/wick#v0.1.0
// build.zig
const wick = b.dependency("wick", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("wick", wick.module("wick"));

Testing

zig build test runs the suite: decoder hardening, control-flow regressions, float semantics (NaN, saturation), call_indirect dispatch and traps, fuel exhaustion, and an end-to-end test of the full plugin-host integration pattern (host imports, user_data, guest strings, export lookup and invocation).

Four more build steps back the roadmap:

  • zig build example — compile the example plugin to wasm and run the host that loads it.

  • zig build bench — the benchmark suite (ReleaseFast), reporting per-workload instruction counts and MIPS.

  • zig build fuzz — fuzz targets for the decoder and executor.

  • zig build spec — the official WebAssembly spec test suite scoreboard; run scripts/fetch-spec-tests.sh once first (needs git and wabt).

License

MIT

About

A small, dependency-free WebAssembly interpreter for embedding plugin systems in Zig applications

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages