WASI & GOOS=wasip1 — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solutions are sketched at the end.
Easy¶
Task 1 — Build and run a wasip1 module on two runtimes¶
Write a main.go that prints a line. Build it with GOOS=wasip1 GOARCH=wasm, then run the same main.wasm on two different runtimes (e.g. Wasmtime and wazero).
Success: - One .wasm artifact runs unchanged on both runtimes. - Output is identical.
Goal. See that a wasip1 module is a portable artifact, not runtime-specific.
Task 2 — Observe deny-by-default¶
Write a program that reads /data/in.txt and prints its size. Run it three ways:
Confirm the first fails ("not found"/"not permitted") and the second works after you create ./data/in.txt.
Goal. Internalise that the filesystem is invisible until a directory is preopened.
Task 3 — Pass args and env explicitly¶
Write a program that prints os.Args, os.Getenv("TOKEN"), and os.Getenv("PATH"). Run it with and without --env TOKEN=....
Observe: TOKEN is empty until granted; PATH is always empty (your shell environment is not inherited); args appear only after the module name.
Goal. See that args, env, and files are three independent capability grants.
Task 4 — Set up the go run / go test exec wrapper¶
Configure the toolchain so go run . works for wasip1:
export PATH="$PATH:$(go env GOROOT)/lib/wasm"
export GOWASIRUNTIME=wasmtime
GOOS=wasip1 GOARCH=wasm go run .
Then deliberately unset PATH's wasm entry and observe the failure (an "exec format error" or "no such file").
Goal. Understand why go run/go test need the exec wrapper and GOWASIRUNTIME.
Task 5 — Diagnose a guest/host path mismatch¶
Take Task 2's program but change the code to read /input/in.txt. Run it with --dir=./data::/data. It fails even though the directory is preopened. Fix it by either changing the code to /data/in.txt or the flag to --dir=./data::/input.
Goal. Recognise that "preopened but still not found" is almost always a guest-path mismatch.
Medium¶
Task 6 — A portable filter as a .wasm¶
Write a stdin→stdout filter (e.g. uppercase each line, or count words). Build it for wasip1. Pipe input through it on a runtime:
Confirm it behaves as a Unix filter, shipped as one architecture-independent module.
Goal. See that filter-shaped programs port to wasip1 with zero changes.
Task 7 — Prove networking does not work, then guard it¶
Write a program that calls net.Dial("tcp", "example.com:80"). Build for wasip1 and run. Observe the failure (an unknown import socket error at instantiation, or a runtime error). Then refactor: put the networking behind an interface with a //go:build !wasip1 native implementation and a //go:build wasip1 implementation that returns a clear "not supported on wasip1" error.
Goal. Internalise the no-networking reality and the build-tag fix.
Task 8 — go:wasmimport against a standalone runtime (and watch it fail)¶
Write a program with a go:wasmimport declaration:
Build and run it on a stock wasmtime main.wasm with no embedder. Observe instantiation fail with unknown import: env::log_message. This is the expected behaviour — the host must supply the import.
Goal. Understand that go:wasmimport requires a host that provides the function.
Task 9 — Inspect the import surface¶
For any wasip1 binary you built, dump its imports:
List the wasi_snapshot_preview1 functions present. For Task 7's networking binary, find the socket import that the stock runtime cannot satisfy.
Goal. Learn to read the module's contract — the import list is the truth about host obligations.
Task 10 — Strip and measure the binary¶
Build the same program twice: once plain, once with -ldflags="-s -w" -trimpath. Compare sizes with ls -l / du -h. Note the reduction and that the runtime is still embedded (it is still MB-scale).
Goal. Know the size levers and that Go wasip1 binaries are large by nature.
Task 11 — Test the wasip1 path in CI¶
Add a _test.go that exercises a function reading a fixture from testdata/. Run go test ./... under wasip1 via the exec wrapper, ensuring testdata/ is preopened (through the runtime's flags). Confirm the test passes on wasm, not just natively.
Goal. Build the CI habit that catches build-tag and ABI mistakes the native build hides.
Hard¶
Task 12 — Embed a Go guest in a Go host with wazero¶
Write a Go host program using wazero that:
- Reads a
.wasmplugin file. - Instantiates WASI preview-1 host functions.
CompileModuleonce.- Instantiates the module with stdin set to some input and stdout captured.
- Returns the captured output.
Then write a Go guest (built for wasip1) that reads stdin, transforms it, and writes stdout. Run the guest through your host.
Goal. Build the canonical Go-host-embeds-Go-guest plugin pattern.
Task 13 — Add a custom host function (go:wasmimport + wazero)¶
Extend Task 12: in the host, register a host.emit(ptr, len) function via HostModuleBuilder that reads the guest's memory (m.Memory().Read) and logs it. In the guest, declare //go:wasmimport host emit and call it with a pointer+length. Confirm the host logs the guest's message.
Goal. Implement the inbound interop ABI end-to-end with safe memory reads.
Task 14 — Bound an untrusted plugin¶
Extend Task 12 so a misbehaving guest cannot harm the host:
- Write a guest with an infinite loop.
- In the host, wrap instantiation in
context.WithTimeout(ctx, 1*time.Second). - Confirm the guest is interrupted and the host receives a trap/cancellation error rather than hanging.
- Add a memory limit and confirm an unbounded-allocation guest traps instead of OOMing the host.
Goal. Practise resource containment for untrusted code — what the sandbox does not give you for free.
Task 15 — go:wasmexport reactor (Go 1.24+)¶
On Go 1.24 or newer, write a guest that exports a function:
In a wazero host, instantiate the module, ensure _initialize runs, then call process multiple times with different inputs without re-instantiating. Verify the first call works and subsequent calls reuse the same instance.
Goal. Build the request/response (reactor) plugin model and see the difference from a run-once command.
Task 16 — Capability scoping per invocation¶
Extend the plugin host so each invocation gets a fresh per-call scratch directory preopened at a fixed guest path, and only a contractually-defined env set. Run two invocations and confirm one cannot see the other's scratch files, and that neither can read a directory you did not grant.
Goal. Implement capability scoping as a host responsibility.
Bonus / Stretch¶
Task 17 — Compile-once, instantiate-many benchmark¶
Benchmark two host designs: (A) CompileModule + InstantiateModule on every request; (B) CompileModule once, InstantiateModule per request. Measure throughput. Quantify how much compilation dominates.
Goal. Prove the "compile once" rule with numbers.
Task 18 — Deterministic execution with injected clock and seed¶
Using wazero, inject a fixed wall clock and a seeded random source into the module config. Run a guest that prints time.Now() and a random number twice. Confirm both runs produce identical output.
Goal. Make wasip1 execution deterministic by controlling the host — the basis for replay/consensus systems.
Task 19 — Cross-runtime portability matrix¶
Take one non-trivial .wasm (file I/O + clock + random, no networking) and run it on Wasmtime, wazero, and WasmEdge with each runtime's path-mapping syntax. Document the syntax differences and confirm identical behaviour for the standard feature set.
Goal. Experience real cross-runtime portability and its syntax caveats.
Task 20 — AOT precompile and cold-start comparison¶
For a runtime that supports it (Wasmtime), wasmtime compile main.wasm -o main.cwasm. Time a cold run from the .wasm (compile + instantiate) versus from the .cwasm (instantiate only). Quantify the cold-start improvement.
Goal. Understand AOT caching as the edge/serverless cold-start lever.
Solutions (sketched)¶
Solution 1¶
go mod init example.com/w
cat > main.go <<'EOF'
package main
import "fmt"
func main() { fmt.Println("portable wasm") }
EOF
GOOS=wasip1 GOARCH=wasm go build -o main.wasm .
wasmtime main.wasm
wazero run main.wasm
Solution 2¶
No --dir → no descriptor for /data → the open fails. --dir=./data::/data creates a preopen the os package resolves against.
Solution 3¶
PATH is empty because the shell environment is never inherited; TOKEN is empty until --env TOKEN=...; args follow the module name. Three independent grants.
Solution 4¶
The wrapper go_wasip1_wasm_exec lives in $(go env GOROOT)/lib/wasm; it reads GOWASIRUNTIME. Without it on PATH, the OS cannot launch a .wasm.
Solution 5¶
The directory is preopened as guest path /data, but the code asked for /input/.... Match the guest path on both sides. "Preopened but not found" is a mapping bug.
Solution 6¶
Filters port for free because they use only stdio.Solution 7¶
net.Dial lowers to a socket import the stock runtime cannot satisfy → unknown import. Guard:
//go:build wasip1
func dial(addr string) (net.Conn, error) {
return nil, errors.New("dial: not supported on wasip1")
}
Solution 8¶
unknown import: env::log_message is correct and expected — go:wasmimport only resolves against a host that provides the function. Run it through your own embedder (Task 13), not a stock CLI.
Solution 9¶
You will seefd_write, clock_time_get, random_get, args_get, etc. The networking binary shows an extra sock_* import the stock runtime rejects. Solution 10¶
GOOS=wasip1 GOARCH=wasm go build -o plain.wasm .
GOOS=wasip1 GOARCH=wasm go build -ldflags="-s -w" -trimpath -o slim.wasm .
ls -l plain.wasm slim.wasm
slim.wasm is smaller but still MB-scale; the runtime is embedded. TinyGo is the next lever. Solution 11¶
export PATH="$PATH:$(go env GOROOT)/lib/wasm"
export GOWASIRUNTIME=wazero
GOOS=wasip1 GOARCH=wasm go test ./...
testdata/. A native-only pass is not proof the wasip1 build works. Solution 12¶
r := wazero.NewRuntime(ctx); defer r.Close(ctx)
wasi_snapshot_preview1.MustInstantiate(ctx, r)
compiled, _ := r.CompileModule(ctx, wasmBytes) // once
cfg := wazero.NewModuleConfig().WithStdin(in).WithStdout(&out)
mod, _ := r.InstantiateModule(ctx, compiled, cfg); defer mod.Close(ctx)
out holds the guest's output. Solution 13¶
Host:
r.NewHostModuleBuilder("host").NewFunctionBuilder().
WithFunc(func(_ context.Context, m api.Module, ptr, n uint32) {
b, _ := m.Memory().Read(ptr, n); log.Printf("guest: %s", b)
}).Export("emit").Instantiate(ctx)
//go:wasmimport host emit + call with unsafe.Pointer(&b[0]), uint32(len(b)). Memory().Read validates bounds. Solution 14¶
context.WithTimeout interrupts the guest; instantiation returns an error instead of hanging. A memory limit makes unbounded allocation trap. Memory isolation is free; resource limits are your job.
Solution 15¶
On Go 1.24+, //go:wasmexport process makes the module a reactor. The host calls _initialize once, then process repeatedly. On Go < 1.24 the directive is rejected — check go version.
Solution 16¶
Per call, os.MkdirTemp a host dir and WithFSConfig/WithDir it at a fixed guest path; pass only contractual env. Each invocation sees only its own scratch dir.
Solution 17¶
Design B wins decisively; CompileModule dominates. The measured ratio is the argument for caching the compiled module.
Solution 18¶
wazero config injects a fixed walltime and a deterministic random source; both runs print identical time and random values. This is the determinism opt-in.
Solution 19¶
Wasmtime --dir=host::guest, wazero -mount=host:guest, WasmEdge reverses order. Standard-feature behaviour is identical; only the invocation syntax differs.
Solution 20¶
wasmtime compile main.wasm -o main.cwasm
time wasmtime run main.wasm # compile + instantiate
time wasmtime run --allow-precompiled main.cwasm
.cwasm path skips compilation — the edge cold-start win. Checkpoints¶
After the easy tasks: you can build and run a wasip1 module on multiple runtimes, grant capabilities, set up the exec wrapper, and diagnose a path mismatch. After the medium tasks: you can ship a portable filter, guard networking with build tags, use go:wasmimport, inspect the import surface, strip binaries, and test the wasm path in CI. After the hard tasks: you can embed a Go guest in a Go host with wazero, implement a custom host function, bound untrusted execution, and build a go:wasmexport reactor with per-invocation capability scoping. After the bonus tasks: you have measured compile-once gains, achieved deterministic execution, verified cross-runtime portability, and quantified AOT cold-start improvements.
In this topic