WASI & GOOS=wasip1 — Professional Level¶
Table of Contents¶
- Introduction
- What the Toolchain Does for
GOOS=wasip1 - The Import Surface:
wasi_snapshot_preview1in Detail go:wasmimportInternals and Type Rulesgo:wasmexportInternals (Go 1.24)- The Memory Boundary: Pointers, Pinning, and the GC
- The
_start/_initializeLifecycle - The Exec Wrapper and
GOWASIRUNTIME - Embedding a Go Guest in a Go Host with wazero
- Build Flags, Size, and Reproducibility
- CI/CD for
wasip1Artifacts - Edge Cases the Source Reveals
- Operational Playbook
- Summary
Introduction¶
The professional level treats wasip1 not as a build flag but as the surface area of a contract between four subsystems: the Go compiler and linker, the wasm execution model, the WASI preview-1 ABI, and the host runtime that supplies it. The go build command emits a module, but every import that module declares is later resolved by a host under strict instantiation checks. Misunderstanding the import surface, the memory boundary, or the lifecycle is the single most common source of opaque "unknown import" and "memory corruption" failures.
This file is for engineers who build wasip1 infrastructure, embed wasm in Go hosts, define host/guest ABIs, or own the correctness of sandboxed execution. After reading you will:
- Know what the toolchain does for
GOOS=wasip1, end to end. - Reason about the
wasi_snapshot_preview1import set as the module's actual contract. - Use
go:wasmimport(Go 1.21) andgo:wasmexport(Go 1.24) with correct type and memory rules. - Understand the
_start/_initializelifecycle that distinguishes a command from a reactor. - Embed a Go-compiled guest inside a Go host with wazero, correctly and performantly.
wasip1 is conceptually simple — Go compiled to a sandboxed module — but its details govern the safety and reproducibility of the system for years. Treating it as "just another GOOS" misses the ABI machinery that makes host interop safe.
What the Toolchain Does for GOOS=wasip1¶
When you run GOOS=wasip1 GOARCH=wasm go build, the toolchain:
- Selects the
wasip1runtime support. The Go runtime has a port (runtime/os_wasip1.go,syscall/syscall_wasip1.go, and friends) whose syscall layer callswasi_snapshot_preview1functions instead of Linux/Darwin syscalls. - Compiles to the wasm instruction set.
GOARCH=wasmselects the wasm backend; the result is a.wasmbinary in the standard WebAssembly binary format. - Emits imports for the WASI functions the runtime uses. Every WASI call the linked runtime can reach becomes an
(import "wasi_snapshot_preview1" "...")entry in the module. - Emits a
_startexport. Awasip1go buildproduces a command module: the host calls the exported_start, which runs Go's runtime init and yourmain, then exits. (Withgo:wasmexport, additional exports appear; see below.) - Embeds the Go runtime. The scheduler, GC, and stdlib are compiled into the module — which is why even a trivial binary is several MB.
The output is a single self-describing artifact: its import section is the complete list of host obligations, and its export section is the complete list of entry points. Dumping both (wasm-tools print, wasm-objdump -x) tells you everything the host must provide and everything it can call.
The Import Surface: wasi_snapshot_preview1 in Detail¶
The module imports a fixed namespace. The functions Go's wasip1 runtime relies on include:
| Function | Backs (Go) |
|---|---|
fd_write | os.Stdout.Write, file writes |
fd_read | os.Stdin.Read, file reads |
fd_close, fd_seek, fd_fdstat_get | file descriptor management |
path_open | os.Open, os.OpenFile (relative to a preopen) |
path_filestat_get, path_unlink_file, path_create_directory | filesystem metadata/mutation |
fd_prestat_get, fd_prestat_dir_name | enumerating preopened directories |
clock_time_get, clock_res_get | time.Now, monotonic time |
random_get | crypto/rand, math/rand seeding |
args_sizes_get, args_get | os.Args |
environ_sizes_get, environ_get | os.Getenv, os.Environ |
poll_oneoff | timers, time.Sleep, blocking |
proc_exit | os.Exit, normal termination |
sched_yield | scheduler cooperation |
Three professional observations:
- The import set defines feasibility, not the language. A Go feature whose syscalls are not in this set either errors at runtime or is excluded by build tags. There is no
socket,fork,execve,mmap-with-arbitrary-flags, orptracehere. poll_oneoffis how blocking works. Single-threaded Go onwasip1usespoll_oneoffto wait on timers and fds, yielding the one thread cooperatively. Runtimes implement it differently; subtle blocking/timing bugs trace back here.- Preopen discovery is dynamic. At startup the Go runtime calls
fd_prestat_get/fd_prestat_dir_nameto learn which directories were preopened and at which guest paths, building the mapping theospackage resolves against. This is why the guest path is whatever the host advertised, not what your code wishes.
A host that does not implement every imported function fails instantiation with unknown import. This is the contract being enforced.
go:wasmimport Internals and Type Rules¶
go:wasmimport (Go 1.21) declares that a Go function is implemented by a host import. The directive form:
The compiler emits an (import "<module>" "<name>" (func ...)) with a signature derived from the Go function, and the linker leaves the body unresolved — the host supplies it at instantiation.
Allowed types (the hard rule)¶
Only types that map directly to wasm value types or to guest memory may cross the boundary:
int32,uint32→ wasmi32int64,uint64→ wasmi64float32→ wasmf32,float64→ wasmf64unsafe.Pointer→i32(a guest linear-memory offset)uintptr(in pointer-ish positions), and pointer-to-allowed-types in specific contextsboolandstring/slice support has tightened across versions; the portable, always-correct approach is to pass a pointer + length and let the host read memory
You cannot pass a Go string, []byte, map, interface, or struct value directly across a go:wasmimport boundary in a way that survives version changes. The compiler rejects unsupported types with a clear error. Pass primitives and memory offsets.
The marshalling convention¶
Because rich types do not cross, the universal pattern is pointer + length:
//go:wasmimport host emit
func emit(ptr unsafe.Pointer, n uint32) uint32 // returns bytes consumed, say
func send(b []byte) {
if len(b) == 0 {
return
}
emit(unsafe.Pointer(&b[0]), uint32(len(b)))
}
The host receives the offset and length, reads that range out of the guest's linear memory, and acts on it. You define and document the serialisation (raw bytes, JSON, protobuf) layered on top.
go:wasmexport Internals (Go 1.24)¶
go:wasmexport (added in Go 1.24) is the inverse: it exposes a Go function to the host as a wasm export.
//go:wasmexport process
func process(ptr unsafe.Pointer, n uint32) uint32 {
input := unsafe.Slice((*byte)(ptr), n)
// ... compute over input, write result somewhere the host can read ...
return resultLen
}
Key facts and constraints:
- Version floor: Go 1.24. On earlier toolchains the directive is unrecognised. Code using it will not build on 1.21–1.23. This is the most common
go:wasmexportmistake — using it after reading a current blog post on an older toolchain. - Same type rules as
go:wasmimport. Parameters and results must be wasm-native scalars or pointers. Rich data crosses as pointer + length. - It changes the module from a pure command to a reactor. A module with exported functions is meant to be instantiated once and called multiple times by the host, rather than run-once via
_start. The host instantiates, then invokesprocessper request. This is the request/response plugin model. - The Go runtime must be initialised before exports are called. With reactor-style usage the host calls
_initialize(not_start) once to run runtime/package init, then calls the exported functions. Getting this lifecycle wrong (calling an export before init) is a class of subtle bug.
go:wasmexport is what makes a wasip1 module a callable library rather than a one-shot program — the foundation for performant plugin systems where compile-once-instantiate-once-call-many is the pattern.
The Memory Boundary: Pointers, Pinning, and the GC¶
The most dangerous part of host interop is memory, because the type system does not protect you here.
The model¶
- The guest has a single linear memory (a
[]byte-like region). Pointers passed across the boundary are offsets into this memory, not host addresses. - The host reads/writes the guest's linear memory at those offsets. It cannot follow Go pointers into structured objects; it sees raw bytes.
The GC hazard¶
Go's garbage collector can move or free objects. If you pass unsafe.Pointer(&b[0]) to the host and the slice is collected or moved before/while the host reads it, the host reads freed or relocated memory — corruption or a crash.
Mitigations:
- Keep the backing object live across the call. In the simplest case, the slice referenced by
&b[0]is reachable for the duration of the synchronousgo:wasmimportcall, which is enough for a host that reads synchronously and does not retain the pointer. - For asynchronous or retained access, the host must copy the bytes out during the call; the guest must not assume the pointer is valid afterward.
runtime.Pinner(Go 1.21+) can pin an object so the GC will not move it for the pin's duration — useful when a pointer must remain stable across a sequence of host operations.- Never pass a pointer the host will hold past the call unless you have explicitly pinned and lifecycle-managed the memory. Document ownership for every pointer in the ABI.
Practical rule¶
Treat every cross-boundary pointer as valid only for the synchronous duration of the call, unless you have pinned it and documented otherwise. The compiler will not catch a violation; the symptom is intermittent corruption that looks like a runtime bug.
The _start / _initialize Lifecycle¶
There are two host-callable lifecycles, and conflating them causes real bugs.
- Command modules (default
go build). The module exports_start. The host calls_startonce; Go runs runtime init, packageinitfunctions, andmain; the program runs to completion and callsproc_exit. This is the CLI / one-shot model. A command module is consumed by running it. - Reactor modules (
go:wasmexportusage). The module exports_initializeplus your exported functions. The host calls_initializeonce to run runtime and package init, then calls exported functions repeatedly without re-initialising. The module persists across calls. This is the library / plugin model.
The professional consequences:
- A reactor whose exports are called before
_initializeruns against an uninitialised runtime — undefined behaviour. Hosts (and wazero/Wasmtime helpers) must enforce the order. - A command module's
maincallingproc_exittears down the instance; you cannot then call exports. If you need repeated calls, you need the reactor model, hence Go 1.24'sgo:wasmexport. - Re-
_initialize-ing is not supported; for a fresh state you instantiate a new module instance (cheap, given a cached compiled module).
The Exec Wrapper and GOWASIRUNTIME¶
go run and go test cannot execute a .wasm directly; the toolchain delegates to an exec wrapper.
- The wrapper is
go_wasip1_wasm_exec, shipped in the Go distribution under$(go env GOROOT)/lib/wasm/(older layouts:misc/wasm/). It must be onPATH. - It reads
GOWASIRUNTIMEto choose the runtime (wasmtimedefault; alsowazero,wasmedge,wasmeron supported wrapper versions) and invokes it on the built.wasm. - It forwards
os.Argsand may forward runtime arguments; filesystem/env capabilities for tests come from the runtime invocation, so fixture directories must be preopened by the wrapper's runtime args.
export PATH="$PATH:$(go env GOROOT)/lib/wasm"
export GOWASIRUNTIME=wazero
GOOS=wasip1 GOARCH=wasm go test ./...
In CI this is the mechanism that lets you run the full go test suite against the wasip1 build, catching build-tag and ABI mistakes that native testing cannot. Pin the runtime version; behaviour (especially around stdin EOF and path mapping) varies.
Embedding a Go Guest in a Go Host with wazero¶
The professional reference embedding: a Go program that compiles, instantiates, and drives a wasip1 guest, with no CGo and no external runtime binary.
import (
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
type PluginHost struct {
rt wazero.Runtime
compiled wazero.CompiledModule
}
func NewPluginHost(ctx context.Context, wasm []byte) (*PluginHost, error) {
// A compilation cache makes repeated process starts cheap.
cache := wazero.NewCompilationCache()
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().WithCompilationCache(cache))
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
// Provide a custom host function the guest calls via go:wasmimport.
_, err := rt.NewHostModuleBuilder("host").
NewFunctionBuilder().
WithFunc(func(_ context.Context, m api.Module, ptr, n uint32) uint32 {
buf, _ := m.Memory().Read(ptr, n) // read guest memory safely
log.Printf("guest: %s", buf)
return n
}).
Export("emit").
Instantiate(ctx)
if err != nil {
return nil, err
}
compiled, err := rt.CompileModule(ctx, wasm) // expensive: do once
if err != nil {
return nil, err
}
return &PluginHost{rt: rt, compiled: compiled}, nil
}
func (h *PluginHost) Run(ctx context.Context, input []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // bound execution
defer cancel()
var out bytes.Buffer
cfg := wazero.NewModuleConfig().
WithStdin(bytes.NewReader(input)).
WithStdout(&out)
mod, err := h.rt.InstantiateModule(ctx, h.compiled, cfg)
if err != nil {
return nil, err // includes traps: fuel/timeout/unknown-import
}
defer mod.Close(ctx)
return out.Bytes(), nil
}
Professional notes:
Memory().Read/Writeare the safe boundary API on the host side: they validate the offset and length against the guest's memory bounds, turning an out-of-range pointer into an error instead of a host crash.CompileModuleonce,InstantiateModuleper request. Compilation dominates; instantiation is cheap. ACompilationCachepersists compiled artifacts across host process restarts.contextis your kill switch. Cancelling the context interrupts the guest; pair it with memory limits for untrusted code.- wazero is pure Go — no CGo, cross-compiles cleanly, embeds in any Go binary. This is why it dominates the Go-host-embeds-wasm use case.
Build Flags, Size, and Reproducibility¶
A Go wasip1 binary embeds the runtime; size and reproducibility deserve attention.
- Strip symbols:
-ldflags="-s -w"removes the symbol table and DWARF, shrinking the module meaningfully. - Trim paths:
-trimpathremoves absolute build paths, improving reproducibility (the same source produces the same bytes across machines). - Reproducible builds: pin the Go toolchain (the
go/toolchaindirectives plus a fixed build image), pinGOOS/GOARCH, and use-trimpath. The standard library is compiled in, so the toolchain version materially affects output bytes. - TinyGo for small modules: TinyGo emits far smaller
wasip1modules at the cost of reduced stdlib and reflection support — covered in 16.3 TinyGo. Choose TinyGo when size/density dominates and you can live within its subset. - AOT-compile artifacts: for runtimes that support it (
wasmtime compile), precompile the.wasmto a native artifact and ship that, so deployment cold start is instantiation-only.
CI/CD for wasip1 Artifacts¶
Treat the .wasm as a build artifact with its own pipeline.
# Build the artifact
- run: GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags="-s -w" -o app.wasm ./cmd/app
# Test the wasip1 code path through the exec wrapper
- run: |
export PATH="$PATH:$(go env GOROOT)/lib/wasm"
export GOWASIRUNTIME=wazero
GOOS=wasip1 GOARCH=wasm go test ./...
# Validate the module structurally (imports/exports as expected)
- run: wasm-tools validate app.wasm
# Smoke-run on the target runtime(s) you deploy to
- run: wasmtime run --dir=./testdata::/data app.wasm
- Test under
wasip1in CI, not just natively. The native build can pass while thewasip1build pulls innetand fails to instantiate. The exec wrapper catches this. - Validate and inspect the module.
wasm-tools validateconfirms it is well-formed; dumping imports confirms no unexpected host obligations (e.g. a straysock_*import) slipped in. - Pin runtime versions in the smoke-run; behaviour differs across versions and runtimes.
- Test on every runtime you ship to, not just one. "Runs on Wasmtime" does not imply "runs on WasmEdge."
Edge Cases the Source Reveals¶
A close reading of the wasip1 runtime port and the WASI ABI exposes corners most users never hit:
os.Args[0]is runtime-dependent. Some runtimes pass the module filename, others a path; do not parse meaning from it.- Empty
environ/argsare normal. With nothing granted,args_sizes_getreturns the module name only andenviron_getreturns nothing; code must not assume a populated environment. poll_oneoffprecision varies.time.Sleepresolution depends on the runtime'spoll_oneoffimplementation; do not rely on sub-millisecond timing portability.crypto/randrequiresrandom_get. A runtime that stubsrandom_getto a constant breaks cryptographic randomness silently — verify on the runtimes you deploy to.- Preopen ordering matters. The guest path resolution walks preopens; overlapping or shadowing mappings can resolve to a surprising directory. Keep mappings disjoint.
proc_exitwith a nonzero code is howos.Exit(1)surfaces; the host receives it as the module's exit status, not as a trap. Distinguish "the program exited nonzero" from "the module trapped."- Unsupported syscalls return
ENOSYS-style errors, not panics; code that ignores errors may proceed on a failed operation. Check errors. go:wasmimport/go:wasmexportsignatures are checked at compile time for type validity but never checked against the host's actual implementation — a mismatch is a runtime failure or corruption. The ABI is your responsibility.
These are not facts to memorise but pointers to reach for the spec and the runtime source when something unexpected happens.
Operational Playbook¶
| Scenario | Recipe |
|---|---|
Build a wasip1 module | GOOS=wasip1 GOARCH=wasm go build -o app.wasm ./cmd/app |
| Run it standalone | wasmtime run --dir=./data::/data app.wasm |
| Run/test via go | PATH+=:$(go env GOROOT)/lib/wasm; GOWASIRUNTIME=wazero; go run . |
| Inspect imports (host obligations) | wasm-tools print app.wasm \| grep '(import' |
Diagnose unknown import | Dump imports; find the unexpected sock_*/env::*; guard or provide it |
| Shrink the binary | -ldflags="-s -w" -trimpath; consider TinyGo |
| Embed in a Go host | wazero: CompileModule once, InstantiateModule per call, Memory().Read/Write for the boundary |
| Call host funcs from guest | go:wasmimport <mod> <name>; pass pointer+length; keep memory live |
| Expose guest funcs to host (1.24+) | go:wasmexport <name>; reactor model; host calls _initialize first |
| Bound untrusted execution | wazero: context.WithTimeout + memory limit; Wasmtime: fuel + memory limit |
| Verify reproducibility | Pin toolchain + -trimpath; build twice; cmp app.wasm app2.wasm |
| AOT-cache for cold start | wasmtime compile app.wasm -o app.cwasm; ship the compiled artifact |
Summary¶
GOOS=wasip1 GOARCH=wasm is a deceptively simple command: compile Go to a sandboxed module that runs outside the browser. The professional understanding is the contract that compilation creates with the host — the wasi_snapshot_preview1 import set as the complete list of host obligations, the _start vs _initialize lifecycle that distinguishes a command from a reactor, and the memory boundary where the type system stops protecting you.
Host interop is the heart of the professional material. go:wasmimport (Go 1.21) lets the guest call host functions; go:wasmexport (Go 1.24) makes the guest a callable library. Both restrict boundary types to wasm-native scalars and pointers, both force rich data through a pointer+length convention, and both leave memory ownership and GC interaction entirely to you — pin what the host retains, keep slices live across calls, and treat every cross-boundary pointer as valid only for the call's duration unless proven otherwise.
The reference embedding is a Go host driving a Go guest with wazero: compile once, instantiate per request, read/write guest memory through the bounds-checked API, and bound execution with context and memory limits. Mastering the import surface, the lifecycle, and the memory boundary turns wasip1 from a black box into a precise tool — and that precision is what makes a sandboxed plugin system safe rather than merely functional.
In this topic