Skip to content

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:

examples/refusals/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

examples/refusals/main.go
// 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/:

examples/refusals/output.txt
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 mismatch without 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 Feature reaches them, so through the public API this result is final in v0.12.0. (Until v0.12.0 this 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 Instantiate path 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: true changed nothing here. None of these modules declines. No module in the default feature set is currently known to produce a decline. Strict only matters once the validator meets a construct it has no rule for yet.
  • Shared memory runs, because gate:threads is on by default. On this core path there is only ever one agent, though: Instantiate supplies no imports, so nothing here starts a second thread, and memory.atomic.wait32 can only return 2 ("timed-out"), or 1 ("not-equal") if the value already differs. (A wasip1 command can start its own threads, through the "burroughs" "spawn" import, since v0.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:

examples/cli/transcripts/t4-trap.txt
$ 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

examples/cli/transcripts/t4-cli.txt
$ 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:

examples/refusals/output-threads0.txt
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:

examples/cli/transcripts/t4-threads.txt
$ 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

examples/cli/transcripts/t4-gates.txt
$ 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

examples/cli/transcripts/t4-features.txt
$ 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:

examples/describe/main.go
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:

examples/describe/output.txt
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.