Tutorial 4: traps, refusals, gates and the environment switches¶
Work in progress
This page is a draft. It describes Burroughs as of its latest release and may change. Where it disagrees with the engine's own documentation or CHANGELOG, those win.
The program is this site's examples/refusals/ (main.go, the .wat sources and their assembled .wasm,
output.txt, output-threads0.txt), and the CLI transcripts are examples/cli/. CI runs all of them against the
latest Burroughs release, with the CLI built at that tag, and fails if any output differs from what is shown here.
[exit N] in a transcript is the exit status when it is not 0.
Burroughs avoids returning one undifferentiated error. "Fix your module", "rebuild with that gate on" and "this engine is incomplete" ask different people to do different things, so each has its own classification. This tutorial builds one module per outcome and shows how each one is reported. Concepts covers the vocabulary itself.
The modules¶
| File | Contents | Expected outcome |
|---|---|---|
malformed.wasm |
the bytes not a wasm module |
malformed |
invalid.wat |
(module (func (export "f") (result i32) i64.const 42)) |
invalid: type mismatch |
gc.wat |
(module (type $s (struct (field i32)))) |
gated: GC is off |
eh.wat |
a function using try_table |
gated: exception handling is off |
imports.wat |
imports env.log |
unlinkable: the core path supplies no imports |
shared.wat |
a shared memory, i32.atomic.rmw.add, memory.atomic.wait32 |
runs: the threads grammar is on |
shared.wat:
(module
(memory (export "mem") 1 1 shared)
(func (export "wait") (result i32)
(memory.atomic.wait32 (i32.const 0) (i32.atomic.load (i32.const 0)) (i64.const 1000000)))
(func (export "inc") (result i32)
(i32.atomic.rmw.add (i32.const 0) (i32.const 1))))
Assemble the .wat files with wasm-tools parse X.wat -o X.wasm (the site commits the assembled .wasm beside
each source).
The program¶
// Command refusals loads one module per outcome and prints which public
// classification each one lands in.
package main
import (
"errors"
"fmt"
"log"
"os"
"github.com/scttfrdmn/burroughs"
)
// classify names the sentinel an error matches. The sentinels are disjoint
// classifications, so the order of the cases does not matter for these.
func classify(err error) string {
var trap *burroughs.Trap
switch {
case err == nil:
return "ok"
case errors.Is(err, burroughs.ErrMalformed):
return "ErrMalformed"
case errors.Is(err, burroughs.ErrInvalid):
return "ErrInvalid"
case errors.Is(err, burroughs.ErrDeclined):
return "ErrDeclined"
case errors.Is(err, burroughs.ErrGated):
return "ErrGated"
case errors.Is(err, burroughs.ErrUnlinkable):
return "ErrUnlinkable"
case errors.Is(err, burroughs.ErrUnsupported):
return "ErrUnsupported"
case errors.As(err, &trap):
return "*Trap"
}
return "other"
}
func main() {
// gc and eh are the two proposals still gated off by default. v0.12.0 turned memory64 and tail calls on, and
// the modules that stood here are admitted now.
for _, name := range []string{"malformed", "invalid", "gc", "eh", "imports", "shared"} {
wasm, err := os.ReadFile(name + ".wasm")
if err != nil {
log.Fatal(err)
}
in, err := burroughs.Config{Strict: true}.Instantiate(wasm)
fmt.Printf("%-9s -> %s\n", name, classify(err))
if err != nil {
fmt.Printf(" %v\n", err)
continue
}
if name == "shared" {
// Admitted by default (gate:threads is on). Core Instantiate supplies no
// imports, so nothing on this path starts a second agent over this memory,
// and a wait can only time out. (A wasip1 command can start one, through
// the "burroughs" "spawn" door, since v0.12.0.)
n, err := in.Call("inc")
fmt.Println(" inc ->", n, err)
r, err := in.Call("wait")
fmt.Println(" wait(timeout 1ms) ->", r, err, "(2 means timed-out)")
}
}
}
Output, default build¶
Run with go run . in examples/refusals/:
malformed -> ErrMalformed
burroughs: malformed module: magic header not detected
invalid -> ErrInvalid
burroughs: invalid module: type mismatch: instruction requires [i32] but stack has [i64] (instr 1: end) (func 0)
gc -> ErrGated
burroughs: proposal gate is off in this build: gc: feature gate disabled
eh -> ErrGated
burroughs: proposal gate is off in this build: try_table: feature gate disabled
imports -> ErrUnlinkable
burroughs: module has unsupplied imports: unknown import: "env" "log" (interp: link failed)
shared -> ok
inc -> [i32:0] <nil>
wait(timeout 1ms) -> [i32:2] <nil> (2 means timed-out)
Notes on each line:
- Malformed and invalid carry the spec's own text, so you can match on
type mismatchwithout Burroughs restating the spec's rules. - Gated names the proposal. The module is well-formed, and a build with that gate on would run it. The public
API cannot turn GC or exception handling on. Both are off by default and no
Featurereaches them, so through the public API this result is final inv0.12.0. (Untilv0.12.0this tutorial used a memory64 module and a tail-call module here. Both proposals are on by default now, so those modules are admitted, and the two that stay off took their place.) - Unlinkable: the core
Instantiatepath has no way to supply imports, so the module is refused when it loads, before anything can trap on a missing function. If your module needs host functions, the only supported host imports are WASI preview 1 (WASIP1Config.Run) and the WASI 0.2 subset for components. Strict: truechanged nothing here. None of these modules declines. No module in the default feature set is currently known to produce a decline.Strictonly matters once the validator meets a construct it has no rule for yet.- Shared memory runs, because
gate:threadsis on by default. On this core path there is only ever one agent, though:Instantiatesupplies no imports, so nothing here starts a second thread, andmemory.atomic.wait32can only return 2 ("timed-out"), or 1 ("not-equal") if the value already differs. (Awasip1command can start its own threads, through the"burroughs" "spawn"import, sincev0.12.0.)
Traps¶
A trap is not a refusal: the module was accepted and ran, and the program then went wrong. It comes back from
Call (or Run) as a *burroughs.Trap, whose Reason is the spec's text:
$ burroughs run embed-core/calc.wasm divmod i32:1 i32:0
burroughs: trap: integer divide by zero
[exit 4]
A core instance is still callable after a trap. A component instance is locked by one: every later
Component.Call returns ErrInstanceLocked wrapping the trap (tutorial 3).
The same modules through the CLI¶
$ burroughs run refusals/malformed.wasm
burroughs: malformed module: magic header not detected
[exit 3]
$ burroughs run refusals/invalid.wasm
burroughs: invalid module: type mismatch: instruction requires [i32] but stack has [i64] (instr 1: end) (func 0)
[exit 3]
$ burroughs run refusals/gc.wasm
burroughs: proposal gate is off in this build: gc: feature gate disabled
[exit 6]
$ burroughs run refusals/imports.wasm
burroughs: module has unsupplied imports: unknown import: "env" "log" (interp: link failed)
[exit 3]
$ burroughs run refusals/shared.wasm wait
i32:2
The environment switches¶
Each default-on gate has a rollback: set its variable to 0. Any other value, or leaving it unset, keeps the
default. The variables are read when they are used, so they work for the CLI and for your own program alike.
| Variable | Gate | Default since |
|---|---|---|
BURROUGHS_THREADS |
gate:threads: shared memories, the 0xFE atomics, wait/notify |
v0.10.0 (ADR 0106) |
BURROUGHS_COMPONENTS |
gate:components: the component model |
2026-09-11 |
BURROUGHS_ASYNC |
gate:async: the async component ABI |
2026-09-18 |
BURROUGHS_CM_THREADS |
gate:cm-threads: component-model threads, phase 1 |
2026-10-17 (v0.8.0) |
BURROUGHS_EXTENDED_CONST |
gate:extended-const: arithmetic in constant expressions |
v0.12.0 (ADR 0107) |
BURROUGHS_TAIL_CALL |
gate:tail-call: return_call, return_call_indirect |
v0.12.0 (ADR 0108) |
BURROUGHS_MULTI_MEMORY |
gate:multi-memory: more than one memory, an explicit memory index |
v0.12.0 (ADR 0109) |
BURROUGHS_MEMORY64 |
gate:memory64: an i64 index type on a memory or table |
v0.12.0 (ADR 0110) |
SIMD and relaxed SIMD are also on by default, but have no environment rollback.
BURROUGHS_THREADS=0¶
The same program with the gate rolled back (BURROUGHS_THREADS=0 go run .) prints the same lines, except for the
last module:
malformed -> ErrMalformed
burroughs: malformed module: magic header not detected
invalid -> ErrInvalid
burroughs: invalid module: type mismatch: instruction requires [i32] but stack has [i64] (instr 1: end) (func 0)
gc -> ErrGated
burroughs: proposal gate is off in this build: gc: feature gate disabled
eh -> ErrGated
burroughs: proposal gate is off in this build: try_table: feature gate disabled
imports -> ErrUnlinkable
burroughs: module has unsupplied imports: unknown import: "env" "log" (interp: link failed)
shared -> ErrGated
burroughs: proposal gate is off in this build: threads: feature gate disabled
With the gate rolled back, a wasip1 guest that needs atomics can still ask for them with FeatureThreads (CLI:
--features=threads). sharedcmd.wasm (source in examples/refusals/sharedcmd.wat) is a tiny command with a shared memory that calls proc_exit:
$ burroughs run sharedcmd.wasm
$ BURROUGHS_THREADS=0 burroughs run sharedcmd.wasm
burroughs: proposal gate is off in this build: threads: feature gate disabled
[exit 6]
$ BURROUGHS_THREADS=0 burroughs run --features=threads sharedcmd.wasm
$ BURROUGHS_THREADS=0 burroughs run --features=threads shared.wasm wait
burroughs: --features applies to a wasip1 command or a component; a core module takes the default proposal set
[exit 2]
(sharedcmd.wasm exits with the value the atomic add returns, the old contents of address 0, which is 0.)
Config, the core-module path, has no Features. With BURROUGHS_THREADS=0, a core module that uses threads
cannot be admitted through Instantiate at all. The last command above shows the CLI's core path refusing
--features by name (exit 2), because a core module takes the default proposal set. (Since v0.12.0,
#1128: before it the flag was silently ignored, and the module
was refused as gated, exit 6.)
BURROUGHS_COMPONENTS=0 and BURROUGHS_ASYNC=0¶
$ BURROUGHS_COMPONENTS=0 burroughs run testdata/p3hello.wasm
burroughs: proposal gate is off in this build: gate:components is off in this build (BURROUGHS_COMPONENTS=0); unset it to run a component (the gate is on by default as of the 2026-09-11 flip)
[exit 6]
$ BURROUGHS_ASYNC=0 burroughs run testdata/p3async-hello.wasm
burroughs: proposal gate is off in this build: gate:async is off in this build: this component uses the async component-model ABI (an async canon lower); unset BURROUGHS_ASYNC to run it (the gate is on by default as of the 2026-09-18 flip)
[exit 6]
$ burroughs run testdata/p3async-hello.wasm
Hello, world!
Both exit 6, and errors.Is(err, burroughs.ErrGated) holds for them in Go. (p3hello.wasm and
p3async-hello.wasm are in the engine repository's internal/component/testdata/, fetched at the tag. Both print
Hello, world! with the gates at their defaults: the last command above, and p3hello in the
CLI reference.)
An unknown feature name¶
$ burroughs run --features=gc --dir ./data:/data guest.wasm
burroughs: feature not implemented in this phase: unknown feature "gc"; this build recognizes "threads"
[exit 5]
That is ErrUnsupported, exit 5. An unrecognized name is refused rather than ignored.
A branching template¶
describe is this site's examples/describe/, which CI runs; the function as it appears there:
func describe(err error) string {
var trap *burroughs.Trap
switch {
case err == nil:
return "ok"
case errors.Is(err, burroughs.ErrInstanceLocked): // component: an earlier trap locked it (also wraps a *Trap)
return "locked"
case errors.As(err, &trap): // ran, then trapped: trap.Reason
return "trap: " + trap.Reason
case errors.Is(err, burroughs.ErrGated): // fine module, gate off
return "gated"
case errors.Is(err, burroughs.ErrMalformed),
errors.Is(err, burroughs.ErrInvalid),
errors.Is(err, burroughs.ErrDeclined),
errors.Is(err, burroughs.ErrUnlinkable): // the module was refused
return "refused"
case errors.Is(err, burroughs.ErrUnsupported): // engine gap, unknown Feature, or (until v0.12.0, #1129) a component type mismatch at Call
return "unsupported"
case errors.Is(err, burroughs.ErrCancelled): // you cancelled ctx, and the task ended
return "cancelled"
case errors.Is(err, burroughs.ErrComponentClosed):
return "closed"
case errors.Is(err, burroughs.ErrCloseIncomplete):
return "close incomplete"
default: // a caller mistake: wrong arity, no such export, ...
return "other: " + err.Error()
}
}
Its main runs describe over one error of each kind it can produce without fixtures:
instantiate boom: ok
call boom: trap: unreachable
call nope: other: interp: no exported function "nope"
malformed bytes: refused
GC module: gated
Feature "gc": unsupported
Order matters in one place: test ErrInstanceLocked before the *Trap case. A locked-instance error wraps the
trap that caused the lock, so it also satisfies errors.As(err, &trap). If the trap case came first, the locked case
could never be reached.