Repository: tetratelabs/wazero
Pull request #955 (2022-12-22 02:59:39)

cli: allows use of GOOS=js
This changes the CLI to detect if GOOS=js is likely in use and switches accordingly. This makes it easier to find bugs and may eventually lead to go being able to swap out node.js for wazero. This adds 1.5-2MB to the binary depending on ldflags.

Note: GOOS=js a.k.a. gojs isn't complete, yet, so this helps identify that as well. For example, similar to WASI there's no support for mkdir yet.
```bash
$ GOOS=js GOARCH=wasm go test -o os.wasm -c os
$ wazero run os.wasm -test.v
=== RUN   TestExpand
--- PASS: TestExpand (0.00s)
=== RUN   TestConsistentEnviron
--- PASS: TestConsistentEnviron (0.00s)
=== RUN   TestUnsetenv
--- PASS: TestUnsetenv (0.00s)
=== RUN   TestClearenv
--- PASS: TestClearenv (0.00s)
=== RUN   TestLookupEnv
--- PASS: TestLookupEnv (0.00s)
=== RUN   TestEnvironConsistency
--- PASS: TestEnvironConsistency (0.00s)
=== RUN   TestErrIsExist
    error_test.go:19: open ErrIsExist tempfile: open /tmp/_Go_ErrIsExist1152228349: No such file or directory
--- FAIL: TestErrIsExist (0.00s)
=== RUN   TestErrIsNotExist
error instantiating wasm binary: TODO: call fs.mkdir (recovered by wazero)
wasm stack trace:
	go.syscall/js.valueCall(i32)
	.syscall_js.valueCall(i32) i32
	.syscall_js.Value.Call(i32) i32
	.syscall.fsCall(i32) i32
	.syscall.Mkdir(i32) i32
	.os.Mkdir.func1(i32) i32
	.os.Mkdir(i32) i32
	.os.MkdirTemp(i32) i32
	.wasm_pc_f_loop()
	.wasm_export_run(i32,i32)
```


codefromthecrypt: > like it, though curious if it's possible to test this

we can test it with temporary wasm as GOOS=js wasm is too huge to check in.. lemme

Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index 959bf99e..90c50663 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -12,7 +12,9 @@ import (
 	"strings"
 
 	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
+	gojs "github.com/tetratelabs/wazero/imports/go"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/version"
 	"github.com/tetratelabs/wazero/sys"
@@ -222,11 +224,16 @@ func doRun(args []string, stdOut io.Writer, stdErr io.Writer, exit func(code int
 		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)
+	needsWASI, needsGo := detectImports(code.ImportedFunctions())
+
+	if needsWASI {
+		wasi_snapshot_preview1.MustInstantiate(ctx, rt)
+		_, err = rt.InstantiateModule(ctx, code, conf)
+	} else if needsGo {
+		gojs.MustInstantiate(ctx, rt)
+		err = gojs.Run(ctx, rt, code, conf)
+	}
 
-	_, err = rt.InstantiateModule(ctx, code, conf)
 	if err != nil {
 		if exitErr, ok := err.(*sys.ExitError); ok {
 			exit(int(exitErr.ExitCode()))
@@ -239,6 +246,21 @@ func doRun(args []string, stdOut io.Writer, stdErr io.Writer, exit func(code int
 	exit(0)
 }
 
+func detectImports(imports []api.FunctionDefinition) (needsWASI, needsGo bool) {
+	for _, f := range imports {
+		moduleName, _, _ := f.Import()
+		switch moduleName {
+		case wasi_snapshot_preview1.ModuleName:
+			needsWASI = true
+			return // can't be both WASI and go
+		case "go":
+			needsGo = true
+			return // can't be both WASI and go
+		}
+	}
+	return
+}
+
 func cacheDirFlag(flags *flag.FlagSet) *string {
 	return flags.String("cachedir", "", "Writeable directory for native code compiled from wasm. "+
 		"Contents are re-used for the same version of wazero.")


Test patch:
diff --git a/cmd/wazero/testdata/cat/.gitignore b/cmd/wazero/testdata/cat/.gitignore
new file mode 100644
index 00000000..76e0c3b4
--- /dev/null
+++ b/cmd/wazero/testdata/cat/.gitignore
@@ -0,0 +1,2 @@
+# GOARCH=wasm GOOS=js binaries are too huge to check-in
+cat.wasm
diff --git a/cmd/wazero/testdata/cat/cat.go b/cmd/wazero/testdata/cat/cat.go
new file mode 100644
index 00000000..0888df56
--- /dev/null
+++ b/cmd/wazero/testdata/cat/cat.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"os"
+)
+
+// main runs cat: concatenate and print files.
+func main() {
+	// Start at arg[1] because args[0] is the program name.
+	for i := 1; i < len(os.Args); i++ {
+		bytes, err := os.ReadFile(os.Args[i])
+		if err != nil {
+			os.Exit(1)
+		}
+
+		// Use write to avoid needing to worry about Windows newlines.
+		os.Stdout.Write(bytes)
+	}
+}
diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go
index 38009191..18f067a0 100644
--- a/cmd/wazero/wazero_test.go
+++ b/cmd/wazero/wazero_test.go
@@ -7,11 +7,15 @@ import (
 	"fmt"
 	"log"
 	"os"
+	"os/exec"
 	"path"
 	"path/filepath"
 	"runtime"
 	"testing"
 
+	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/version"
 )
@@ -28,10 +32,19 @@ var wasmWasiFd []byte
 //go:embed testdata/fs/bear.txt
 var bearTxt []byte
 
+// wasmCat is compiled on demand with `GOARCH=wasm GOOS=js`
+var wasmCat []byte
+
 func TestMain(m *testing.M) {
 	// For some reason, riscv64 fails to see directory listings.
 	if a := runtime.GOARCH; a == "riscv64" {
-		log.Println("gojs: skipping due to not yet supported GOARCH:", a)
+		log.Println("main: skipping due to not yet supported GOARCH:", a)
+		os.Exit(0)
+	}
+
+	// Notably our scratch containers don't have go, so don't fail tests.
+	if err := compileGoJS(); err != nil {
+		log.Println("main: Skipping GOARCH=wasm GOOS=js tests due to:", err)
 		os.Exit(0)
 	}
 	os.Exit(m.Run())
@@ -208,6 +221,13 @@ func TestRun(t *testing.T) {
 			wazeroOpts: []string{fmt.Sprintf("--mount=%s:/", filepath.Dir(bearPath))},
 			stdOut:     "pooh\n",
 		},
+		{
+			name:       "GOARCH=wasm GOOS=js",
+			wasm:       wasmCat,
+			wazeroOpts: []string{fmt.Sprintf("--mount=%s:/", filepath.Dir(bearPath))},
+			wasmArgs:   []string{"/bear.txt"},
+			stdOut:     "pooh\n",
+		},
 		{
 			name:       "cachedir existing absolute",
 			wazeroOpts: []string{"--cachedir=" + existingDir1},
@@ -264,6 +284,12 @@ func TestRun(t *testing.T) {
 
 	for _, tc := range tests {
 		tt := tc
+
+		if tc.wasm == nil {
+			// We should only skip when the runtime is a scratch image.
+			require.False(t, platform.CompilerSupported())
+			continue
+		}
 		t.Run(tt.name, func(t *testing.T) {
 			wasmPath := filepath.Join(tmpDir, "test.wasm")
 			require.NoError(t, os.WriteFile(wasmPath, tt.wasm, 0o700))
@@ -337,6 +363,67 @@ func TestRun_Errors(t *testing.T) {
 	}
 }
 
+var _ api.FunctionDefinition = importer{}
+
+type importer struct {
+	moduleName, name string
+}
+
+func (i importer) ModuleName() string { return "" }
+func (i importer) Index() uint32      { return 0 }
+func (i importer) Import() (moduleName, name string, isImport bool) {
+	return i.moduleName, i.name, true
+}
+func (i importer) ExportNames() []string        { return nil }
+func (i importer) Name() string                 { return "" }
+func (i importer) DebugName() string            { return "" }
+func (i importer) GoFunction() interface{}      { return nil }
+func (i importer) ParamTypes() []api.ValueType  { return nil }
+func (i importer) ParamNames() []string         { return nil }
+func (i importer) ResultTypes() []api.ValueType { return nil }
+func (i importer) ResultNames() []string        { return nil }
+
+func Test_detectImports(t *testing.T) {
+	tests := []struct {
+		message                        string
+		imports                        []api.FunctionDefinition
+		expectNeedsWASI, expectNeedsGo bool
+	}{
+		{
+			message: "no imports",
+		},
+		{
+			message: "other imports",
+			imports: []api.FunctionDefinition{
+				importer{"env", "emscripten_notify_memory_growth"},
+			},
+		},
+		{
+			message: "wasi",
+			imports: []api.FunctionDefinition{
+				importer{wasi_snapshot_preview1.ModuleName, "fd_read"},
+			},
+			expectNeedsWASI: true,
+		},
+		{
+			message: "GOARCH=wasm GOOS=js",
+			imports: []api.FunctionDefinition{
+				importer{"go", "syscall/js.valueCall"},
+			},
+			expectNeedsGo: true,
+		},
+	}
+
+	for _, tc := range tests {
+		tt := tc
+		t.Run(tt.message, func(t *testing.T) {
+			needsWASI, needsGo := detectImports(tc.imports)
+			require.Equal(t, tc.expectNeedsWASI, needsWASI)
+			require.Equal(t, tc.expectNeedsGo, needsGo)
+		})
+	}
+}
+
 func TestHelp(t *testing.T) {
 	exitCode, _, stdErr := runMain(t, []string{"-h"})
 	require.Equal(t, 0, exitCode)
@@ -382,3 +469,26 @@ func runMain(t *testing.T, args []string) (int, string, string) {
 
 	return exitCode, stdOut.String(), stdErr.String()
 }
+
+// compileGoJS compiles "testdata/cat/cat.go" on demand as the binary generated
+// is too big (1.6MB) to check into the source tree.
+func compileGoJS() (err error) {
+	dir, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+
+	srcDir := path.Join(dir, "testdata", "cat")
+	outPath := path.Join(srcDir, "cat.wasm")
+
+	// This doesn't add "-ldflags=-s -w", as the binary size only changes 28KB.
+	cmd := exec.Command("go", "build", "-o", outPath, ".")
+	cmd.Dir = srcDir
+	cmd.Env = append(os.Environ(), "GOARCH=wasm", "GOOS=js", "GOWASM=satconv,signext")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("go build: %v\n%s", err, out)
+	}
+
+	wasmCat, err = os.ReadFile(outPath)
+	return
+}