Initial wazero CLI to run a standalone Wasm binary (#813)

* Initial wazero CLI to run a standalone Wasm binary

Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
This commit is contained in:
Anuraag Agrawal
2022-09-28 20:59:38 +09:00
committed by GitHub
parent 1561c4ca7b
commit 84488768e4
16 changed files with 461 additions and 3 deletions

View File

@@ -146,7 +146,9 @@ jobs:
- name: Build test binaries
# Exclude benchmarks as we don't run those in Docker
run: go list -f '{{.Dir}}' ./... | egrep -v '(bench|vs|spectest)' | xargs -Ipkg go test pkg -c -o pkg.test
run: |
go list -f '{{.Dir}}' ./... | egrep -v '(bench|vs|spectest)' | xargs -Ipkg go test pkg -c -o pkg.test
go build -o wazerocli ./cmd/wazero
env:
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 0
@@ -166,7 +168,7 @@ jobs:
- name: Run built test binaries
# This runs all tests compiled above in sequence. Note: This mounts /tmp to allow t.TempDir() in tests.
run: find . -name "*.test" | xargs -Itestbin docker run --platform linux/${{ matrix.arch }} -v $(pwd)/testbin:/test --tmpfs /tmp --rm -t wazero:test
run: find . -name "*.test" | xargs -Itestbin docker run --platform linux/${{ matrix.arch }} -v $(pwd)/testbin:/test -v $(pwd)/wazerocli:/wazero -e WAZEROCLI=/wazero --tmpfs /tmp --rm -t wazero:test
bench:
name: Benchmark

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
/wazero
# Test binary, built with `go test -c`
*.test

View File

@@ -56,7 +56,7 @@ build.examples.as:
build.examples.zig:
@cd examples/allocation/zig/testdata/ && zig build -fstage1 -Drelease-small=true && mv zig-out/lib/greet.wasm .
tinygo_sources := examples/basic/testdata/add.go examples/allocation/tinygo/testdata/greet.go imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.go
tinygo_sources := examples/basic/testdata/add.go examples/allocation/tinygo/testdata/greet.go examples/cli/testdata/cli.go imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.go
.PHONY: build.examples.tinygo
build.examples.tinygo: $(tinygo_sources)
@for f in $^; do \

View File

@@ -52,6 +52,10 @@ share internals between compilers.
End-user packages include `wazero`, with `Config` structs, `api`, with shared types, and the built-in `wasi` library.
Everything else is internal.
We put the main program for wazero into a directory of the same name to match conventions used in `go install`,
notably the name of the folder becomes the binary name. We chose to use `cmd/wazero` as it is common practice
and less surprising than `wazero/wazero`.
### Internal packages
Most code in wazero is internal, and it is acknowledged that this prevents external implementation of facets such as
compilers or decoding. It also prevents splitting this code into separate repositories, resulting in a larger monorepo.

21
cmd/wazero/README.md Normal file
View File

@@ -0,0 +1,21 @@
## wazero CLI
The wazero CLI can be used to execute a standalone WebAssembly binary.
### Installation
```bash
$ go install github.com/tetratelabs/wazero/cmd/wazero@latest
```
### Usage
The wazero CLI accepts a single argument, the path to a WebAssembly binary.
Arguments can be passed to the WebAssembly binary itself after the path.
```bash
wazero run calc.wasm 1 + 2
```
In addition to arguments, the WebAssembly binary has access to stdout, stderr,
and stdin.

BIN
cmd/wazero/testdata/wasi_arg.wasm vendored Normal file

Binary file not shown.

62
cmd/wazero/testdata/wasi_arg.wat vendored Normal file
View File

@@ -0,0 +1,62 @@
;; Copied from imports/wasi_snapshot_preview1/testdata/wasi_arg.wat
;; $wasi_arg is a WASI command which copies null-terminated args to stdout.
(module $wasi_arg
;; args_get reads command-line argument data.
;;
;; See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-args_getargv-pointerpointeru8-argv_buf-pointeru8---errno
(import "wasi_snapshot_preview1" "args_get"
(func $wasi.args_get (param $argv i32) (param $argv_buf i32) (result (;errno;) i32)))
;; args_sizes_get returns command-line argument data sizes.
;;
;; See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-args_sizes_get---errno-size-size
(import "wasi_snapshot_preview1" "args_sizes_get"
(func $wasi.args_sizes_get (param $result.argc i32) (param $result.argv_buf_size i32) (result (;errno;) i32)))
;; fd_write write bytes to a file descriptor.
;;
;; See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_write
(import "wasi_snapshot_preview1" "fd_write"
(func $wasi.fd_write (param $fd i32) (param $iovs i32) (param $iovs_len i32) (param $result.size i32) (result (;errno;) i32)))
;; WASI commands are required to export "memory". Particularly, imported functions mutate this.
;;
;; Note: 1 is the size in pages (64KB), not bytes!
;; See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#memories%E2%91%A7
(memory (export "memory") 1)
;; $iovs are offset/length pairs in memory fd_write copies to the file descriptor.
;; $main will only write one offset/length pair, corresponding to null-terminated args.
(global $iovs i32 i32.const 1024) ;; 1024 is an arbitrary offset larger than the args.
;; WASI parameters are usually memory offsets, you can ignore values by writing them to an unread offset.
(global $ignored i32 i32.const 32768)
;; _start is a special function defined by a WASI Command that runs like a main function would.
;;
;; See https://github.com/WebAssembly/WASI/blob/snapshot-01/design/application-abi.md#current-unstable-abi
(func $main (export "_start")
;; To copy an argument to a file, we first need to load it into memory.
(call $wasi.args_get
(global.get $ignored) ;; ignore $argv as we only read the argv_buf
(i32.const 0) ;; Write $argv_buf (null-terminated args) to memory offset zero.
)
drop ;; ignore the errno returned
;; Next, we need to know how many bytes were loaded, as that's how much we'll copy to the file.
(call $wasi.args_sizes_get
(global.get $ignored) ;; ignore $result.argc as we only read the argv_buf.
(i32.add (global.get $iovs) (i32.const 4)) ;; store $result.argv_buf_size as the length to copy
)
drop ;; ignore the errno returned
;; Finally, write the memory region to the file.
(call $wasi.fd_write
(i32.const 1) ;; $fd is a file descriptor and 1 is stdout (console).
(global.get $iovs) ;; $iovs is the start offset of the IO vectors to copy.
(i32.const 1) ;; $iovs_len is the count of offset/length pairs to copy to memory.
(global.get $ignored) ;; ignore $result.size as we aren't verifying it.
)
drop ;; ignore the errno returned
)
)

134
cmd/wazero/wazero.go Normal file
View File

@@ -0,0 +1,134 @@
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/tetratelabs/wazero/sys"
)
func main() {
doMain(os.Stdout, os.Stderr, os.Exit)
}
// doMain is separated out for the purpose of unit testing.
func doMain(stdOut io.Writer, stdErr io.Writer, exit func(code int)) {
flag.CommandLine.SetOutput(stdErr)
var help bool
flag.BoolVar(&help, "h", false, "print usage")
flag.Parse()
if help || flag.NArg() == 0 {
printUsage(stdErr)
exit(0)
}
if flag.NArg() < 1 {
fmt.Fprintln(stdErr, "missing path to wasm file")
printUsage(stdErr)
exit(1)
}
subCmd := flag.Arg(0)
switch subCmd {
case "run":
doRun(flag.Args()[1:], stdOut, stdErr, exit)
default:
fmt.Fprintln(stdErr, "invalid command")
printUsage(stdErr)
exit(1)
}
}
func doRun(args []string, stdOut io.Writer, stdErr io.Writer, exit func(code int)) {
flags := flag.NewFlagSet("run", flag.ExitOnError)
flags.SetOutput(stdErr)
_ = flags.Parse(args)
if flags.NArg() < 1 {
fmt.Fprintln(stdErr, "missing path to wasm file")
printRunUsage(stdErr, flags)
exit(1)
}
wasmPath := flags.Arg(0)
wasmArgs := flags.Args()[1:]
if len(wasmArgs) > 1 {
// Skip "--" if provided
if wasmArgs[0] == "--" {
wasmArgs = wasmArgs[1:]
}
}
wasm, err := os.ReadFile(wasmPath)
if err != nil {
fmt.Fprintf(stdErr, "error reading wasm binary: %v\n", err)
exit(1)
}
wasmExe := filepath.Base(wasmPath)
ctx := context.Background()
rt := wazero.NewRuntime(ctx)
defer rt.Close(ctx)
// Because we are running a binary directly rather than embedding in an application,
// we default to wiring up commonly used OS functionality.
conf := wazero.NewModuleConfig().
WithStdout(stdOut).
WithStderr(stdErr).
WithStdin(os.Stdin).
WithRandSource(rand.Reader).
WithSysNanosleep().
WithSysNanotime().
WithSysWalltime().
WithArgs(append([]string{wasmExe}, wasmArgs...)...)
code, err := rt.CompileModule(ctx, wasm, wazero.NewCompileConfig())
if err != nil {
fmt.Fprintf(stdErr, "error compiling wasm binary: %v\n", err)
exit(1)
}
// WASI is needed to access args and very commonly required by self-contained wasm
// binaries, so we instantiate it by default.
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
_, err = rt.InstantiateModule(ctx, code, conf)
if err != nil {
if exitErr, ok := err.(*sys.ExitError); ok {
exit(int(exitErr.ExitCode()))
}
fmt.Fprintf(stdErr, "error instantiating wasm binary: %v\n", err)
exit(1)
}
// We're done, _start was called as part of instantiating the module.
exit(0)
}
func printUsage(stdErr io.Writer) {
fmt.Fprintln(stdErr, "wazero CLI")
fmt.Fprintln(stdErr)
fmt.Fprintln(stdErr, "Usage:\n wazero <command>")
fmt.Fprintln(stdErr)
fmt.Fprintln(stdErr, "Commands:")
fmt.Fprintln(stdErr, " run\t\tRuns a WebAssembly binary")
}
func printRunUsage(stdErr io.Writer, flags *flag.FlagSet) {
fmt.Fprintln(stdErr, "wazero CLI")
fmt.Fprintln(stdErr)
fmt.Fprintln(stdErr, "Usage:\n wazero run <path to wasm file> [--] <wasm args>")
fmt.Fprintln(stdErr)
}

123
cmd/wazero/wazero_test.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"bytes"
"embed"
"flag"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/tetratelabs/wazero/internal/testing/require"
)
//go:embed testdata
var testdata embed.FS
func TestRun(t *testing.T) {
tests := []struct {
wasmPath string
wasmArgs []string
stdOut string
stdErr string
}{
{
wasmPath: "testdata/wasi_arg.wasm",
wasmArgs: []string{"hello world"},
// Executable name is first arg so is printed.
stdOut: "test.wasm\x00hello world\x00",
},
{
wasmPath: "testdata/wasi_arg.wasm",
wasmArgs: []string{"--", "hello world"},
// Executable name is first arg so is printed.
stdOut: "test.wasm\x00hello world\x00",
},
}
for _, tc := range tests {
tt := tc
t.Run(tt.wasmPath, func(t *testing.T) {
wasmBytes, err := fs.ReadFile(testdata, tt.wasmPath)
require.NoError(t, err)
wasmPath := filepath.Join(t.TempDir(), "test.wasm")
require.NoError(t, os.WriteFile(wasmPath, wasmBytes, 0755))
exitCode, stdOut, stdErr := runMain(t, append([]string{"run", wasmPath}, tt.wasmArgs...))
require.Equal(t, 0, exitCode)
require.Equal(t, tt.stdOut, stdOut)
require.Equal(t, tt.stdErr, stdErr)
})
}
}
func TestHelp(t *testing.T) {
exitCode, _, stdErr := runMain(t, []string{"-h"})
require.Equal(t, 0, exitCode)
require.Contains(t, stdErr, "wazero CLI\n\nUsage:")
}
func TestErrors(t *testing.T) {
notWasmPath := filepath.Join(t.TempDir(), "bears.wasm")
require.NoError(t, os.WriteFile(notWasmPath, []byte("pooh"), 0755))
tests := []struct {
message string
args []string
}{
{
message: "missing path to wasm file",
args: []string{},
},
{
message: "error reading wasm binary",
args: []string{"non-existent.wasm"},
},
{
message: "error compiling wasm binary",
args: []string{notWasmPath},
},
}
for _, tc := range tests {
tt := tc
t.Run(tt.message, func(t *testing.T) {
exitCode, _, stdErr := runMain(t, append([]string{"run"}, tt.args...))
require.Equal(t, 1, exitCode)
require.Contains(t, stdErr, tt.message)
})
}
}
func runMain(t *testing.T, args []string) (int, string, string) {
t.Helper()
oldArgs := os.Args
t.Cleanup(func() {
os.Args = oldArgs
})
os.Args = append([]string{"wazero"}, args...)
var exitCode int
stdOut := &bytes.Buffer{}
stdErr := &bytes.Buffer{}
var exited bool
func() {
defer func() {
if r := recover(); r != nil {
exited = true
}
}()
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
doMain(stdOut, stdErr, func(code int) {
exitCode = code
panic(code)
})
}()
require.True(t, exited)
return exitCode, stdOut.String(), stdErr.String()
}

Binary file not shown.

0
examples/basic/testdata/add.wasm vendored Normal file → Executable file
View File

12
examples/cli/README.md Normal file
View File

@@ -0,0 +1,12 @@
## CLI example
This example shows a simple CLI application compiled to WebAssembly and
executed with the wazero CLI.
```bash
$ go run github.com/tetratelabs/wazero/cmd/wazero run testdata/cli.wasm 3 4
```
The wazero CLI can run stand-alone Wasm binaries, providing access to any
arguments passed after the path. The Wasm binary reads arguments and otherwise
operates on the host via WASI functions.

59
examples/cli/cli_test.go Normal file
View File

@@ -0,0 +1,59 @@
package cli
import (
"bytes"
_ "embed"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/tetratelabs/wazero/internal/testing/require"
)
//go:embed testdata/cli.wasm
var cliWasm []byte
func TestRun(t *testing.T) {
tests := []struct {
args []string
stdOut string
}{
{
args: []string{"3", "1"},
stdOut: "result: 4",
},
{
args: []string{"-sub=true", "3", "1"},
stdOut: "result: 2",
},
}
wasmPath := filepath.Join(t.TempDir(), "cli.wasm")
require.NoError(t, os.WriteFile(wasmPath, cliWasm, 0755))
// We can't invoke go run in our docker based cross-architecture tests. We do want to use
// otherwise so running unit tests normally does not require special build steps.
var cmdExe string
var cmdArgs []string
if cmdPath := os.Getenv("WAZEROCLI"); cmdPath != "" {
cmdExe = cmdPath
} else {
cmdExe = filepath.Join(runtime.GOROOT(), "bin", "go")
cmdArgs = []string{"run", "../../cmd/wazero"}
}
cmdArgs = append(cmdArgs, "run", wasmPath)
for _, tc := range tests {
tt := tc
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
stdOut := &bytes.Buffer{}
cmd := exec.Command(cmdExe, append(cmdArgs, tt.args...)...)
cmd.Stdout = stdOut
require.NoError(t, cmd.Run())
require.Equal(t, tt.stdOut, strings.TrimSpace(stdOut.String()))
})
}
}

40
examples/cli/testdata/cli.go vendored Normal file
View File

@@ -0,0 +1,40 @@
package main
import (
"flag"
"os"
"strconv"
)
func main() {
var sub bool
flag.BoolVar(&sub, "sub", false, "whether to subtract arguments instead of add")
flag.Parse()
if flag.NArg() < 2 {
os.Stdout.WriteString("bad arguments\n")
os.Exit(1)
}
a, err := strconv.Atoi(flag.Arg(0))
if err != nil {
os.Stdout.WriteString("bad arguments\n")
os.Exit(1)
}
b, err := strconv.Atoi(flag.Arg(1))
if err != nil {
os.Stdout.WriteString("bad arguments\n")
os.Exit(1)
}
var res int
if sub {
res = a - b
} else {
res = a + b
}
os.Stdout.WriteString("result: " + strconv.Itoa(res) + "\n")
}

BIN
examples/cli/testdata/cli.wasm vendored Executable file

Binary file not shown.