Skip to content

Experimental: sandboxed wasm task mods with a WebGPU compute path - #56

Draft
IGN-Styly wants to merge 4 commits into
mainfrom
feat/wasm-webgpu-mods
Draft

IGN-Styly wants to merge 4 commits into
mainfrom
feat/wasm-webgpu-mods

Conversation

@IGN-Styly

@IGN-Styly IGN-Styly commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Experimental. This is opt-in: the default build and the server are unchanged unless you build engine --features wasm.

What

This adds a new enginewasm crate. With it, a task type can be defined by an untrusted WebAssembly module (wasm32 or wasm64) instead of a native .rf mod. The mod can also ship a WGSL compute kernel that workers run on the GPU through wgpu (WebGPU on Vulkan/Metal/DX12).

  • Server: loads ./mods/*.wasm and uses the mod's ge_verify to check payloads and results. It never runs tasks.
  • Worker (wasm_worker): leases chunks and runs them either on the GPU (the mod's kernel) or on the CPU (the mod's ge_run). Both paths must produce identical bytes.
  • Benchmark: wasm_worker --bench N compares native vs wasm vs GPU locally. The native row is compiled from the example mod's own mix.rs.
  • Configuration: limits and capability grants come from config.toml [wasm], with per-mod overrides. Everything is denied by default.

Full spec is in docs/wasm.md.

Configuration: limits and grants

[wasm]                              # defaults for every wasm mod
max_memory_mb = 256
call_timeout_ms = 2000
max_io_mb = 16
gpu_timeout_ms = 10000
memory64 = true

[wasm.mods."demo:mix"]              # overrides for one task type
max_memory_mb = 8192                # above 4 GiB needs a wasm64 module
fs  = [{ name = "data", path = "/srv/data" }, { name = "out", path = "/srv/out", write = true }]
net = ["api.example.com:443", "10.0.0.5:9000"]
  • Who reads it: the server and wasm_worker --config both read this table.
  • Fail closed: a bad [wasm] table, such as a typo'd key or a malformed host:port, stops startup instead of falling back to defaults.
  • Files (ge.fs_read / ge.fs_write):
    • Paths are <mount>/<relative path>.
    • Access goes through cap-std directory handles, so .., absolute paths and symlinks that point out of the mount are all refused.
    • Mounts are read-only unless write = true.
  • Network (ge.net_connect / send / recv / close):
    • Only exact host:port matches from the allowlist can be connected to.
    • Sockets get the call's remaining time budget as their timeouts, and all of them close when the call ends.
  • Ungranted calls: every ge.* import is always linked, so any mod loads. A call the mod wasn't granted returns -1.

64-bit wasm

  • Width detection: the host reads the width from the exported memory. All host code is generic over pointer width.
  • ABI v2: ge_alloc(usize) -> usize, ge_verify(ptr, len) -> i32, and ge_run(ptr, len, out) -> i32, which writes [ptr, len] to a slot the host allocates.
  • One source, two targets: the same Rust source builds for wasm32-unknown-unknown and, on nightly with -Zbuild-std, for wasm64-unknown-unknown. The example ships both.
  • Large memories: wasm64 mods can be given more than 4 GiB of memory. A test grows a guest to 5 GiB.
  • Switch: memory64 = false refuses 64-bit modules, globally or per mod.

Safety model

CPU path (wasmtime):

  • wasmtime is built without WASI, threads, GC or the component model. The only way out of the sandbox is a granted ge.* call.
  • Memory is capped (StoreLimits). Oversized initial memory fails to instantiate, and memory.grow past the cap returns -1.
  • Every call runs under an epoch deadline, so an infinite loop traps instead of hanging.
  • The guest stack is limited to 1 MiB.
  • Every pointer and length the guest returns is bounds-checked before the host reads it.
  • A trap or timeout drops that instance; the next call gets a fresh one. A verify that errors counts as "invalid" and never panics the server.

Loading:

  • Metadata (ge:meta) and the kernel (ge:wgsl) are read from custom sections before any guest code is compiled or run. Unknown keys are rejected.
  • Namespace and name must match [A-Za-z0-9_-] (: is refused, because these names become storage key prefixes).
  • A mod can't replace a task type that is already registered.

GPU path (wgpu):

  • At load time, naga checks the kernel against a fixed contract: exactly one read-only input buffer and one read_write output buffer, with nothing else bound.
  • wgpu compiles the kernel with runtime bounds checks.
  • GPU errors are captured by error scopes instead of hitting wgpu's default panic handler.
  • Each batch has a timeout.

Performance

These numbers are from demo:mix (lowbias32, 1024 rounds per word) on an RX 6800 (Vulkan) against 12 CPU threads. Every backend produced identical bytes, and rates vary by about ±15% between runs.

payload native (no wasm) wasm32 wasm64 GPU (wgpu)
20k × 1 KiB 17–20k tasks/s 0.99–1.00× 0.96–0.98× 21–26×
4k × 16 KiB 1.2k tasks/s 0.98× 0.99× 24×
  • Sandbox cost: on a compute-bound kernel the sandbox costs about 0–2%.
  • wasm64 cost: about 1–4% more, because without the 4 GiB guard-page trick every memory access is explicitly bounds-checked.
  • Light kernels: with 32 rounds per word, PCIe transfer dominates and the GPU is only about as fast as the CPU.

Tests

enginewasm/tests/sandbox.rs has 22 tests, all using hand-written WAT guests:

  • the ABI round trip and multithreaded batches;
  • hostile guests: infinite loop, memory bomb, oversized memory, out-of-bounds pointers, a WASI import;
  • bad metadata and bad kernels;
  • wasm64: running, the memory64 switch, a 5 GiB memory, and the shared host calls;
  • fs: a call without a grant is denied; .., // and unknown mounts are denied; a symlink escape is refused; read-only vs writable mounts;
  • net: only allowlisted endpoints connect (tested against a loopback echo server);
  • config: defaults, per-mod overrides and inheritance, and rejection of bad tables;
  • engine registration and submit-time verification;
  • CPU/GPU parity (skips without a GPU);
  • an end-to-end loopback-TCP test: insert → verify → lease → run → complete.

cargo test --workspace, cargo build --workspace and clippy on the new code are all clean.

Known gaps / follow-ups

  • Some host calls can't be interrupted. File reads and DNS lookups block, and the epoch deadline can't stop them; only socket I/O is time-bounded.
  • The net allowlist is by name. DNS can still resolve an allowed name to an internal address; pinning to IPs or CIDRs is a follow-up.
  • No clock or randomness on purpose. Results must stay deterministic for the CPU/GPU parity check and for re-execution after a lost lease.
  • No RunnerKind::Gpu on the wire. The worker picks its backend locally. Advertising it to the server needs a PROTO_VERSION bump.
  • Derived types aren't supported yet. The worker hands derived chunks back.
  • Instances are reused across calls of the same mod. A guest can keep state between the tasks it handles, so this isn't per-task isolation.
  • GPU timeouts don't preempt. A timeout stops the host waiting but doesn't stop a running kernel.
  • No buffer reuse. GPU buffers are allocated per batch; reusing them would help light kernels.

Try it

cd examples/wasm-mix
cargo build --release --target wasm32-unknown-unknown
cargo +nightly build --release --target wasm64-unknown-unknown -Zbuild-std=core,alloc
cd ../..
cargo run --release -p engine --features wasm --bin wasm_worker -- \
  --mod examples/wasm-mix/target/wasm64-unknown-unknown/release/wasm_mix.wasm --bench 20000 --words 256

🤖 Generated with Claude Code

Add the `enginewasm` crate: task types defined by untrusted core-wasm
modules (wasmtime, no WASI; only `ge.log` may be imported) under memory,
epoch-timeout and io caps, with metadata read from custom sections before
any guest code runs. A mod may ship a WGSL kernel (`ge:wgsl`), validated
with naga against a two-buffer binding contract and run through wgpu with
many payloads packed per dispatch; it must match the CPU path byte for byte.

The server loads `./mods/*.wasm` behind `engine --features wasm` and uses
the mod's `ge_verify` for payloads and results; `wasm_worker` leases and
runs tasks on the GPU or CPU, and `--bench` compares the two locally.
Spec in docs/wasm.md; example guest in examples/wasm-mix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread engine/src/bin/wasm_worker.rs Dismissed
IGN-Styly and others added 3 commits October 25, 2026 21:28
…line

- `[wasm]` / `[wasm.mods."ns:name"]` in config.toml (`WasmConfig`) set
  memory, call/GPU timeouts, io caps, pool size and memory64 per mod, plus
  capability grants: `fs` mounts (read-only or writable, via cap-std
  handles so `..`/absolute/symlink escapes are refused) and a `net`
  `host:port` allowlist. Everything stays denied by default; the server
  and `wasm_worker --config` fail closed on a bad table.
- `ge.fs_read`/`fs_write`/`net_connect`/`send`/`recv`/`close` host calls;
  sockets inherit the call's remaining budget and close when it ends.
- wasm64 (memory64) modules: the host is generic over pointer width and
  the ABI is now `ge_run(ptr, len, out) -> i32` with a `[ptr, len]` result
  slot, so one Rust source builds for wasm32 and wasm64 (example included).
- `wasm_worker --bench` adds a native row compiled from the example's own
  `mix.rs` and reports speedups against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	AGENTS.md
#	Cargo.lock
#	engine/Cargo.toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants