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:
6
.github/workflows/commit.yaml
vendored
6
.github/workflows/commit.yaml
vendored
@@ -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
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/wazero
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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 \
|
||||
|
||||
@@ -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
21
cmd/wazero/README.md
Normal 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
BIN
cmd/wazero/testdata/wasi_arg.wasm
vendored
Normal file
Binary file not shown.
62
cmd/wazero/testdata/wasi_arg.wat
vendored
Normal file
62
cmd/wazero/testdata/wasi_arg.wat
vendored
Normal 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
134
cmd/wazero/wazero.go
Normal 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
123
cmd/wazero/wazero_test.go
Normal 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()
|
||||
}
|
||||
BIN
examples/allocation/tinygo/testdata/greet.wasm
vendored
BIN
examples/allocation/tinygo/testdata/greet.wasm
vendored
Binary file not shown.
0
examples/basic/testdata/add.wasm
vendored
Normal file → Executable file
0
examples/basic/testdata/add.wasm
vendored
Normal file → Executable file
12
examples/cli/README.md
Normal file
12
examples/cli/README.md
Normal 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
59
examples/cli/cli_test.go
Normal 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
40
examples/cli/testdata/cli.go
vendored
Normal 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
BIN
examples/cli/testdata/cli.wasm
vendored
Executable file
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user