Skip to content

Tutorial 2: run a Go program built for WASI preview 1

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 code is this site's examples/wasip1/ (guest/, host/, data/, output.txt). CI builds the guest with the stock Go toolchain (the site pins go1.27.3), builds the burroughs CLI at the latest release tag, runs both the CLI sessions and the Go host, and fails if any output differs from what is shown here.

The stock Go toolchain can target GOOS=wasip1 GOARCH=wasm. (wasip2/wasip3 are not supported Go targets.) A wasip1 binary is a command: it imports wasi_snapshot_preview1 and exports _start. Burroughs runs one through WASIP1Config.Run, or burroughs run on the command line.

1. The guest

guest/main.go is an ordinary Go program:

examples/wasip1/guest/main.go
// Command guest is an ordinary Go program, built for GOOS=wasip1 GOARCH=wasm.
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func main() {
    fmt.Println("args:", os.Args)
    fmt.Println("GREETING env:", os.Getenv("GREETING"))
    fmt.Println("env entries:", len(os.Environ()))

    b, err := os.ReadFile("/data/greeting.txt")
    if err != nil {
        fmt.Fprintln(os.Stderr, "read:", err)
        os.Exit(3)
    }
    fmt.Printf("/data/greeting.txt: %q\n", strings.TrimSpace(string(b)))

    // Writing under a read-only grant is refused.
    if err := os.WriteFile("/data/new.txt", []byte("x"), 0o644); err != nil {
        fmt.Println("write under /data:", err)
    }

    // Writing under a writable (scratch) grant, if one was given.
    if err := os.WriteFile("/out/result.txt", []byte("written by the guest\n"), 0o644); err != nil {
        fmt.Println("write under /out:", err)
    } else {
        fmt.Println("wrote /out/result.txt")
    }

    // Read stdin lines, if any.
    sc := bufio.NewScanner(os.Stdin)
    n := 0
    for sc.Scan() {
        n++
    }
    fmt.Println("stdin lines:", n)

    if len(os.Args) > 1 && os.Args[1] == "fail" {
        os.Exit(7)
    }
}

Build it. The file it reads, data/greeting.txt, holds the line hello from the host:

$ cd guest
$ GOOS=wasip1 GOARCH=wasm go build -o ../guest.wasm .

(CI runs exactly this build; the binary is about 2.6 MB, so the site does not commit it.)

2. Run it from the CLI

From examples/wasip1/, where in.txt holds three lines (a, b, c). [exit N] is the exit status when it is not 0:

examples/cli/transcripts/t2-run.txt
$ mkdir -p out
$ burroughs run --dir ./data:/data --scratch ./out:/out --env GREETING=hi guest.wasm -- first second < in.txt
args: [guest.wasm first second]
GREETING env: hi
env entries: 1
/data/greeting.txt: "hello from the host"
write under /data: open /data/new.txt: Capabilities insufficient
wrote /out/result.txt
stdin lines: 3
$ cat out/result.txt
written by the guest

The guest's exit code is the CLI's:

examples/cli/transcripts/t2-fail.txt
$ burroughs run --dir ./data:/data guest.wasm -- fail < /dev/null
args: [guest.wasm fail]
GREETING env: 
env entries: 0
/data/greeting.txt: "hello from the host"
write under /data: open /data/new.txt: Capabilities insufficient
write under /out: open /out/result.txt: Bad file number
stdin lines: 0
[exit 7]

With no grants the guest sees no filesystem at all:

examples/cli/transcripts/t2-nogrants.txt
$ burroughs run guest.wasm < /dev/null
args: [guest.wasm]
GREETING env: 
env entries: 0
read: open /data/greeting.txt: Bad file number
[exit 3]

That 3 is the guest's own os.Exit(3). It is not the CLI's "refused" code, even though the number is the same. The difference is that there is no burroughs: line on stderr.

3. Run it from Go

host/main.go:

examples/wasip1/host/main.go
// Command host runs ../guest.wasm, a Go program built for wasip1, through
// burroughs.WASIP1Config, with captured stdout, an environment, a read-only
// directory and a writable scratch directory.
package main

import (
    "bytes"
    "fmt"
    "log"
    "os"
    "path/filepath"
    "strings"

    "github.com/scttfrdmn/burroughs"
)

func main() {
    wasm, err := os.ReadFile("../guest.wasm")
    if err != nil {
        log.Fatal(err)
    }

    isCmd, err := burroughs.IsWASIP1Command(wasm)
    fmt.Println("IsWASIP1Command:", isCmd, err)

    scratch, err := os.MkdirTemp("", "wasip1-scratch-")
    if err != nil {
        log.Fatal(err)
    }
    defer os.RemoveAll(scratch)
    data, err := filepath.Abs("../data")
    if err != nil {
        log.Fatal(err)
    }

    var stdout, stderr bytes.Buffer
    cfg := burroughs.WASIP1Config{
        Args:   []string{"guest", "fail"},
        Env:    []string{"GREETING=hi"},
        Stdin:  strings.NewReader("one\ntwo\n"),
        Stdout: &stdout,
        Stderr: &stderr,
        Preopens: []burroughs.Preopen{
            {Host: data, Guest: "/data"},                  // read-only (the zero value)
            {Host: scratch, Guest: "/out", Writable: true}, // a scratch grant
        },
    }
    code, err := cfg.Run(wasm)
    fmt.Printf("exit code %d, err %v\n", code, err)
    fmt.Print("--- guest stdout\n", stdout.String())
    fmt.Print("--- guest stderr\n", stderr.String())

    written, err := os.ReadFile(filepath.Join(scratch, "result.txt"))
    fmt.Printf("--- host sees result.txt: %q %v\n", written, err)

    // No Preopens: the guest reaches no filesystem at all.
    stdout.Reset()
    stderr.Reset()
    code, err = burroughs.WASIP1Config{
        Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr,
    }.Run(wasm)
    fmt.Printf("--- no grants: exit code %d, err %v, stderr %q\n", code, err, stderr.String())
}

Run it with go run . in examples/wasip1/host/. It prints:

examples/wasip1/output.txt
IsWASIP1Command: true <nil>
exit code 7, err <nil>
--- guest stdout
args: [guest fail]
GREETING env: hi
env entries: 1
/data/greeting.txt: "hello from the host"
write under /data: open /data/new.txt: Capabilities insufficient
wrote /out/result.txt
stdin lines: 2
--- guest stderr
--- host sees result.txt: "written by the guest\n" <nil>
--- no grants: exit code 3, err <nil>, stderr "read: open /data/greeting.txt: Bad file number\n"

What to take from it

  • proc_exit(n) is (n, nil). An error from Run means the module failed to load, link or run (a trap), and then the code is 0.
  • Capabilities are explicit. No Preopens means no filesystem. A grant is read-only unless Writable is set. A write through a read-only grant fails in the guest with ENOTCAPABLE ("Capabilities insufficient"). Paths cannot escape a grant's resolved root.
  • Nothing is inherited from the host's environment. The guest saw exactly the one variable it was given.
  • Stream defaults: a nil Stdin/Stdout/Stderr in WASIP1Config means the process's streams. The host above sets all three so it can capture them. Components default the other way: see tutorial 3.
  • Threads: a guest from the stock Go toolchain runs the same with BURROUGHS_THREADS=0, so it does not depend on the threads gate:

    examples/cli/transcripts/t2-threads0.txt
    $ BURROUGHS_THREADS=0 burroughs run --dir ./data:/data guest.wasm -- fail < /dev/null
    args: [guest.wasm fail]
    GREETING env: 
    env entries: 0
    /data/greeting.txt: "hello from the host"
    write under /data: open /data/new.txt: Capabilities insufficient
    write under /out: open /out/result.txt: Bad file number
    stdin lines: 0
    [exit 7]
    

    The guests from the project's Go fork (the "Phase 4 fork") do carry atomics. Since v0.12.0 their "burroughs" "spawn" import is served by WASIP1Config.Run and burroughs run, so a fork-built program starts real threads through this same path. One fork-side limit applies: a guest that calls runtime.LockOSThread and then needs another M crashes the fork's runtime. See Known limits. - Only a subset of preview 1 is implemented for writes. Twenty functions still answer ENOSYS (ADR 0091's amendment counts 21; the host refuses 20). If your guest needs one, it gets that error back. It does not crash.