diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index b4b3c57b..70851fe6 100644 --- a/.github/workflows/commit.yaml +++ b/.github/workflows/commit.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 997feae8..89ac6f90 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +/wazero # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 788c37c4..a63bd459 100644 --- a/Makefile +++ b/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 \ diff --git a/RATIONALE.md b/RATIONALE.md index 0aed0f00..0c9a1b70 100644 --- a/RATIONALE.md +++ b/RATIONALE.md @@ -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. diff --git a/cmd/wazero/README.md b/cmd/wazero/README.md new file mode 100644 index 00000000..031770e6 --- /dev/null +++ b/cmd/wazero/README.md @@ -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. diff --git a/cmd/wazero/testdata/wasi_arg.wasm b/cmd/wazero/testdata/wasi_arg.wasm new file mode 100644 index 00000000..a3d5c317 Binary files /dev/null and b/cmd/wazero/testdata/wasi_arg.wasm differ diff --git a/cmd/wazero/testdata/wasi_arg.wat b/cmd/wazero/testdata/wasi_arg.wat new file mode 100644 index 00000000..c83a8fe8 --- /dev/null +++ b/cmd/wazero/testdata/wasi_arg.wat @@ -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 + ) +) diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go new file mode 100644 index 00000000..c7769477 --- /dev/null +++ b/cmd/wazero/wazero.go @@ -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 ") + 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 [--] ") + fmt.Fprintln(stdErr) +} diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go new file mode 100644 index 00000000..3b7880db --- /dev/null +++ b/cmd/wazero/wazero_test.go @@ -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() +} diff --git a/examples/allocation/tinygo/testdata/greet.wasm b/examples/allocation/tinygo/testdata/greet.wasm index 09547d6c..0a49cb0d 100755 Binary files a/examples/allocation/tinygo/testdata/greet.wasm and b/examples/allocation/tinygo/testdata/greet.wasm differ diff --git a/examples/basic/testdata/add.wasm b/examples/basic/testdata/add.wasm old mode 100644 new mode 100755 diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 00000000..0dee07d0 --- /dev/null +++ b/examples/cli/README.md @@ -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. diff --git a/examples/cli/cli_test.go b/examples/cli/cli_test.go new file mode 100644 index 00000000..32e70d01 --- /dev/null +++ b/examples/cli/cli_test.go @@ -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())) + }) + } +} diff --git a/examples/cli/testdata/cli.go b/examples/cli/testdata/cli.go new file mode 100644 index 00000000..0096852f --- /dev/null +++ b/examples/cli/testdata/cli.go @@ -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") +} diff --git a/examples/cli/testdata/cli.wasm b/examples/cli/testdata/cli.wasm new file mode 100755 index 00000000..e3e60f7c Binary files /dev/null and b/examples/cli/testdata/cli.wasm differ diff --git a/imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.wasm b/imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.wasm index b90b1ce3..8a6114f8 100755 Binary files a/imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.wasm and b/imports/wasi_snapshot_preview1/example/testdata/tinygo/cat.wasm differ