diff --git a/Makefile b/Makefile index a8196c40940..bb9524be864 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ hugo := github.com/gohugoio/hugo@v0.101.0 # Make 3.81 doesn't support '**' globbing: Set explicitly instead of recursion. all_sources := $(wildcard *.go */*.go */*/*.go */*/*/*.go */*/*/*.go */*/*/*/*.go) -all_testdata := $(wildcard testdata/* */testdata/* */*/testdata/* */*/*/testdata/*) +all_testdata := $(wildcard testdata/* */testdata/* */*/testdata/* */*/testdata/*/* */*/*/testdata/*) all_testing := $(wildcard internal/testing/* internal/testing/*/* internal/testing/*/*/*) all_examples := $(wildcard examples/* examples/*/* examples/*/*/*) all_it := $(wildcard internal/integration_test/* internal/integration_test/*/* internal/integration_test/*/*/*) @@ -53,7 +53,16 @@ build.examples.zig: examples/allocation/zig/testdata/greet.wasm @(cd $(@D); zig build) @mv $(@D)/zig-out/lib/$(@F) $(@D) -tinygo_sources := $(wildcard examples/*/testdata/*.go examples/*/*/testdata/*.go examples/*/testdata/*/*.go) +go_sources := examples/wasm_exec/testdata/cat.go +.PHONY: build.examples.go +build.examples.go: $(go_sources) + @for f in $^; do \ + cd $$(dirname $$f); \ + GOARCH=wasm GOOS=js go build -o $$(basename $$f | sed -e 's/\.go/\.wasm/') .; \ + cd -; \ + done + +tinygo_sources := $(filter-out $(go_sources), $(wildcard examples/*/testdata/*.go examples/*/*/testdata/*.go examples/*/testdata/*/*.go)) .PHONY: build.examples.tinygo build.examples.tinygo: $(tinygo_sources) @for f in $^; do \ diff --git a/examples/gojs/README.md b/examples/gojs/README.md new file mode 100644 index 00000000000..ad4c2dff112 --- /dev/null +++ b/examples/gojs/README.md @@ -0,0 +1,15 @@ +## gojs example + +This shows how to use Wasm built by go using `GOARCH=wasm GOOS=js`. Notably, +this shows an interesting feature this supports, HTTP client requests. + +```bash +$ go run stars.go +wazero has 99999999 stars. Does that include you? +``` + +Internally, this uses [gojs](../../experimental/gojs/gojs.go), which implements +the custom host functions required by Go. + +Note: `GOARCH=wasm GOOS=js` is experimental as is wazero's support of it. For +details, see https://wazero.io/languages/go/. diff --git a/examples/gojs/stars.go b/examples/gojs/stars.go new file mode 100644 index 00000000000..d2580051bcc --- /dev/null +++ b/examples/gojs/stars.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/exec" + "path" + "runtime" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental/gojs" + "github.com/tetratelabs/wazero/sys" +) + +// main invokes Wasm compiled via `GOARCH=wasm GOOS=js`, which reports the star +// count of wazero. +// +// This shows how to integrate an HTTP client with wasm using gojs. +func main() { + // Choose the context to use for function calls. + ctx := context.Background() + + // Create a new WebAssembly Runtime. + r := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig(). + // WebAssembly 2.0 allows use of gojs. + WithWasmCore2()) + defer r.Close(ctx) // This closes everything this Runtime created. + + // Combine the above into our baseline config, overriding defaults. + config := wazero.NewModuleConfig(). + // By default, I/O streams are discarded, so you won't see output. + WithStdout(os.Stdout).WithStderr(os.Stderr) + + // Compile the WebAssembly module using the default configuration. + compiled, err := r.CompileModule(ctx, compileWasm(), wazero.NewCompileConfig()) + if err != nil { + log.Panicln(err) + } + + // Grant the compiled wasm access to the default HTTP Transport. + ctx = gojs.WithRoundTripper(ctx, http.DefaultTransport) + + // Execute the "run" function, which corresponds to "main" in cat/main.go. + err = gojs.Run(ctx, r, compiled, config) + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + log.Panicln(err) + } else if !ok { + log.Panicln(err) + } +} + +// compileWasm compiles "stars/main.go" on demand as the binary generated is +// too big (>1MB) to check into the source tree. +func compileWasm() []byte { + cmd := exec.Command("go", "build", "-o", "main.wasm", ".") + + _, thisFile, _, ok := runtime.Caller(1) + if !ok { + panic("couldn't read path to current file") + } + cmd.Dir = path.Join(path.Dir(thisFile), "stars") + + cmd.Env = append(os.Environ(), "GOARCH=wasm", "GOOS=js") + if out, err := cmd.CombinedOutput(); err != nil { + log.Panicf("go build: %v\n%s", err, out) + } + bin, err := os.ReadFile(path.Join(cmd.Dir, "main.wasm")) + if err != nil { + panic(err) + } + return bin +} diff --git a/examples/gojs/stars/.gitignore b/examples/gojs/stars/.gitignore new file mode 100644 index 00000000000..f7aa9b7a36c --- /dev/null +++ b/examples/gojs/stars/.gitignore @@ -0,0 +1 @@ +main.wasm diff --git a/examples/gojs/stars/go.mod b/examples/gojs/stars/go.mod new file mode 100644 index 00000000000..38f9ff15ae4 --- /dev/null +++ b/examples/gojs/stars/go.mod @@ -0,0 +1,3 @@ +module github.com/tetratelabs/wazero/examples/gojs/stars + +go 1.18 diff --git a/examples/gojs/stars/main.go b/examples/gojs/stars/main.go new file mode 100644 index 00000000000..9345a8b7096 --- /dev/null +++ b/examples/gojs/stars/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +const gitHubRepoAPI = "https://api.github.com/repos/tetratelabs/wazero" + +type gitHubRepo struct { + Stars int `json:"stargazers_count"` +} + +func main() { + req, err := http.NewRequest("GET", gitHubRepoAPI, nil) + if err != nil { + panic(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + b, _ := io.ReadAll(resp.Body) + panic("GitHub lookup failed: " + string(b)) + } + + var repo gitHubRepo + json.NewDecoder(resp.Body).Decode(&repo) + fmt.Println("wazero has", repo.Stars, "stars. Does that include you?") +} diff --git a/examples/gojs/stars_test.go b/examples/gojs/stars_test.go new file mode 100644 index 00000000000..2229ae9285c --- /dev/null +++ b/examples/gojs/stars_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "testing" + + "github.com/tetratelabs/wazero/internal/testing/maintester" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +// Test_main ensures the following will work: +// +// go run stars.go +func Test_main(t *testing.T) { + stdout, stderr := maintester.TestMain(t, main, "stars") + require.Equal(t, "", stderr) + require.Contains(t, stdout, "Does that include you?\n") +} diff --git a/experimental/gojs/gojs.go b/experimental/gojs/gojs.go new file mode 100644 index 00000000000..aacb646e18f --- /dev/null +++ b/experimental/gojs/gojs.go @@ -0,0 +1,131 @@ +// Package gojs allows you to run wasm binaries compiled by Go when `GOOS=js` +// and `GOARCH=wasm`. +// +// # Usage +// +// When `GOOS=js` and `GOARCH=wasm`, Go's compiler targets WebAssembly 1.0 +// Binary format (%.wasm). +// +// Ex. +// +// GOOS=js GOARCH=wasm go build -o cat.wasm . +// +// After compiling `cat.wasm` with wazero.Runtime's `CompileModule`, run it +// like below: +// +// err = gojs.Run(ctx, r, compiled, config) +// if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { +// log.Panicln(err) +// } else if !ok { +// log.Panicln(err) +// } +// +// Under the scenes, the compiled Wasm calls host functions that support the +// runtime.GOOS. This is similar to what is implemented in wasm_exec.js. See +// https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js +// +// # Experimental +// +// Go defines js "EXPERIMENTAL... exempt from the Go compatibility promise." +// Accordingly, wazero cannot guarantee this will work from release to release, +// or that usage will be relatively free of bugs. Due to this and the +// relatively high implementation overhead, most will choose TinyGo instead. +package gojs + +import ( + "context" + "net/http" + + "github.com/tetratelabs/wazero" + . "github.com/tetratelabs/wazero/internal/gojs" +) + +// WithRoundTripper sets the http.RoundTripper used to Run Wasm. +// +// For example, if the code compiled via `GOARCH=wasm GOOS=js` uses +// http.RoundTripper, you can avoid failures by assigning an implementation +// like so: +// +// ctx = gojs.WithRoundTripper(ctx, http.DefaultTransport) +// err = gojs.Run(ctx, r, compiled, config) +func WithRoundTripper(ctx context.Context, rt http.RoundTripper) context.Context { + return context.WithValue(ctx, RoundTripperKey{}, rt) +} + +// Run instantiates a new module and calls "run" with the given config. +// +// # Parameters +// +// - ctx: context to use when instantiating the module and calling "run". +// - r: runtime to instantiate both the host and guest (compiled) module into. +// - compiled: guest binary compiled with `GOARCH=wasm GOOS=js` +// - config: the configuration such as args, env or filesystem to use. +// +// # Example +// +// After compiling your Wasm binary with wazero.Runtime's `CompileModule`, run +// it like below: +// +// err = gojs.Run(ctx, r, compiled, config) +// if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { +// log.Panicln(err) +// } else if !ok { +// log.Panicln(err) +// } +// +// Note: Both the host and guest module are closed after being run. +func Run(ctx context.Context, r wazero.Runtime, compiled wazero.CompiledModule, config wazero.ModuleConfig) error { + // Instantiate the imports needed by go-compiled wasm. + js, err := moduleBuilder(r).Instantiate(ctx, r) + if err != nil { + return err + } + defer js.Close(ctx) + + // Instantiate the module compiled by go, noting it has no init function. + mod, err := r.InstantiateModule(ctx, compiled, config) + if err != nil { + return err + } + defer mod.Close(ctx) + + // Extract the args and env from the module config and write it to memory. + ctx = WithState(ctx) + argc, argv, err := WriteArgsAndEnviron(ctx, mod) + if err != nil { + return err + } + // Invoke the run function. + _, err = mod.ExportedFunction("run").Call(ctx, uint64(argc), uint64(argv)) + return err +} + +// moduleBuilder returns a new wazero.ModuleBuilder +func moduleBuilder(r wazero.Runtime) wazero.ModuleBuilder { + return r.NewModuleBuilder("go"). + ExportFunction(GetRandomData.Name(), GetRandomData). + ExportFunction(Nanotime1.Name(), Nanotime1). + ExportFunction(WasmExit.Name(), WasmExit). + ExportFunction(CopyBytesToJS.Name(), CopyBytesToJS). + ExportFunction(ValueCall.Name(), ValueCall). + ExportFunction(ValueGet.Name(), ValueGet). + ExportFunction(ValueIndex.Name(), ValueIndex). + ExportFunction(ValueLength.Name(), ValueLength). + ExportFunction(ValueNew.Name(), ValueNew). + ExportFunction(ValueSet.Name(), ValueSet). + ExportFunction(WasmWrite.Name(), WasmWrite). + ExportFunction(ResetMemoryDataView.Name, ResetMemoryDataView). + ExportFunction(Walltime.Name(), Walltime). + ExportFunction(ScheduleTimeoutEvent.Name, ScheduleTimeoutEvent). + ExportFunction(ClearTimeoutEvent.Name, ClearTimeoutEvent). + ExportFunction(FinalizeRef.Name(), FinalizeRef). + ExportFunction(StringVal.Name(), StringVal). + ExportFunction(ValueDelete.Name, ValueDelete). + ExportFunction(ValueSetIndex.Name, ValueSetIndex). + ExportFunction(ValueInvoke.Name, ValueInvoke). + ExportFunction(ValuePrepareString.Name(), ValuePrepareString). + ExportFunction(ValueInstanceOf.Name, ValueInstanceOf). + ExportFunction(ValueLoadString.Name(), ValueLoadString). + ExportFunction(CopyBytesToGo.Name(), CopyBytesToGo). + ExportFunction(Debug.Name, Debug) +} diff --git a/internal/gojs/argsenv.go b/internal/gojs/argsenv.go new file mode 100644 index 00000000000..11899b4bb55 --- /dev/null +++ b/internal/gojs/argsenv.go @@ -0,0 +1,61 @@ +package gojs + +import ( + "context" + "errors" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/wasm" +) + +// Constants about memory layout. See REFERENCE.md +const ( + endOfPageZero = uint32(4096) // runtime.minLegalPointer + maxArgsAndEnviron = uint32(8192) // ld.wasmMinDataAddr - runtime.minLegalPointer + wasmMinDataAddr = endOfPageZero + maxArgsAndEnviron // ld.wasmMinDataAddr +) + +// WriteArgsAndEnviron writes arguments and environment variables to memory, so +// they can be read by main, Go compiles as the function export "run". +func WriteArgsAndEnviron(ctx context.Context, mod api.Module) (argc, argv uint32, err error) { + mem := mod.Memory() + sysCtx := mod.(*wasm.CallContext).Sys + args := sysCtx.Args() + environ := sysCtx.Environ() + + argc = uint32(len(args)) + offset := endOfPageZero + + strPtr := func(val, field string, i int) (ptr uint32) { + // TODO: return err and format "%s[%d], field, i" + ptr = offset + mustWrite(ctx, mem, field, offset, append([]byte(val), 0)) + offset += uint32(len(val) + 1) + if pad := offset % 8; pad != 0 { + offset += 8 - pad + } + return + } + argvPtrs := make([]uint32, 0, len(args)+1+len(environ)+1) + for i, arg := range args { + argvPtrs = append(argvPtrs, strPtr(arg, "args", i)) + } + argvPtrs = append(argvPtrs, 0) + + for i, env := range environ { + argvPtrs = append(argvPtrs, strPtr(env, "env", i)) + } + argvPtrs = append(argvPtrs, 0) + + argv = offset + for _, ptr := range argvPtrs { + // TODO: return err and format "argvPtrs[%d], i" + mustWriteUint64Le(ctx, mem, "argvPtrs[i]", offset, uint64(ptr)) + offset += 8 + } + + if offset >= wasmMinDataAddr { + err = errors.New("total length of command line and environment variables exceeds limit") + } + return +} diff --git a/internal/gojs/argsenv_test.go b/internal/gojs/argsenv_test.go new file mode 100644 index 00000000000..50ed0178dd6 --- /dev/null +++ b/internal/gojs/argsenv_test.go @@ -0,0 +1,25 @@ +package gojs_test + +import ( + _ "embed" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/argsenv/main.go +var argsenvGo string + +func Test_argsAndEnv(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, argsenvGo, wazero.NewModuleConfig().WithArgs("prog", "a", "b").WithEnv("c", "d").WithEnv("a", "b")) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, ` +args 0 = a +args 1 = b +environ 0 = c=d +environ 1 = a=b +`, stdout) +} diff --git a/internal/gojs/builtin.go b/internal/gojs/builtin.go new file mode 100644 index 00000000000..0f3a82f3472 --- /dev/null +++ b/internal/gojs/builtin.go @@ -0,0 +1,110 @@ +package gojs + +import ( + "net/http" +) + +const ( + // predefined + + idValueNaN uint32 = iota + idValueZero + idValueNull + idValueTrue + idValueFalse + idValueGlobal + idJsGo + + // The below are derived from analyzing `*_js.go` source. + idObjectConstructor + idArrayConstructor + idJsProcess + idJsfs + idJsfsConstants + idUint8ArrayConstructor + idJsCrypto + idJsDateConstructor + idJsDate + idHttpFetch + idHttpHeaders + nextID +) + +const ( + refValueUndefined = ref(0) + refValueNaN = (nanHead|ref(typeFlagNone))<<32 | ref(idValueNaN) + refValueZero = (nanHead|ref(typeFlagNone))<<32 | ref(idValueZero) + refValueNull = (nanHead|ref(typeFlagNone))<<32 | ref(idValueNull) + refValueTrue = (nanHead|ref(typeFlagNone))<<32 | ref(idValueTrue) + refValueFalse = (nanHead|ref(typeFlagNone))<<32 | ref(idValueFalse) + refValueGlobal = (nanHead|ref(typeFlagObject))<<32 | ref(idValueGlobal) + refJsGo = (nanHead|ref(typeFlagObject))<<32 | ref(idJsGo) + refObjectConstructor = (nanHead|ref(typeFlagFunction))<<32 | ref(idObjectConstructor) + refArrayConstructor = (nanHead|ref(typeFlagFunction))<<32 | ref(idArrayConstructor) + refJsProcess = (nanHead|ref(typeFlagObject))<<32 | ref(idJsProcess) + refJsfs = (nanHead|ref(typeFlagObject))<<32 | ref(idJsfs) + refJsfsConstants = (nanHead|ref(typeFlagObject))<<32 | ref(idJsfsConstants) + refUint8ArrayConstructor = (nanHead|ref(typeFlagFunction))<<32 | ref(idUint8ArrayConstructor) + refJsCrypto = (nanHead|ref(typeFlagFunction))<<32 | ref(idJsCrypto) + refJsDateConstructor = (nanHead|ref(typeFlagFunction))<<32 | ref(idJsDateConstructor) + refJsDate = (nanHead|ref(typeFlagObject))<<32 | ref(idJsDate) + refHttpFetch = (nanHead|ref(typeFlagFunction))<<32 | ref(idHttpFetch) + refHttpHeadersConstructor = (nanHead|ref(typeFlagFunction))<<32 | ref(idHttpHeaders) +) + +// newJsGlobal = js.Global() // js.go init +func newJsGlobal(rt http.RoundTripper) *jsVal { + var fetchProperty interface{} = undefined + if rt != nil { + fetchProperty = refHttpFetch + } + return newJsVal(refValueGlobal, "global"). + addProperties(map[string]interface{}{ + "Object": objectConstructor, + "Array": arrayConstructor, + "crypto": jsCrypto, + "Uint8Array": uint8ArrayConstructor, + "fetch": fetchProperty, + "AbortController": undefined, + "Headers": headersConstructor, + "process": jsProcess, + "fs": jsfs, + "Date": jsDateConstructor, + }). + addFunction("fetch", &fetch{}) +} + +var ( + // Values below are not built-in, but verifiable by looking at Go's source. + // When marked "XX.go init", these are eagerly referenced during syscall.init + + // jsGo is not a constant + + // objectConstructor is used by js.ValueOf to make `map[string]any`. + // Get("Object") // js.go init + objectConstructor = newJsVal(refObjectConstructor, "Object") + + // arrayConstructor is used by js.ValueOf to make `[]any`. + // Get("Array") // js.go init + arrayConstructor = newJsVal(refArrayConstructor, "Array") + + // jsProcess = js.Global().Get("process") // fs_js.go init + jsProcess = newJsVal(refJsProcess, "process"). + addProperties(map[string]interface{}{ + "pid": float64(1), // Get("pid").Int() in syscall_js.go for syscall.Getpid + "ppid": refValueZero, // Get("ppid").Int() in syscall_js.go for syscall.Getppid + }). + addFunction("cwd", &cwd{}). // syscall.Cwd in fs_js.go + addFunction("chdir", &chdir{}). // syscall.Chdir in fs_js.go + addFunction("getuid", &returnZero{}). // syscall.Getuid in syscall_js.go + addFunction("getgid", &returnZero{}). // syscall.Getgid in syscall_js.go + addFunction("geteuid", &returnZero{}). // syscall.Geteuid in syscall_js.go + addFunction("getgroups", &returnSliceOfZero{}). // syscall.Getgroups in syscall_js.go + addFunction("umask", &returnArg0{}) // syscall.Umask in syscall_js.go + + // uint8ArrayConstructor = js.Global().Get("Uint8Array") + // // fs_js.go, rand_js.go, roundtrip_js.go init + // + // It has only one invocation pattern: `buf := uint8Array.New(len(b))` + uint8ArrayConstructor = newJsVal(refUint8ArrayConstructor, "Uint8Array") +) diff --git a/internal/gojs/compiler_test.go b/internal/gojs/compiler_test.go new file mode 100644 index 00000000000..c28dce5be62 --- /dev/null +++ b/internal/gojs/compiler_test.go @@ -0,0 +1,115 @@ +package gojs_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental/gojs" + "github.com/tetratelabs/wazero/internal/testing/require" + "github.com/tetratelabs/wazero/internal/wasm" + "github.com/tetratelabs/wazero/internal/wasm/binary" +) + +// testCtx is an arbitrary, non-default context. Non-nil also prevents linter errors. +var testCtx = context.WithValue(context.Background(), struct{}{}, "arbitrary") + +// Test_compileJsWasm ensures the infrastructure to generate wasm on-demand works. +func Test_compileJsWasm(t *testing.T) { + bin := compileJsWasm(t, `package main + +import "os" + +func main() { + os.Exit(1) +}`) + + m, err := binary.DecodeModule(bin, wasm.Features20191205, wasm.MemorySizer) + require.NoError(t, err) + // TODO: implement go.buildid custom section and validate it instead. + require.NotNil(t, m.MemorySection) +} + +func Test_compileAndRunJsWasm(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, `package main + +func main() {}`, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Zero(t, stdout) +} + +func compileAndRunJsWasm(ctx context.Context, t *testing.T, goSrc string, config wazero.ModuleConfig) (stdout, stderr string, err error) { + bin := compileJsWasm(t, goSrc) + + var stdoutBuf, stderrBuf bytes.Buffer + + r := wazero.NewRuntimeWithConfig(testCtx, wazero.NewRuntimeConfig().WithWasmCore2()) + defer r.Close(ctx) + + compiled, compileErr := r.CompileModule(ctx, bin, wazero.NewCompileConfig()) + if compileErr != nil { + err = compileErr + return + } + + err = gojs.Run(ctx, r, compiled, config.WithStdout(&stdoutBuf).WithStderr(&stderrBuf)) + stdout = stdoutBuf.String() + stderr = stderrBuf.String() + return +} + +// compileJsWasm allows us to generate a binary with runtime.GOOS=js and runtime.GOARCH=wasm. +func compileJsWasm(t *testing.T, goSrc string) []byte { + goBin, err := findGoBin() + if err != nil { + t.Skip("Skipping tests due to missing Go binary: ", err) + } + // For some reason, windows and freebsd fail to compile with exit status 1. + if os := runtime.GOOS; os != "darwin" && os != "linux" { + t.Skip("Skipping tests due to not yet supported OS: ", os) + } + + workDir := t.TempDir() + + err = os.WriteFile(filepath.Join(workDir, "main.go"), []byte(goSrc), 0o600) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(workDir, "go.mod"), + []byte("module github.com/tetratelabs/wazero/experimental/gojs\n\ngo 1.18\n"), 0o600) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + bin := "out.wasm" + cmd := exec.CommandContext(ctx, goBin, "build", "-o", bin, ".") //nolint:gosec + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + cmd.Dir = workDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "couldn't compile %s: %s", bin, string(out)) + + binBytes, err := os.ReadFile(filepath.Join(workDir, bin)) //nolint:gosec + require.NoError(t, err, "couldn't compile %s", bin) + return binBytes +} + +func findGoBin() (string, error) { + binName := "go" + if runtime.GOOS == "windows" { + binName += ".exe" + } + goBin := filepath.Join(runtime.GOROOT(), "bin", binName) + if _, err := os.Stat(goBin); err == nil { + return goBin, nil + } + // Now, search the path + return exec.LookPath(binName) +} diff --git a/internal/gojs/crypto.go b/internal/gojs/crypto.go new file mode 100644 index 00000000000..034a55c32d4 --- /dev/null +++ b/internal/gojs/crypto.go @@ -0,0 +1,29 @@ +package gojs + +import ( + "context" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/wasm" +) + +// jsCrypto gets random values. +// +// It has only one invocation pattern: +// +// jsCrypto.Call("getRandomValues", a /* uint8Array */) +// +// This is defined as `Get("crypto")` in rand_js.go init +var jsCrypto = newJsVal(refJsCrypto, "crypto"). + addFunction("getRandomValues", &getRandomValues{}) + +type getRandomValues struct{} + +// invoke implements jsFn.invoke +func (*getRandomValues) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + randSource := mod.(*wasm.CallContext).Sys.RandSource() + + r := args[0].(*byteArray) + n, err := randSource.Read(r.slice) + return uint32(n), err +} diff --git a/internal/gojs/crypto_test.go b/internal/gojs/crypto_test.go new file mode 100644 index 00000000000..762648a3e61 --- /dev/null +++ b/internal/gojs/crypto_test.go @@ -0,0 +1,21 @@ +package gojs_test + +import ( + _ "embed" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/crypto/main.go +var cryptoGo string + +func Test_crypto(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, cryptoGo, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `7a0c9f9f0d +`, stdout) +} diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go new file mode 100644 index 00000000000..d2d7472996e --- /dev/null +++ b/internal/gojs/fs.go @@ -0,0 +1,417 @@ +package gojs + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "syscall" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/wasm" +) + +var ( + // jsfs = js.Global().Get("fs") // fs_js.go init + // + // js.fsCall conventions: + // * funcWrapper callback is the last parameter + // * arg0 is error and up to one result in arg1 + jsfs = newJsVal(refJsfs, "fs"). + addProperties(map[string]interface{}{ + "constants": jsfsConstants, // = jsfs.Get("constants") // init + }). + addFunction("open", &jsfsOpen{}). + addFunction("stat", &jsfsStat{}). + addFunction("fstat", &jsfsFstat{}). + addFunction("lstat", &jsfsStat{}). // because fs.FS doesn't support symlink + addFunction("close", &jsfsClose{}). + addFunction("read", &jsfsRead{}). + addFunction("write", &jsfsWrite{}). + addFunction("readdir", &jsfsReaddir{}) + + // TODO: stub all these with syscall.ENOSYS + // * _, err := fsCall("mkdir", path, perm) // syscall.Mkdir + // * _, err := fsCall("unlink", path) // syscall.Unlink + // * _, err := fsCall("rmdir", path) // syscall.Rmdir + // * _, err := fsCall("chmod", path, mode) // syscall.Chmod + // * _, err := fsCall("fchmod", fd, mode) // syscall.Fchmod + // * _, err := fsCall("chown", path, uint32(uid), uint32(gid)) // syscall.Chown + // * _, err := fsCall("fchown", fd, uint32(uid), uint32(gid)) // syscall.Fchown + // * _, err := fsCall("lchown", path, uint32(uid), uint32(gid)) // syscall.Lchown + // * _, err := fsCall("utimes", path, atime, mtime) // syscall.UtimesNano + // * _, err := fsCall("rename", from, to) // syscall.Rename + // * _, err := fsCall("truncate", path, length) // syscall.Truncate + // * _, err := fsCall("ftruncate", fd, length) // syscall.Ftruncate + // * dst, err := fsCall("readlink", path) // syscall.Readlink + // * _, err := fsCall("link", path, link) // syscall.Link + // * _, err := fsCall("symlink", path, link) // syscall.Symlink + // * _, err := fsCall("fsync", fd) // syscall.Fsync + + // jsfsConstants = jsfs Get("constants") // fs_js.go init + jsfsConstants = newJsVal(refJsfsConstants, "constants"). + addProperties(map[string]interface{}{ + "O_WRONLY": oWRONLY, + "O_RDWR": oRDWR, + "O_CREAT": oCREAT, + "O_TRUNC": oTRUNC, + "O_APPEND": oAPPEND, + "O_EXCL": oEXCL, + }) + + // oWRONLY = jsfsConstants Get("O_WRONLY").Int() // fs_js.go init + oWRONLY = api.EncodeF64(float64(os.O_WRONLY)) + + // oRDWR = jsfsConstants Get("O_RDWR").Int() // fs_js.go init + oRDWR = api.EncodeF64(float64(os.O_RDWR)) + + //o CREAT = jsfsConstants Get("O_CREAT").Int() // fs_js.go init + oCREAT = api.EncodeF64(float64(os.O_CREATE)) + + // oTRUNC = jsfsConstants Get("O_TRUNC").Int() // fs_js.go init + oTRUNC = api.EncodeF64(float64(os.O_TRUNC)) + + // oAPPEND = jsfsConstants Get("O_APPEND").Int() // fs_js.go init + oAPPEND = api.EncodeF64(float64(os.O_APPEND)) + + // oEXCL = jsfsConstants Get("O_EXCL").Int() // fs_js.go init + oEXCL = api.EncodeF64(float64(os.O_EXCL)) +) + +// jsfsOpen implements fs.Open +// +// jsFD /* Int */, err := fsCall("open", path, flags, perm) +type jsfsOpen struct{} + +// invoke implements jsFn.invoke +func (*jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + name := args[0].(string) + flags := toUint32(args[1]) // flags are derived from constants like oWRONLY + perm := toUint32(args[2]) + callback := args[3].(funcWrapper) + + fd, err := syscallOpen(ctx, mod, name, flags, perm) + return callback.invoke(ctx, mod, refJsfs, err, fd) // note: error first +} + +// jsfsStat is used for syscall.Stat +// +// jsSt, err := fsCall("stat", path) +type jsfsStat struct{} + +// invoke implements jsFn.invoke +func (*jsfsStat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + name := args[0].(string) + callback := args[1].(funcWrapper) + + stat, err := syscallStat(ctx, mod, name) + return callback.invoke(ctx, mod, refJsfs, err, stat) // note: error first +} + +// syscallStat is like syscall.Stat +func syscallStat(ctx context.Context, mod api.Module, name string) (*jsSt, error) { + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + if fd, err := fsc.OpenFile(ctx, name); err != nil { + return nil, err + } else { + defer fsc.CloseFile(ctx, fd) + return syscallFstat(ctx, mod, fd) + } +} + +// jsfsStat is used for syscall.Open +// +// stat, err := fsCall("fstat", fd); err == nil && stat.Call("isDirectory").Bool() +type jsfsFstat struct{} + +// invoke implements jsFn.invoke +func (*jsfsFstat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + fd := toUint32(args[0]) + callback := args[1].(funcWrapper) + + fstat, err := syscallFstat(ctx, mod, fd) + return callback.invoke(ctx, mod, refJsfs, err, fstat) // note: error first +} + +// syscallFstat is like syscall.Fstat +func syscallFstat(ctx context.Context, mod api.Module, fd uint32) (*jsSt, error) { + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + if f, ok := fsc.OpenedFile(ctx, fd); !ok { + return nil, syscall.EBADF + } else if stat, err := f.File.Stat(); err != nil { + return nil, err + } else { + ret := &jsSt{} + ret.isDir = stat.IsDir() + // TODO ret.dev=stat.Sys + ret.mode = uint32(stat.Mode()) + ret.size = uint32(stat.Size()) + ret.mtimeMs = uint32(stat.ModTime().UnixMilli()) + return ret, nil + } +} + +// jsfsClose is used for syscall.Close +// +// _, err := fsCall("close", fd) +type jsfsClose struct{} + +// invoke implements jsFn.invoke +func (*jsfsClose) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + fd := toUint32(args[0]) + callback := args[1].(funcWrapper) + + err := syscallClose(ctx, mod, fd) + return callback.invoke(ctx, mod, refJsfs, err, true) // note: error first +} + +// syscallClose is like syscall.Close +func syscallClose(ctx context.Context, mod api.Module, fd uint32) (err error) { + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + if ok := fsc.CloseFile(ctx, fd); !ok { + err = syscall.EBADF // already closed + } + return +} + +// jsfsRead is used in syscall.Read and syscall.Pread, called by +// src/internal/poll/fd_unix.go poll.Read. +// +// n, err := fsCall("read", fd, buf, 0, len(b), nil) +type jsfsRead struct{} + +// invoke implements jsFn.invoke +func (*jsfsRead) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + fd := toUint32(args[0]) + buf, ok := args[1].(*byteArray) + if !ok { + return nil, fmt.Errorf("arg[1] is %v not a []byte", args[1]) + } + offset := toUint32(args[2]) + byteCount := toUint32(args[3]) + fOffset := args[4] // nil unless Pread + callback := args[5].(funcWrapper) + + n, err := syscallRead(ctx, mod, fd, fOffset, buf.slice[offset:offset+byteCount]) + return callback.invoke(ctx, mod, refJsfs, err, n) // note: error first +} + +// syscallRead is like syscall.Read +func syscallRead(ctx context.Context, mod api.Module, fd uint32, offset interface{}, p []byte) (n uint32, err error) { + r := fdReader(ctx, mod, fd) + if r == nil { + err = syscall.EBADF + } + + if offset != nil { + if s, ok := r.(io.Seeker); ok { + if _, err := s.Seek(toInt64(offset), io.SeekStart); err != nil { + return 0, err + } + } else { + return 0, syscall.ENOTSUP + } + } + + if nRead, e := r.Read(p); e == nil || e == io.EOF { + // fs_js.go cannot parse io.EOF so coerce it to nil. + // See https://github.com/golang/go/issues/43913 + n = uint32(nRead) + } else { + err = e + } + return +} + +// jsfsWrite is used in syscall.Write and syscall.Pwrite. +// +// Notably, offset is non-nil in Pwrite. +// +// n, err := fsCall("write", fd, buf, 0, len(b), nil) +type jsfsWrite struct{} + +// invoke implements jsFn.invoke +func (*jsfsWrite) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + fd := toUint32(args[0]) + buf, ok := args[1].(*byteArray) + if !ok { + return nil, fmt.Errorf("arg[1] is %v not a []byte", args[1]) + } + offset := toUint32(args[2]) + byteCount := toUint32(args[3]) + fOffset := args[4] // nil unless Pread + callback := args[5].(funcWrapper) + + if byteCount > 0 { // empty is possible on EOF + n, err := syscallWrite(ctx, mod, fd, fOffset, buf.slice[offset:offset+byteCount]) + return callback.invoke(ctx, mod, refJsfs, err, n) // note: error first + } + return callback.invoke(ctx, mod, refJsfs, nil, refValueZero) +} + +// syscallWrite is like syscall.Write +func syscallWrite(ctx context.Context, mod api.Module, fd uint32, offset interface{}, p []byte) (n uint32, err error) { + if writer := fdWriter(ctx, mod, fd); writer == nil { + err = syscall.EBADF + } else if nWritten, e := writer.Write(p); e == nil || e == io.EOF { + // fs_js.go cannot parse io.EOF so coerce it to nil. + // See https://github.com/golang/go/issues/43913 + n = uint32(nWritten) + } else { + err = e + } + return +} + +// jsfsReaddir is used in syscall.Open +// +// dir, err := fsCall("readdir", path) +// dir.Length(), dir.Index(i).String() +type jsfsReaddir struct{} + +// invoke implements jsFn.invoke +func (*jsfsReaddir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + name := args[0].(string) + callback := args[1].(funcWrapper) + + stat, err := syscallReaddir(ctx, mod, name) + return callback.invoke(ctx, mod, refJsfs, err, stat) // note: error first +} + +func syscallReaddir(ctx context.Context, mod api.Module, name string) (*objectArray, error) { + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + fd, err := fsc.OpenFile(ctx, name) + if err != nil { + return nil, err + } + defer fsc.CloseFile(ctx, fd) + + if f, ok := fsc.OpenedFile(ctx, fd); !ok { + return nil, syscall.EBADF + } else if d, ok := f.File.(fs.ReadDirFile); !ok { + return nil, syscall.ENOTDIR + } else if l, err := d.ReadDir(-1); err != nil { + return nil, err + } else { + entries := make([]interface{}, 0, len(l)) + for _, e := range l { + entries = append(entries, e.Name()) + } + return &objectArray{entries}, nil + } +} + +type returnZero struct{} + +// invoke implements jsFn.invoke +func (*returnZero) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + return refValueZero, nil +} + +type returnSliceOfZero struct{} + +// invoke implements jsFn.invoke +func (*returnSliceOfZero) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + return &objectArray{slice: []interface{}{refValueZero}}, nil +} + +type returnArg0 struct{} + +// invoke implements jsFn.invoke +func (*returnArg0) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + return args[0], nil +} + +// cwd for fs.Open syscall.Getcwd in fs_js.go +type cwd struct{} + +// invoke implements jsFn.invoke +func (*cwd) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + return getState(ctx).cwd, nil +} + +// chdir for fs.Open syscall.Chdir in fs_js.go +type chdir struct{} + +// invoke implements jsFn.invoke +func (*chdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + path := args[0].(string) + + // TODO: refactor so that sys has path-based ops, also needed in WASI. + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + if fd, err := fsc.OpenFile(ctx, path); err != nil { + return nil, syscall.ENOENT + } else if f, ok := fsc.OpenedFile(ctx, fd); !ok { + return nil, syscall.ENOENT + } else if s, err := f.File.Stat(); err != nil { + fsc.CloseFile(ctx, fd) + return nil, syscall.ENOENT + } else if !s.IsDir() { + fsc.CloseFile(ctx, fd) + return nil, syscall.ENOTDIR + } else { + getState(ctx).cwd = path + return nil, nil + } +} + +// jsSt is pre-parsed from fs_js.go setStat to avoid thrashing +type jsSt struct { + isDir bool + + dev uint32 + ino uint32 + mode uint32 + nlink uint32 + uid uint32 + gid uint32 + rdev uint32 + size uint32 + blksize uint32 + blocks uint32 + atimeMs uint32 + mtimeMs uint32 + ctimeMs uint32 +} + +// get implements jsGet.get +func (s *jsSt) get(_ context.Context, propertyKey string) interface{} { + switch propertyKey { + case "dev": + return s.dev + case "ino": + return s.ino + case "mode": + return s.mode + case "nlink": + return s.nlink + case "uid": + return s.uid + case "gid": + return s.gid + case "rdev": + return s.rdev + case "size": + return s.size + case "blksize": + return s.blksize + case "blocks": + return s.blocks + case "atimeMs": + return s.atimeMs + case "mtimeMs": + return s.mtimeMs + case "ctimeMs": + return s.ctimeMs + } + panic(fmt.Sprintf("TODO: stat.%s", propertyKey)) +} + +// call implements jsCall.call +func (s *jsSt) call(ctx context.Context, mod api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + if method == "isDirectory" { + return s.isDir, nil + } + panic(fmt.Sprintf("TODO: stat.%s", method)) +} diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go new file mode 100644 index 00000000000..9f9dc4fa1ec --- /dev/null +++ b/internal/gojs/fs_test.go @@ -0,0 +1,49 @@ +package gojs_test + +import ( + _ "embed" + "io/fs" + "log" + "os" + "testing" + "testing/fstest" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/fs/main.go +var fsGo string + +var testFS = fstest.MapFS{ + "empty.txt": {}, + "test.txt": {Data: []byte("animals")}, + "sub": {Mode: fs.ModeDir}, + "sub/test.txt": {Data: []byte("greet sub dir\n")}, +} + +// TestMain ensures fstest works normally +func TestMain(m *testing.M) { + if d, err := fs.Sub(testFS, "sub"); err != nil { + log.Fatalln(err) + } else if err = fstest.TestFS(d, "test.txt"); err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +func Test_fs(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, fsGo, wazero.NewModuleConfig(). + WithFS(testFS)) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `TestFS ok +wd ok +Not a directory +/test.txt ok +test.txt ok +contents: animals +empty: +`, stdout) +} diff --git a/internal/gojs/http.go b/internal/gojs/http.go new file mode 100644 index 00000000000..4c256172062 --- /dev/null +++ b/internal/gojs/http.go @@ -0,0 +1,165 @@ +package gojs + +import ( + "context" + "fmt" + "io" + "net/http" + "net/textproto" + "sort" + + "github.com/tetratelabs/wazero/api" +) + +// headersConstructor = Get("Headers").New() // http.Roundtrip && "fetch" +var headersConstructor = newJsVal(refHttpHeadersConstructor, "Headers") + +type RoundTripperKey struct{} + +func getRoundTripper(ctx context.Context) http.RoundTripper { + if rt, ok := ctx.Value(RoundTripperKey{}).(http.RoundTripper); ok { + return rt + } + return nil +} + +// fetch is used to implement http.RoundTripper +// +// Reference in roundtrip_js.go init +// +// jsFetchMissing = js.Global().Get("fetch").IsUndefined() +// +// In http.Transport RoundTrip, this returns a promise +// +// fetchPromise := js.Global().Call("fetch", req.URL.String(), opt) +type fetch struct{} + +// invoke implements jsFn.invoke +func (*fetch) invoke(ctx context.Context, _ api.Module, args ...interface{}) (interface{}, error) { + rt := getRoundTripper(ctx) + if rt == nil { + panic("unexpected to reach here without roundtripper as property is nil checked") + } + url := args[0].(string) + properties := args[1].(*object).properties + req, err := http.NewRequestWithContext(ctx, properties["method"].(string), url, nil) + if err != nil { + return nil, err + } + // TODO: headers properties[headers] + v := &fetchPromise{rt: rt, req: req} + return v, nil +} + +type fetchPromise struct { + rt http.RoundTripper + req *http.Request +} + +// call implements jsCall.call +func (p *fetchPromise) call(ctx context.Context, mod api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + if method == "then" { + if res, err := p.rt.RoundTrip(p.req); err != nil { + failure := args[1].(funcWrapper) + // HTTP is at the GOOS=js abstraction, so we can return any error. + return failure.invoke(ctx, mod, this, err) + } else { + success := args[0].(funcWrapper) + return success.invoke(ctx, mod, this, &fetchResult{res: res}) + } + } + panic(fmt.Sprintf("TODO: fetchPromise.%s", method)) +} + +type fetchResult struct { + res *http.Response +} + +// get implements jsGet.get +func (s *fetchResult) get(ctx context.Context, propertyKey string) interface{} { + switch propertyKey { + case "headers": + names := make([]string, 0, len(s.res.Header)) + for k := range s.res.Header { + names = append(names, k) + } + // Sort names for consistent iteration + sort.Strings(names) + h := &headers{names: names, headers: s.res.Header} + return h + case "body": + // return undefined as arrayPromise is more complicated than an array. + return undefined + case "status": + return uint32(s.res.StatusCode) + } + panic(fmt.Sprintf("TODO: get fetchResult.%s", propertyKey)) +} + +// call implements jsCall.call +func (s *fetchResult) call(ctx context.Context, _ api.Module, this ref, method string, _ ...interface{}) (interface{}, error) { + switch method { + case "arrayBuffer": + v := &arrayPromise{reader: s.res.Body} + return v, nil + } + panic(fmt.Sprintf("TODO: call fetchResult.%s", method)) +} + +type headers struct { + headers http.Header + names []string + i int +} + +// get implements jsGet.get +func (h *headers) get(_ context.Context, propertyKey string) interface{} { + switch propertyKey { + case "done": + return h.i == len(h.names) + case "value": + name := h.names[h.i] + value := h.headers.Get(name) + h.i++ + return &objectArray{[]interface{}{name, value}} + } + panic(fmt.Sprintf("TODO: get headers.%s", propertyKey)) +} + +// call implements jsCall.call +func (h *headers) call(_ context.Context, _ api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + switch method { + case "entries": + // Sort names for consistent iteration + sort.Strings(h.names) + return h, nil + case "next": + return h, nil + case "append": + name := textproto.CanonicalMIMEHeaderKey(args[0].(string)) + value := args[1].(string) + h.names = append(h.names, name) + h.headers.Add(name, value) + return nil, nil + } + panic(fmt.Sprintf("TODO: call headers.%s", method)) +} + +type arrayPromise struct { + reader io.ReadCloser +} + +// call implements jsCall.call +func (p *arrayPromise) call(ctx context.Context, mod api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + switch method { + case "then": + defer p.reader.Close() + if b, err := io.ReadAll(p.reader); err != nil { + // HTTP is at the GOOS=js abstraction, so we can return any error. + return args[1].(funcWrapper).invoke(ctx, mod, this, err) + } else { + return args[0].(funcWrapper).invoke(ctx, mod, this, &byteArray{b}) + } + } + panic(fmt.Sprintf("TODO: call arrayPromise.%s", method)) +} diff --git a/internal/gojs/http_test.go b/internal/gojs/http_test.go new file mode 100644 index 00000000000..bed5a876e40 --- /dev/null +++ b/internal/gojs/http_test.go @@ -0,0 +1,53 @@ +package gojs_test + +import ( + _ "embed" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental/gojs" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/http/main.go +var httpGo string + +type roundTripperFunc func(r *http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func Test_http(t *testing.T) { + ctx := gojs.WithRoundTripper(testCtx, roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/error" { + return nil, errors.New("error") + } + if req.Body != nil { + require.Equal(t, http.MethodPost, req.Method) + bytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "ice cream", string(bytes)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Header: http.Header{"Custom": {"1"}}, + Body: io.NopCloser(strings.NewReader("abcdef")), + ContentLength: 6, + }, nil + })) + + stdout, stderr, err := compileAndRunJsWasm(ctx, t, httpGo, wazero.NewModuleConfig().WithArgs("http://host")) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `Get "http://host/error": net/http: fetch() failed: error +1 +abcdef +`, stdout) +} diff --git a/internal/gojs/js.go b/internal/gojs/js.go new file mode 100644 index 00000000000..e7444b825f4 --- /dev/null +++ b/internal/gojs/js.go @@ -0,0 +1,132 @@ +package gojs + +import ( + "context" + "fmt" + + "github.com/tetratelabs/wazero/api" +) + +// ref is used to identify a JavaScript value, since the value itself can not be passed to WebAssembly. +// +// The JavaScript value "undefined" is represented by the value 0. +// A JavaScript number (64-bit float, except 0 and NaN) is represented by its IEEE 754 binary representation. +// All other values are represented as an IEEE 754 binary representation of NaN with bits 0-31 used as +// an ID and bits 32-34 used to differentiate between string, symbol, function and object. +type ref uint64 + +const ( + parameterSp = "sp" + functionDebug = "debug" +) + +// jsFn is a jsCall.call function, configured via jsVal.addFunction. +type jsFn interface { + invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) +} + +type jsGet interface { + get(ctx context.Context, propertyKey string) interface{} +} + +// jsCall allows calling a method/function by name. +type jsCall interface { + call(ctx context.Context, mod api.Module, this ref, method string, args ...interface{}) (interface{}, error) +} + +// nanHead are the upper 32 bits of a ref which are set if the value is not encoded as an IEEE 754 number (see above). +const nanHead = 0x7FF80000 + +type typeFlag byte + +const ( + // the type flags need to be in sync with gojs.js + typeFlagNone typeFlag = iota + typeFlagObject + typeFlagString + typeFlagSymbol // nolint + typeFlagFunction +) + +func valueRef(id uint32, typeFlag typeFlag) ref { + return (nanHead|ref(typeFlag))<<32 | ref(id) +} + +func newJsVal(ref ref, name string) *jsVal { + return &jsVal{ref: ref, name: name, properties: map[string]interface{}{}, functions: map[string]jsFn{}} +} + +// jsVal corresponds to a generic js.Value in go, when `GOOS=js`. +type jsVal struct { + // ref when is the constant reference used for built-in values, such as + // objectConstructor. + ref + name string + properties map[string]interface{} + functions map[string]jsFn +} + +func (v *jsVal) addProperties(properties map[string]interface{}) *jsVal { + for k, val := range properties { + v.properties[k] = val + } + return v +} + +func (v *jsVal) addFunction(method string, fn jsFn) *jsVal { + v.functions[method] = fn + // If fn returns an error, js.Call does a type lookup to verify it is a + // function. + // See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L389 + v.properties[method] = fn + return v +} + +// get implements jsGet.get +func (v *jsVal) get(_ context.Context, propertyKey string) interface{} { + if v, ok := v.properties[propertyKey]; ok { + return v + } + panic(fmt.Sprintf("TODO: get %s.%s", v.name, propertyKey)) +} + +// call implements jsCall.call +func (v *jsVal) call(ctx context.Context, mod api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + if v, ok := v.functions[method]; ok { + return v.invoke(ctx, mod, args...) + } + panic(fmt.Sprintf("TODO: call %s.%s", v.name, method)) +} + +// byteArray is a result of uint8ArrayConstructor which temporarily stores +// binary data outside linear memory. +// +// Note: This is a wrapper because a slice is not hashable. +type byteArray struct { + slice []byte +} + +// get implements jsGet.get +func (a *byteArray) get(_ context.Context, propertyKey string) interface{} { + switch propertyKey { + case "byteLength": + return uint32(len(a.slice)) + } + panic(fmt.Sprintf("TODO: get byteArray.%s", propertyKey)) +} + +// objectArray is a result of arrayConstructor typically used to pass +// indexed arguments. +// +// Note: This is a wrapper because a slice is not hashable. +type objectArray struct { + slice []interface{} +} + +// object is a result of objectConstructor typically used to pass named +// arguments. +// +// Note: This is a wrapper because a map is not hashable. +type object struct { + properties map[string]interface{} +} diff --git a/internal/gojs/misc_test.go b/internal/gojs/misc_test.go new file mode 100644 index 00000000000..a3a22e04694 --- /dev/null +++ b/internal/gojs/misc_test.go @@ -0,0 +1,46 @@ +package gojs_test + +import ( + _ "embed" + "strings" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/goroutine/main.go +var goroutineGo string + +func Test_goroutine(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, goroutineGo, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `producer +consumer +`, stdout) +} + +//go:embed testdata/mem/main.go +var memGo string + +func Test_mem(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, memGo, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Zero(t, stdout) +} + +//go:embed testdata/stdio/main.go +var stdioGo string + +func Test_stdio(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, stdioGo, wazero.NewModuleConfig(). + WithStdin(strings.NewReader("stdin\n"))) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Equal(t, "println stdin\nStderr.Write", stderr) + require.Equal(t, "Stdout.Write", stdout) +} diff --git a/internal/gojs/runtime.go b/internal/gojs/runtime.go new file mode 100644 index 00000000000..b1476a7ae7a --- /dev/null +++ b/internal/gojs/runtime.go @@ -0,0 +1,132 @@ +package gojs + +import ( + "context" + "fmt" + "io" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/gojs/spfunc" + "github.com/tetratelabs/wazero/internal/wasm" +) + +const ( + functionWasmExit = "runtime.wasmExit" + functionWasmWrite = "runtime.wasmWrite" + functionResetMemoryDataView = "runtime.resetMemoryDataView" + functionNanotime1 = "runtime.nanotime1" + functionWalltime = "runtime.walltime" + functionScheduleTimeoutEvent = "runtime.scheduleTimeoutEvent" // TODO: trigger usage + functionClearTimeoutEvent = "runtime.clearTimeoutEvent" // TODO: trigger usage + functionGetRandomData = "runtime.getRandomData" +) + +// WasmExit implements runtime.wasmExit which supports runtime.exit. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.go#L28 +var WasmExit = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionWasmExit, functionWasmExit, + []string{"code"}, + func(ctx context.Context, mod api.Module, code int32) { + getState(ctx).clear() + _ = mod.CloseWithExitCode(ctx, uint32(code)) // TODO: should ours be signed bit (like -1 == 255)? + }, +)) + +// WasmWrite implements runtime.wasmWrite which supports runtime.write and +// runtime.writeErr. This implements `println`. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/os_js.go#L29 +var WasmWrite = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionWasmWrite, functionWasmWrite, + []string{"code"}, + func(ctx context.Context, mod api.Module, fd, p, n uint32) { + var writer io.Writer + + switch fd { + case 1: + writer = mod.(*wasm.CallContext).Sys.Stdout() + case 2: + writer = mod.(*wasm.CallContext).Sys.Stderr() + default: + // Keep things simple by expecting nothing past 2 + panic(fmt.Errorf("unexpected fd %d", fd)) + } + + if _, err := writer.Write(mustRead(ctx, mod.Memory(), "p", p, n)); err != nil { + panic(fmt.Errorf("error writing p: %w", err)) + } + }, +)) + +// ResetMemoryDataView signals wasm.OpcodeMemoryGrow happened, indicating any +// cached view of memory should be reset. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/mem_js.go#L82 +var ResetMemoryDataView = &wasm.HostFunc{ + ExportNames: []string{functionResetMemoryDataView}, + Name: functionResetMemoryDataView, + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{parameterSp}, + // TODO: Compiler-based memory.grow callbacks are ignored until we have a generic solution #601 + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeEnd}}, +} + +// Nanotime1 implements runtime.nanotime which supports time.Since. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.s#L184 +var Nanotime1 = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionNanotime1, functionNanotime1, + []string{}, + func(ctx context.Context, mod api.Module) int64 { + return mod.(*wasm.CallContext).Sys.Nanotime(ctx) + })) + +// Walltime implements runtime.walltime which supports time.Now. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.s#L188 +var Walltime = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionWalltime, functionWalltime, + []string{}, + func(ctx context.Context, mod api.Module) (sec int64, nsec int32) { + return mod.(*wasm.CallContext).Sys.Walltime(ctx) + })) + +// ScheduleTimeoutEvent implements runtime.scheduleTimeoutEvent which supports +// runtime.notetsleepg used by runtime.signal_recv. +// +// Unlike other most functions prefixed by "runtime.", this both launches a +// goroutine and invokes code compiled into wasm "resume". +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.s#L192 +var ScheduleTimeoutEvent = stubFunction(functionScheduleTimeoutEvent) + +// ^^ stubbed because signal handling is not implemented in GOOS=js + +// ClearTimeoutEvent implements runtime.clearTimeoutEvent which supports +// runtime.notetsleepg used by runtime.signal_recv. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.s#L196 +var ClearTimeoutEvent = stubFunction(functionClearTimeoutEvent) + +// ^^ stubbed because signal handling is not implemented in GOOS=js + +// GetRandomData implements runtime.getRandomData, which initializes the seed +// for runtime.fastrand. +// +// See https://github.com/golang/go/blob/go1.19/src/runtime/sys_wasm.s#L200 +var GetRandomData = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionGetRandomData, functionGetRandomData, + []string{"buf", "bufLen"}, + func(ctx context.Context, mod api.Module, buf, bufLen uint32) { + randSource := mod.(*wasm.CallContext).Sys.RandSource() + + r := mustRead(ctx, mod.Memory(), "r", buf, bufLen) + + if n, err := randSource.Read(r); err != nil { + panic(fmt.Errorf("RandSource.Read(r /* len=%d */) failed: %w", bufLen, err)) + } else if uint32(n) != bufLen { + panic(fmt.Errorf("RandSource.Read(r /* len=%d */) read %d bytes", bufLen, n)) + } + }, +)) diff --git a/internal/gojs/spfunc/spfunc.go b/internal/gojs/spfunc/spfunc.go new file mode 100644 index 00000000000..02f20f9c037 --- /dev/null +++ b/internal/gojs/spfunc/spfunc.go @@ -0,0 +1,186 @@ +package spfunc + +import ( + "errors" + "fmt" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/leb128" + "github.com/tetratelabs/wazero/internal/wasm" +) + +const debugMode = false + +func MustCallFromSP(expectSP bool, proxied *wasm.HostFunc) *wasm.ProxyFunc { + if ret, err := callFromSP(expectSP, proxied); err != nil { + panic(err) + } else { + return ret + } +} + +// callFromSP generates code to call a function with the provided signature. +// The returned function has a single api.ValueTypeI32 parameter of SP. Each +// parameter is read at 8 byte offsets after that, and each result is written +// at 8 byte offsets after the parameters. +// +// # Parameters +// +// - expectSP: true if a constructor or method invocation. The last result is +// an updated SP value (i32), which affects the result memory offsets. +func callFromSP(expectSP bool, proxied *wasm.HostFunc) (*wasm.ProxyFunc, error) { + params := proxied.ParamTypes + results := proxied.ResultTypes + if (8+len(params)+len(results))*8 > 255 { + return nil, errors.New("TODO: memory offset larger than one byte") + } + + if debugMode { + fmt.Printf("(func $%s.proxy (param $%s %s)", proxied.Name, "sp", wasm.ValueTypeName(wasm.ValueTypeI32)) + } + + var localTypes []api.ValueType + + resultSpOffset := 8 + len(params)*8 + resultSPIndex := byte(0) + if len(results) > 0 { + if debugMode { + fmt.Printf(" (local %s %s)", wasm.ValueTypeName(wasm.ValueTypeI32), wasm.ValueTypeName(wasm.ValueTypeI64)) + } + localTypes = append(localTypes, api.ValueTypeI32, api.ValueTypeI64) + if expectSP { + if debugMode { + fmt.Printf(" (local %s)", wasm.ValueTypeName(wasm.ValueTypeI32)) + } + resultSPIndex = 3 + resultSpOffset += 8 + localTypes = append(localTypes, api.ValueTypeI32) + } + } + + // Load all parameters onto the stack. + var code []byte + for i, t := range params { + if debugMode { + fmt.Printf("\n;; param[%d]=%s\n", i, wasm.ValueTypeName(t)) + } + + // First, add the memory offset to load onto the stack. + offset := 8 + int(i*8) + code = compileAddOffsetToSP(code, 0, offset) + + // Next, load stack parameter $i from memory at that offset. + switch t { + case api.ValueTypeI32: + if debugMode { + fmt.Println(wasm.OpcodeI32LoadName) + } + code = append(code, wasm.OpcodeI32Load, 0x2, 0x0) // alignment=2 (natural alignment) staticOffset=0 + case api.ValueTypeI64: + if debugMode { + fmt.Println(wasm.OpcodeI64LoadName) + } + code = append(code, wasm.OpcodeI64Load, 0x3, 0x0) // alignment=3 (natural alignment) staticOffset=0 + default: + panic(errors.New("TODO: param " + api.ValueTypeName(t))) + } + } + + // Now that all parameters are on the stack, call the function + callFuncPos := len(code) + 1 + if debugMode { + fmt.Printf("\n%s 0\n", wasm.OpcodeCallName) + } + code = append(code, wasm.OpcodeCall, 0) + + // The stack may now have results. Iterate backwards + i := len(results) - 1 + if expectSP { + if debugMode { + fmt.Printf("%s %d ;; refresh SP\n", wasm.OpcodeLocalSetName, resultSPIndex) + } + code = append(code, wasm.OpcodeLocalSet, resultSPIndex) + i-- + } + for ; i >= 0; i-- { + // pop current result from stack + t := results[i] + if debugMode { + fmt.Printf("\n;; result[%d]=%s\n", i, wasm.ValueTypeName(t)) + } + + var typeIndex byte + switch t { + case api.ValueTypeI32: + typeIndex = 1 + case api.ValueTypeI64: + typeIndex = 2 + default: + panic(errors.New("TODO: result " + api.ValueTypeName(t))) + } + + if debugMode { + fmt.Printf("%s %d ;; next result\n", wasm.OpcodeLocalSetName, typeIndex) + } + code = append(code, wasm.OpcodeLocalSet, typeIndex) + + offset := resultSpOffset + i*8 + code = compileAddOffsetToSP(code, resultSPIndex, offset) + + if debugMode { + fmt.Printf("%s %d ;; store next result\n", wasm.OpcodeLocalGetName, typeIndex) + } + code = append(code, wasm.OpcodeLocalGet, typeIndex) + + switch t { + case api.ValueTypeI32: + if debugMode { + fmt.Println(wasm.OpcodeI32StoreName) + } + code = append(code, wasm.OpcodeI32Store, 0x2, 0x0) // alignment=2 (natural alignment) staticOffset=0 + case api.ValueTypeI64: + if debugMode { + fmt.Println(wasm.OpcodeI64StoreName) + } + code = append(code, wasm.OpcodeI64Store, 0x3, 0x0) // alignment=3 (natural alignment) staticOffset=0 + default: + panic(errors.New("TODO: result " + api.ValueTypeName(t))) + } + + } + if debugMode { + fmt.Println("\n)") + } + code = append(code, wasm.OpcodeEnd) + return &wasm.ProxyFunc{ + Proxy: &wasm.HostFunc{ + ExportNames: proxied.ExportNames, + Name: proxied.Name + ".proxy", + ParamTypes: []api.ValueType{api.ValueTypeI32}, + ParamNames: []string{"sp"}, + Code: &wasm.Code{IsHostFunction: true, LocalTypes: localTypes, Body: code}, + }, + Proxied: &wasm.HostFunc{ + Name: proxied.Name, + ParamTypes: proxied.ParamTypes, + ResultTypes: proxied.ResultTypes, + ParamNames: proxied.ParamNames, + Code: proxied.Code, + }, + CallBodyPos: callFuncPos, + }, nil +} + +func compileAddOffsetToSP(code []byte, spLocalIndex byte, offset int) []byte { + if debugMode { + fmt.Printf("%s %d ;; SP\n", wasm.OpcodeLocalGetName, spLocalIndex) + fmt.Printf("%s %d ;; offset\n", wasm.OpcodeI32ConstName, offset) + fmt.Printf("%s\n", wasm.OpcodeI32AddName) + } + code = append(code, wasm.OpcodeLocalGet, spLocalIndex) + // See /RATIONALE.md we can't tell the signed interpretation of a constant, so default to signed. + code = append(code, wasm.OpcodeI32Const) + code = append(code, leb128.EncodeInt32(int32(offset))...) + code = append(code, wasm.OpcodeI32Add) + return code +} diff --git a/internal/gojs/spfunc/spfunc_test.go b/internal/gojs/spfunc/spfunc_test.go new file mode 100644 index 00000000000..b3954b41c78 --- /dev/null +++ b/internal/gojs/spfunc/spfunc_test.go @@ -0,0 +1,240 @@ +package spfunc + +import ( + "context" + _ "embed" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/testing/require" + "github.com/tetratelabs/wazero/internal/wasm" + "github.com/tetratelabs/wazero/internal/wasm/binary" +) + +// testCtx is an arbitrary, non-default context. Non-nil also prevents linter errors. +var testCtx = context.WithValue(context.Background(), struct{}{}, "arbitrary") + +func Test_callFromSP(t *testing.T) { + tests := []struct { + name string + expectSP bool + inputMem, outputMem []byte + proxied *wasm.HostFunc + expected *wasm.ProxyFunc + expectedErr string + }{ + { + name: "i32_v", + proxied: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"x"}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeEnd}}, + }, + expected: &wasm.ProxyFunc{ + Proxy: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n.proxy", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"sp"}, + Code: &wasm.Code{ + IsHostFunction: true, + LocalTypes: nil, // because there are no results + Body: []byte{ + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 8, wasm.OpcodeI32Add, + wasm.OpcodeI32Load, 0x2, 0x0, + wasm.OpcodeCall, 0, + wasm.OpcodeEnd, + }, + }, + }, + Proxied: &wasm.HostFunc{ + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"x"}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeEnd}}, + }, + CallBodyPos: 9, + }, + }, + { + name: "i32_i32", + proxied: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"x"}, + ResultTypes: []wasm.ValueType{wasm.ValueTypeI32}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeI32Const, 1, wasm.OpcodeEnd}}, + }, + expected: &wasm.ProxyFunc{ + Proxy: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n.proxy", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"sp"}, + Code: &wasm.Code{ + IsHostFunction: true, + LocalTypes: []api.ValueType{api.ValueTypeI32, api.ValueTypeI64}, + Body: []byte{ + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 8, wasm.OpcodeI32Add, + wasm.OpcodeI32Load, 0x2, 0x0, + wasm.OpcodeCall, 0, + wasm.OpcodeLocalSet, 1, + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 16, wasm.OpcodeI32Add, + wasm.OpcodeLocalGet, 1, + wasm.OpcodeI32Store, 0x2, 0x0, + wasm.OpcodeEnd, + }, + }, + }, + Proxied: &wasm.HostFunc{ + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"x"}, + ResultTypes: []wasm.ValueType{wasm.ValueTypeI32}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeI32Const, 1, wasm.OpcodeEnd}}, + }, + CallBodyPos: 9, + }, + }, + { + name: "i32i32_i64", + proxied: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32, wasm.ValueTypeI32}, + ParamNames: []string{"x", "y"}, + ResultTypes: []wasm.ValueType{wasm.ValueTypeI64}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeI64Const, 1, wasm.OpcodeEnd}}, + }, + expected: &wasm.ProxyFunc{ + Proxy: &wasm.HostFunc{ + ExportNames: []string{"ex"}, + Name: "n.proxy", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{"sp"}, + Code: &wasm.Code{ + IsHostFunction: true, + LocalTypes: []api.ValueType{api.ValueTypeI32, api.ValueTypeI64}, + Body: []byte{ + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 8, wasm.OpcodeI32Add, + wasm.OpcodeI32Load, 0x2, 0x0, + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 16, wasm.OpcodeI32Add, + wasm.OpcodeI32Load, 0x2, 0x0, + wasm.OpcodeCall, 0, + wasm.OpcodeLocalSet, 2, + wasm.OpcodeLocalGet, 0, wasm.OpcodeI32Const, 24, wasm.OpcodeI32Add, + wasm.OpcodeLocalGet, 2, + wasm.OpcodeI64Store, 0x3, 0x0, + wasm.OpcodeEnd, + }, + }, + }, + Proxied: &wasm.HostFunc{ + Name: "n", + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32, wasm.ValueTypeI32}, + ParamNames: []string{"x", "y"}, + ResultTypes: []wasm.ValueType{wasm.ValueTypeI64}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeI64Const, 1, wasm.OpcodeEnd}}, + }, + CallBodyPos: 17, + }, + }, + } + + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + proxy, err := callFromSP(tc.expectSP, tc.proxied) + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } else { + require.Equal(t, tc.expected, proxy) + } + }) + } +} + +var spMem = []byte{ + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, + 2, 0, 0, 0, 0, 0, 0, 0, + 3, 0, 0, 0, 0, 0, 0, 0, + 4, 0, 0, 0, 0, 0, 0, 0, + 5, 0, 0, 0, 0, 0, 0, 0, + 6, 0, 0, 0, 0, 0, 0, 0, + 7, 0, 0, 0, 0, 0, 0, 0, + 8, 0, 0, 0, 0, 0, 0, 0, + 9, 0, 0, 0, 0, 0, 0, 0, + 10, 0, 0, 0, 0, 0, 0, 0, +} + +func i64i32i32i32i32_i64i32_withSP(vRef uint64, mAddr, mLen, argsArray, argsLen uint32) (xRef uint64, ok uint32, sp uint32) { + if vRef != 1 { + panic("vRef") + } + if mAddr != 2 { + panic("mAddr") + } + if mLen != 3 { + panic("mLen") + } + if argsArray != 4 { + panic("argsArray") + } + if argsLen != 5 { + panic("argsLen") + } + return 10, 20, 8 +} + +func TestMustCallFromSP(t *testing.T) { + r := wazero.NewRuntimeWithConfig(testCtx, wazero.NewRuntimeConfigInterpreter().WithWasmCore2()) + defer r.Close(testCtx) + + funcName := "i64i32i32i32i32_i64i32_withSP" + im, err := r.NewModuleBuilder("go"). + ExportFunction(funcName, MustCallFromSP(true, wasm.NewGoFunc( + funcName, funcName, + []string{"v", "mAddr", "mLen", "argsArray", "argsLen"}, + i64i32i32i32i32_i64i32_withSP))). + Instantiate(testCtx, r) + require.NoError(t, err) + + callDef := im.ExportedFunction(funcName).Definition() + + bin := binary.EncodeModule(&wasm.Module{ + TypeSection: []*wasm.FunctionType{{ + Params: callDef.ParamTypes(), + Results: callDef.ResultTypes(), + }}, + ImportSection: []*wasm.Import{{Module: "go", Name: funcName}}, + MemorySection: &wasm.Memory{Min: 1, Cap: 1, Max: 1}, + FunctionSection: []wasm.Index{0}, + CodeSection: []*wasm.Code{ + {Body: []byte{wasm.OpcodeLocalGet, 0, wasm.OpcodeCall, 0, wasm.OpcodeEnd}}, + }, + ExportSection: []*wasm.Export{{Name: funcName, Index: 1}}, + }) + require.NoError(t, err) + + mod, err := r.InstantiateModuleFromBinary(testCtx, bin) + require.NoError(t, err) + + memView, ok := mod.Memory().Read(testCtx, 0, uint32(len(spMem))) + require.True(t, ok) + copy(memView, spMem) + + _, err = mod.ExportedFunction(funcName).Call(testCtx, 0) // SP + require.NoError(t, err) + require.Equal(t, []byte{ + 7, 0, 0, 0, 0, 0, 0, 0, // 7 left alone as SP was refreshed to 8 + 10, 0, 0, 0, 0, 0, 0, 0, + 20, 0, 0, 0, 0, 0, 0, 0, + 10, 0, 0, 0, 0, 0, 0, 0, + }, memView[56:]) +} diff --git a/internal/gojs/state.go b/internal/gojs/state.go new file mode 100644 index 00000000000..3a9db41d6de --- /dev/null +++ b/internal/gojs/state.go @@ -0,0 +1,271 @@ +package gojs + +import ( + "context" + "fmt" + "math" + + "github.com/tetratelabs/wazero/api" +) + +func WithState(ctx context.Context) context.Context { + s := &state{ + values: &values{ids: map[interface{}]uint32{}}, + valueGlobal: newJsGlobal(getRoundTripper(ctx)), + cwd: "/", + } + return context.WithValue(ctx, stateKey{}, s) +} + +// stateKey is a context.Context Value key. The value must be a state pointer. +type stateKey struct{} + +func getState(ctx context.Context) *state { + return ctx.Value(stateKey{}).(*state) +} + +type event struct { + // id is the funcWrapper.id + id uint32 + this ref + args *objectArray + result interface{} +} + +// get implements jsGet.get +func (e *event) get(_ context.Context, propertyKey string) interface{} { + switch propertyKey { + case "id": + return e.id + case "this": // ex fs + return e.this + case "args": + return e.args + } + panic(fmt.Sprintf("TODO: event.%s", propertyKey)) +} + +var undefined = struct{ name string }{name: "undefined"} +var NaN = math.NaN() + +// loadValue reads up to 8 bytes at the memory offset `addr` to return the +// value written by storeValue. +// +// See https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L122-L133 +func loadValue(ctx context.Context, ref ref) interface{} { // nolint + switch ref { + case 0: + return undefined + case refValueNaN: + return NaN + case refValueZero: + return float64(0) + case refValueNull: + return nil + case refValueTrue: + return true + case refValueFalse: + return false + case refValueGlobal: + return getState(ctx).valueGlobal + case refJsGo: + return getState(ctx) + case refObjectConstructor: + return objectConstructor + case refArrayConstructor: + return arrayConstructor + case refJsProcess: + return jsProcess + case refJsfs: + return jsfs + case refJsfsConstants: + return jsfsConstants + case refUint8ArrayConstructor: + return uint8ArrayConstructor + case refJsCrypto: + return jsCrypto + case refJsDateConstructor: + return jsDateConstructor + case refJsDate: + return jsDate + case refHttpHeadersConstructor: + return headersConstructor + default: + if (ref>>32)&nanHead != nanHead { // numbers are passed through as a ref + return api.DecodeF64(uint64(ref)) + } + return getState(ctx).values.get(uint32(ref)) + } +} + +// loadArgs returns a slice of `len` values at the memory offset `addr`. The +// returned slice is temporary, not stored in state.values. +// +// See https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L191-L199 +func loadArgs(ctx context.Context, mod api.Module, sliceAddr, sliceLen uint32) []interface{} { // nolint + result := make([]interface{}, 0, sliceLen) + for i := uint32(0); i < sliceLen; i++ { // nolint + iRef := mustReadUint64Le(ctx, mod.Memory(), "iRef", sliceAddr+i*8) + result = append(result, loadValue(ctx, ref(iRef))) + } + return result +} + +// storeRef stores a value prior to returning to wasm from a host function. +// This returns 8 bytes to represent either the value or a reference to it. +// Any side effects besides memory must be cleaned up on wasmExit. +// +// See https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L135-L183 +func storeRef(ctx context.Context, v interface{}) uint64 { // nolint + // allow-list because we control all implementations + if v == undefined { + return uint64(refValueUndefined) + } else if v == nil { + return uint64(refValueNull) + } else if r, ok := v.(ref); ok { + return uint64(r) + } else if b, ok := v.(bool); ok { + if b { + return uint64(refValueTrue) + } else { + return uint64(refValueFalse) + } + } else if c, ok := v.(*jsVal); ok { + return uint64(c.ref) // already stored + } else if _, ok := v.(*event); ok { + id := getState(ctx).values.increment(v) + return uint64(valueRef(id, typeFlagFunction)) + } else if _, ok := v.(funcWrapper); ok { + id := getState(ctx).values.increment(v) + return uint64(valueRef(id, typeFlagFunction)) + } else if _, ok := v.(jsFn); ok { + id := getState(ctx).values.increment(v) + return uint64(valueRef(id, typeFlagFunction)) + } else if _, ok := v.(string); ok { + id := getState(ctx).values.increment(v) + return uint64(valueRef(id, typeFlagString)) + } else if ui, ok := v.(uint32); ok { + if ui == 0 { + return uint64(refValueZero) + } + return api.EncodeF64(float64(ui)) // numbers are encoded as float and passed through as a ref + } else if u, ok := v.(uint64); ok { + return u // float is already encoded as a uint64, doesn't need to be stored. + } else if f64, ok := v.(float64); ok { + if f64 == 0 { + return uint64(refValueZero) + } + return api.EncodeF64(f64) + } + id := getState(ctx).values.increment(v) + return uint64(valueRef(id, typeFlagObject)) +} + +type values struct { + // Below is needed to avoid exhausting the ID namespace finalizeRef reclaims + // See https://go-review.googlesource.com/c/go/+/203600 + + values []interface{} // values indexed by ID, nil + goRefCounts []uint32 // recount pair-indexed with values + ids map[interface{}]uint32 // live values + idPool []uint32 // reclaimed IDs (values[i] = nil, goRefCounts[i] nil +} + +func (j *values) get(id uint32) interface{} { + index := id - nextID + if index >= uint32(len(j.values)) { + panic(fmt.Errorf("id %d is out of range %d", id, len(j.values))) + } + return j.values[index] +} + +func (j *values) increment(v interface{}) uint32 { + id, ok := j.ids[v] + if !ok { + if len(j.idPool) == 0 { + id, j.values, j.goRefCounts = uint32(len(j.values)), append(j.values, v), append(j.goRefCounts, 0) + } else { + id, j.idPool = j.idPool[len(j.idPool)-1], j.idPool[:len(j.idPool)-1] + j.values[id], j.goRefCounts[id] = v, 0 + } + j.ids[v] = id + } + j.goRefCounts[id]++ + return id + nextID +} + +func (j *values) decrement(id uint32) { + id -= nextID + j.goRefCounts[id]-- + if j.goRefCounts[id] == 0 { + j.values[id] = nil + j.idPool = append(j.idPool, id) + } +} + +// state holds state used by the "go" imports used by gojs. +// Note: This is module-scoped. +type state struct { + values *values + _pendingEvent *event + + valueGlobal *jsVal + + // cwd is initially "/" + cwd string +} + +// get implements jsGet.get +func (s *state) get(_ context.Context, propertyKey string) interface{} { + switch propertyKey { + case "_pendingEvent": + return s._pendingEvent + } + panic(fmt.Sprintf("TODO: state.%s", propertyKey)) +} + +// call implements jsCall.call +func (s *state) call(_ context.Context, _ api.Module, this ref, method string, args ...interface{}) (interface{}, error) { + switch method { + case "_makeFuncWrapper": + return funcWrapper(args[0].(float64)), nil + } + panic(fmt.Sprintf("TODO: state.%s", method)) +} + +func (s *state) clear() { + s.values.values = s.values.values[:0] + s.values.goRefCounts = s.values.goRefCounts[:0] + for k := range s.values.ids { + delete(s.values.ids, k) + } + s.values.idPool = s.values.idPool[:0] + s._pendingEvent = nil +} + +func toInt64(arg interface{}) int64 { + if arg == refValueZero || arg == undefined { + return 0 + } else if u, ok := arg.(int64); ok { + return u + } + return int64(arg.(float64)) +} + +func toUint32(arg interface{}) uint32 { + if arg == refValueZero || arg == undefined { + return 0 + } else if u, ok := arg.(uint32); ok { + return u + } + return uint32(arg.(float64)) +} + +// valueString returns the string form of JavaScript string, boolean and number types. +func valueString(v interface{}) string { // nolint + if s, ok := v.(string); ok { + return s + } else { + return fmt.Sprintf("%v", v) + } +} diff --git a/internal/gojs/syscall.go b/internal/gojs/syscall.go new file mode 100644 index 00000000000..880835daceb --- /dev/null +++ b/internal/gojs/syscall.go @@ -0,0 +1,489 @@ +package gojs + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "syscall" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/gojs/spfunc" + "github.com/tetratelabs/wazero/internal/wasm" + "github.com/tetratelabs/wazero/sys" +) + +const ( + functionFinalizeRef = "syscall/js.finalizeRef" + functionStringVal = "syscall/js.stringVal" + functionValueGet = "syscall/js.valueGet" + functionValueSet = "syscall/js.valueSet" + functionValueDelete = "syscall/js.valueDelete" // stubbed + functionValueIndex = "syscall/js.valueIndex" + functionValueSetIndex = "syscall/js.valueSetIndex" // stubbed + functionValueCall = "syscall/js.valueCall" + functionValueInvoke = "syscall/js.valueInvoke" // stubbed + functionValueNew = "syscall/js.valueNew" + functionValueLength = "syscall/js.valueLength" + functionValuePrepareString = "syscall/js.valuePrepareString" + functionValueLoadString = "syscall/js.valueLoadString" + functionValueInstanceOf = "syscall/js.valueInstanceOf" // stubbed + functionCopyBytesToGo = "syscall/js.copyBytesToGo" + functionCopyBytesToJS = "syscall/js.copyBytesToJS" +) + +// FinalizeRef implements js.finalizeRef, which is used as a +// runtime.SetFinalizer on the given reference. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L61 +var FinalizeRef = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionFinalizeRef, functionFinalizeRef, + []string{"r"}, + func(ctx context.Context, mod api.Module, id uint32) { // 32-bits of the ref are the ID + getState(ctx).values.decrement(id) + }, +)) + +// StringVal implements js.stringVal, which is used to load the string for +// `js.ValueOf(x)`. For example, this is used when setting HTTP headers. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L212 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L305-L308 +var StringVal = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionStringVal, functionStringVal, + []string{"xAddr", "xLen"}, + func(ctx context.Context, mod api.Module, xAddr, xLen uint32) uint64 { + x := string(mustRead(ctx, mod.Memory(), "x", xAddr, xLen)) + return storeRef(ctx, x) + }, +)) + +// ValueGet implements js.valueGet, which is used to load a js.Value property +// by name, ex. `v.Get("address")`. Notably, this is used by js.handleEvent to +// get the pending event. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L295 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L311-L316 +var ValueGet = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValueGet, functionValueGet, + []string{"v", "pAddr", "pLen"}, + func(ctx context.Context, mod api.Module, vRef uint64, pAddr, pLen uint32) uint64 { + p := string(mustRead(ctx, mod.Memory(), "p", pAddr, pLen)) + v := loadValue(ctx, ref(vRef)) + + var result interface{} + if g, ok := v.(jsGet); ok { + result = g.get(ctx, p) + } else if e, ok := v.(error); ok { + switch p { + case "message": // js (GOOS=js) error, can be anything. + result = e.Error() + case "code": // syscall (GOARCH=wasm) error, must match key in mapJSError in fs_js.go + result = mapJSError(e).Error() + default: + panic(fmt.Errorf("TODO: valueGet(v=%v, p=%s)", v, p)) + } + } else { + panic(fmt.Errorf("TODO: valueGet(v=%v, p=%s)", v, p)) + } + + xRef := storeRef(ctx, result) + return xRef + }, +)) + +// ValueSet implements js.valueSet, which is used to store a js.Value property +// by name, ex. `v.Set("address", a)`. Notably, this is used by js.handleEvent +// set the event result. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L309 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L318-L322 +var ValueSet = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValueSet, functionValueSet, + []string{"v", "pAddr", "pLen", "x"}, + func(ctx context.Context, mod api.Module, vRef uint64, pAddr, pLen uint32, xRef uint64) { + v := loadValue(ctx, ref(vRef)) + p := string(mustRead(ctx, mod.Memory(), "p", pAddr, pLen)) + x := loadValue(ctx, ref(xRef)) + if v == getState(ctx) { + switch p { + case "_pendingEvent": + if x == nil { // syscall_js.handleEvent + v.(*state)._pendingEvent = nil + return + } + } + } else if e, ok := v.(*event); ok { // syscall_js.handleEvent + switch p { + case "result": + e.result = x + return + } + } else if m, ok := v.(*object); ok { + m.properties[p] = x // Ex. opt.Set("method", req.Method) + return + } + panic(fmt.Errorf("TODO: valueSet(v=%v, p=%s, x=%v)", v, p, x)) + }, +)) + +// ValueDelete is stubbed as it isn't used in Go's main source tree. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L321 +var ValueDelete = stubFunction(functionValueDelete) + +// ValueIndex implements js.valueIndex, which is used to load a js.Value property +// by index, ex. `v.Index(0)`. Notably, this is used by js.handleEvent to read +// event arguments +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L334 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L331-L334 +var ValueIndex = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValueIndex, functionValueIndex, + []string{"v", "i"}, + func(ctx context.Context, mod api.Module, vRef uint64, i uint32) (xRef uint64) { + v := loadValue(ctx, ref(vRef)) + result := v.(*objectArray).slice[i] + xRef = storeRef(ctx, result) + return + }, +)) + +// ValueSetIndex is stubbed as it is only used for js.ValueOf when the input is +// []interface{}, which doesn't appear to occur in Go's source tree. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L348 +var ValueSetIndex = stubFunction(functionValueSetIndex) + +// ValueCall implements js.valueCall, which is used to call a js.Value function +// by name, ex. `document.Call("createElement", "div")`. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L394 +// +// https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L343-L358 +var ValueCall = spfunc.MustCallFromSP(true, wasm.NewGoFunc( + functionValueCall, functionValueCall, + []string{"v", "mAddr", "mLen", "argsArray", "argsLen"}, + func(ctx context.Context, mod api.Module, vRef uint64, mAddr, mLen, argsArray, argsLen uint32) (xRef uint64, ok uint32, sp uint32) { + this := ref(vRef) + v := loadValue(ctx, this) + m := string(mustRead(ctx, mod.Memory(), "m", mAddr, mLen)) + args := loadArgs(ctx, mod, argsArray, argsLen) + + if c, isCall := v.(jsCall); !isCall { + panic(fmt.Errorf("TODO: valueCall(v=%v, m=%v, args=%v)", v, m, args)) + } else if result, err := c.call(ctx, mod, this, m, args...); err != nil { + xRef = storeRef(ctx, err) + ok = 0 + } else { + xRef = storeRef(ctx, result) + ok = 1 + } + + sp = refreshSP(mod) + return + }, +)) + +// ValueInvoke is stubbed as it isn't used in Go's main source tree. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L413 +var ValueInvoke = stubFunction(functionValueInvoke) + +// ValueNew implements js.valueNew, which is used to call a js.Value, ex. +// `array.New(2)`. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L432 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L380-L391 +var ValueNew = spfunc.MustCallFromSP(true, wasm.NewGoFunc( + functionValueNew, functionValueNew, + []string{"v", "argsArray", "argsLen"}, + func(ctx context.Context, mod api.Module, vRef uint64, argsArray, argsLen uint32) (xRef uint64, ok uint32, sp uint32) { + args := loadArgs(ctx, mod, argsArray, argsLen) + ref := ref(vRef) + v := loadValue(ctx, ref) + + switch ref { + case refArrayConstructor: + result := &objectArray{} + xRef = storeRef(ctx, result) + ok = 1 + case refUint8ArrayConstructor: + var result *byteArray + if n, ok := args[0].(float64); ok { + result = &byteArray{make([]byte, uint32(n))} + } else if n, ok := args[0].(uint32); ok { + result = &byteArray{make([]byte, n)} + } else if b, ok := args[0].(*byteArray); ok { + // In case of below, in HTTP, return the same ref + // uint8arrayWrapper := uint8Array.New(args[0]) + result = b + } else { + panic(fmt.Errorf("TODO: valueNew(v=%v, args=%v)", v, args)) + } + xRef = storeRef(ctx, result) + ok = 1 + case refObjectConstructor: + result := &object{properties: map[string]interface{}{}} + xRef = storeRef(ctx, result) + ok = 1 + case refHttpHeadersConstructor: + result := &headers{headers: http.Header{}} + xRef = storeRef(ctx, result) + ok = 1 + case refJsDateConstructor: + xRef = uint64(refJsDate) + ok = 1 + default: + panic(fmt.Errorf("TODO: valueNew(v=%v, args=%v)", v, args)) + } + + sp = refreshSP(mod) + return + }, +)) + +// ValueLength implements js.valueLength, which is used to load the length +// property of a value, ex. `array.length`. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L372 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L396-L397 +var ValueLength = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValueLength, functionValueLength, + []string{"v"}, + func(ctx context.Context, mod api.Module, vRef uint64) uint32 { + v := loadValue(ctx, ref(vRef)) + return uint32(len(v.(*objectArray).slice)) + }, +)) + +// ValuePrepareString implements js.valuePrepareString, which is used to load +// the string for `o.String()` (via js.jsString) for string, boolean and +// number types. Notably, http.Transport uses this in RoundTrip to coerce the +// URL to a string. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L531 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L402-L405 +var ValuePrepareString = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValuePrepareString, functionValuePrepareString, + []string{"v"}, + func(ctx context.Context, mod api.Module, vRef uint64) (sRef uint64, sLen uint32) { + v := loadValue(ctx, ref(vRef)) + s := valueString(v) + sRef = storeRef(ctx, s) + sLen = uint32(len(s)) + return + }, +)) + +// ValueLoadString implements js.valueLoadString, which is used copy a string +// value for `o.String()`. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L533 +// +// https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L410-L412 +var ValueLoadString = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionValueLoadString, functionValueLoadString, + []string{"v", "bAddr", "bLen"}, + func(ctx context.Context, mod api.Module, vRef uint64, bAddr, bLen uint32) { + v := loadValue(ctx, ref(vRef)) + s := valueString(v) + b := mustRead(ctx, mod.Memory(), "b", bAddr, bLen) + copy(b, s) + }, +)) + +// ValueInstanceOf is stubbed as it isn't used in Go's main source tree. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L543 +var ValueInstanceOf = stubFunction(functionValueInstanceOf) + +// CopyBytesToGo copies a JavaScript managed byte array to linear memory. +// For example, this is used to read an HTTP response body. +// +// # Results +// +// - n is the count of bytes written. +// - ok is false if the src was not a uint8Array. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L569 +// and https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L424-L433 +var CopyBytesToGo = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionCopyBytesToGo, functionCopyBytesToGo, + []string{"dstAddr", "dstLen", "src"}, + func(ctx context.Context, mod api.Module, dstAddr, dstLen, _ uint32, srcRef uint64) (n, ok uint32) { + dst := mustRead(ctx, mod.Memory(), "dst", dstAddr, dstLen) // nolint + v := loadValue(ctx, ref(srcRef)) + if src, isBuf := v.(*byteArray); isBuf { + n = uint32(copy(dst, src.slice)) + ok = 1 + } + return + }, +)) + +// CopyBytesToJS copies linear memory to a JavaScript managed byte array. +// For example, this is used to read an HTTP request body. +// +// # Results +// +// - n is the count of bytes written. +// - ok is false if the dst was not a uint8Array. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go#L583 +// +// https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L438-L448 +var CopyBytesToJS = spfunc.MustCallFromSP(false, wasm.NewGoFunc( + functionCopyBytesToJS, functionCopyBytesToJS, + []string{"dst", "srcAddr", "srcLen"}, + func(ctx context.Context, mod api.Module, dstRef uint64, srcAddr, srcLen, _ uint32) (n, ok uint32) { + src := mustRead(ctx, mod.Memory(), "src", srcAddr, srcLen) // nolint + v := loadValue(ctx, ref(dstRef)) + if dst, isBuf := v.(*byteArray); isBuf { + if dst != nil { // empty is possible on EOF + n = uint32(copy(dst.slice, src)) + } + ok = 1 + } + return + }, +)) + +// refreshSP refreshes the stack pointer, which is needed prior to storeValue +// when in an operation that can trigger a Go event handler. +// +// See https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js#L210-L213 +func refreshSP(mod api.Module) uint32 { + // Cheat by reading global[0] directly instead of through a function proxy. + // https://github.com/golang/go/blob/go1.19/src/runtime/rt0_js_wasm.s#L87-L90 + return uint32(mod.(*wasm.CallContext).GlobalVal(0)) +} + +// syscallErr is a (GOARCH=wasm) error, which must match a key in mapJSError. +// +// See https://github.com/golang/go/blob/go1.19/src/syscall/tables_js.go#L371-L494 +type syscallErr struct { + s string +} + +// Error implements error. +func (e *syscallErr) Error() string { + return e.s +} + +// While usually I/O returns the correct errors, being explicit helps reduce +// chance of problems. +var ( + ebadf = &syscallErr{"EBADF"} + einval = &syscallErr{"EBADF"} + eexist = &syscallErr{"EEXIST"} + enoent = &syscallErr{"ENOENT"} + enotdir = &syscallErr{"ENOTDIR"} +) + +// mapJSError maps I/O errors as the message must be the code, ex. "EINVAL", +// not the message, ex. "invalid argument". +func mapJSError(err error) *syscallErr { + if e, ok := err.(*syscallErr); ok { + return e + } + switch { + case errors.Is(err, syscall.EBADF), errors.Is(err, fs.ErrClosed): + return ebadf + case errors.Is(err, syscall.EINVAL), errors.Is(err, fs.ErrInvalid): + return einval + case errors.Is(err, syscall.EEXIST), errors.Is(err, fs.ErrExist): + return eexist + case errors.Is(err, syscall.ENOENT), errors.Is(err, fs.ErrNotExist): + return enoent + case errors.Is(err, syscall.ENOTDIR): + return enotdir + default: + // panic so we can map the error before reaching JavaScript, which + // can't see the error message as it just prints "object". + panic(fmt.Errorf("unmapped error: %v", err)) + } +} + +// syscallOpen is like syscall.Open +func syscallOpen(ctx context.Context, mod api.Module, name string, flags, perm uint32) (uint32, error) { + fsc := mod.(*wasm.CallContext).Sys.FS(ctx) + return fsc.OpenFile(ctx, name) +} + +const ( + fdStdin = iota + fdStdout + fdStderr +) + +// fdReader returns a valid reader for the given file descriptor or nil if ErrnoBadf. +func fdReader(ctx context.Context, mod api.Module, fd uint32) io.Reader { + sysCtx := mod.(*wasm.CallContext).Sys + if fd == fdStdin { + return sysCtx.Stdin() + } else if f, ok := sysCtx.FS(ctx).OpenedFile(ctx, fd); !ok { + return nil + } else { + return f.File + } +} + +// fdWriter returns a valid writer for the given file descriptor or nil if ErrnoBadf. +func fdWriter(ctx context.Context, mod api.Module, fd uint32) io.Writer { + sysCtx := mod.(*wasm.CallContext).Sys + switch fd { + case fdStdout: + return sysCtx.Stdout() + case fdStderr: + return sysCtx.Stderr() + default: + // Check to see if the file descriptor is available + if f, ok := sysCtx.FS(ctx).OpenedFile(ctx, fd); !ok || f.File == nil { + return nil + // fs.FS doesn't declare io.Writer, but implementations such as + // os.File implement it. + } else if writer, ok := f.File.(io.Writer); !ok { + return nil + } else { + return writer + } + } +} + +// funcWrapper is the result of go's js.FuncOf ("_makeFuncWrapper" here). +// +// This ID is managed on the Go side an increments (possibly rolling over). +type funcWrapper uint32 + +// jsFn implements jsFn.invoke +func (f funcWrapper) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + e := &event{id: uint32(f), this: args[0].(ref)} + + if len(args) > 1 { // Ensure arguments are hashable. + e.args = &objectArray{args[1:]} + for i, v := range e.args.slice { + if s, ok := v.([]byte); ok { + args[i] = &byteArray{s} + } else if s, ok := v.([]interface{}); ok { + args[i] = &objectArray{s} + } else if e, ok := v.(error); ok { + args[i] = e + } + } + } + + getState(ctx)._pendingEvent = e // Note: _pendingEvent reference is cleared during resume! + + if _, err := mod.ExportedFunction("resume").Call(ctx); err != nil { + if _, ok := err.(*sys.ExitError); ok { + return nil, nil // allow error-handling to unwind when wasm calls exit due to a panic + } else { + return nil, err + } + } + + return e.result, nil +} diff --git a/internal/gojs/syscall_test.go b/internal/gojs/syscall_test.go new file mode 100644 index 00000000000..92cf5b42600 --- /dev/null +++ b/internal/gojs/syscall_test.go @@ -0,0 +1,28 @@ +package gojs_test + +import ( + _ "embed" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/syscall/main.go +var syscallGo string + +func Test_syscall(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, syscallGo, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `syscall.Getpid()=1 +syscall.Getppid()=0 +syscall.Getuid()=0 +syscall.Getgid()=0 +syscall.Geteuid()=0 +syscall.Umask(0077)=0o77 +syscall.Getgroups()=[0] +os.FindProcess(pid)=&{1 0 0 {{0 0} 0 0 0 0}} +`, stdout) +} diff --git a/internal/gojs/testdata/argsenv/main.go b/internal/gojs/testdata/argsenv/main.go new file mode 100644 index 00000000000..3447d1f7ac5 --- /dev/null +++ b/internal/gojs/testdata/argsenv/main.go @@ -0,0 +1,17 @@ +package main + +import ( + _ "flag" // to ensure flags parse + "fmt" + "os" +) + +func main() { + fmt.Println() + for i, a := range os.Args[1:] { + fmt.Println("args", i, "=", a) + } + for i, e := range os.Environ() { + fmt.Println("environ", i, "=", e) + } +} diff --git a/internal/gojs/testdata/crypto/main.go b/internal/gojs/testdata/crypto/main.go new file mode 100644 index 00000000000..e28c9ca50ff --- /dev/null +++ b/internal/gojs/testdata/crypto/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" +) + +func main() { + b := make([]byte, 5) + if n, err := rand.Read(b); err != nil { + log.Panicln(err) + } else if n != 5 { + log.Panicln("expected 5, but have ", n) + } + fmt.Println(hex.EncodeToString(b)) +} diff --git a/internal/gojs/testdata/fs/main.go b/internal/gojs/testdata/fs/main.go new file mode 100644 index 00000000000..b15f53a86c9 --- /dev/null +++ b/internal/gojs/testdata/fs/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "log" + "os" + "syscall" + "testing/fstest" +) + +func main() { + testFS() + testAdHoc() +} + +func testFS() { + if err := fstest.TestFS(os.DirFS("sub"), "test.txt"); err != nil { + log.Panicln("TestFS err:", err) + } + fmt.Println("TestFS ok") +} + +func testAdHoc() { + if wd, err := syscall.Getwd(); err != nil { + log.Panicln(err) + } else if wd != "/" { + log.Panicln("not root") + } + fmt.Println("wd ok") + + if err := syscall.Chdir("/test.txt"); err == nil { + log.Panicln("shouldn't be able to chdir to file") + } else { + fmt.Println(err) // should be the textual message of the errno. + } + + for _, path := range []string{"/test.txt", "test.txt"} { + s, err := os.Stat(path) + if err != nil { + log.Panicln(err) + } + if s.IsDir() { + log.Panicln(path, "is dir") + } + fmt.Println(path, "ok") + } + + b, err := os.ReadFile("/test.txt") + if err != nil { + log.Panicln(err) + } + fmt.Println("contents:", string(b)) + + b, err = os.ReadFile("/empty.txt") + if err != nil { + log.Panicln(err) + } + fmt.Println("empty:", string(b)) +} diff --git a/internal/gojs/testdata/goroutine/main.go b/internal/gojs/testdata/goroutine/main.go new file mode 100644 index 00000000000..2ad6b49ec9e --- /dev/null +++ b/internal/gojs/testdata/goroutine/main.go @@ -0,0 +1,18 @@ +package main + +import "fmt" + +func main() { + msg := make(chan int) + finished := make(chan int) + go func() { + <-msg + fmt.Println("consumer") + finished <- 1 + }() + go func() { + fmt.Println("producer") + msg <- 1 + }() + <-finished +} diff --git a/internal/gojs/testdata/http/main.go b/internal/gojs/testdata/http/main.go new file mode 100644 index 00000000000..1eae8cac2a3 --- /dev/null +++ b/internal/gojs/testdata/http/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "strings" +) + +func main() { + res, err := http.Get(os.Args[0] + "/error") + if err == nil { + log.Panicln(err) + } + fmt.Println(err) + + res, err = http.Post(os.Args[0], "text/plain", io.NopCloser(strings.NewReader("ice cream"))) + if err != nil { + log.Panicln(err) + } + body, err := io.ReadAll(res.Body) + if err != nil { + log.Panicln(err) + } + res.Body.Close() + + fmt.Println(res.Header.Get("Custom")) + fmt.Println(string(body)) +} diff --git a/internal/gojs/testdata/mem/main.go b/internal/gojs/testdata/mem/main.go new file mode 100644 index 00000000000..5b9afb79143 --- /dev/null +++ b/internal/gojs/testdata/mem/main.go @@ -0,0 +1,7 @@ +package main + +func main() { + // Go compiles into Wasm with a 16MB heap. + // As there will be some used already, allocating 16MB should force growth. + _ = make([]byte, 16*1024*1024) +} diff --git a/internal/gojs/testdata/stdio/main.go b/internal/gojs/testdata/stdio/main.go new file mode 100644 index 00000000000..7c73acc6887 --- /dev/null +++ b/internal/gojs/testdata/stdio/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "bufio" + "os" +) + +func main() { + reader := bufio.NewReader(os.Stdin) + input, _, err := reader.ReadLine() + if err != nil { + panic(err) + } + println("println", string(input)) + os.Stdout.Write([]byte("Stdout.Write")) + os.Stderr.Write([]byte("Stderr.Write")) +} diff --git a/internal/gojs/testdata/syscall/main.go b/internal/gojs/testdata/syscall/main.go new file mode 100644 index 00000000000..e1224f8c951 --- /dev/null +++ b/internal/gojs/testdata/syscall/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + "os" + "syscall" +) + +func main() { + fmt.Printf("syscall.Getpid()=%d\n", syscall.Getpid()) + fmt.Printf("syscall.Getppid()=%d\n", syscall.Getppid()) + fmt.Printf("syscall.Getuid()=%d\n", syscall.Getuid()) + fmt.Printf("syscall.Getgid()=%d\n", syscall.Getgid()) + fmt.Printf("syscall.Geteuid()=%d\n", syscall.Geteuid()) + fmt.Printf("syscall.Umask(0077)=%O\n", syscall.Umask(0077)) + if g, err := syscall.Getgroups(); err != nil { + log.Panicln(err) + } else { + fmt.Printf("syscall.Getgroups()=%v\n", g) + } + + if p, err := os.FindProcess(syscall.Getpid()); err != nil { + log.Panicln(err) + } else { + fmt.Printf("os.FindProcess(pid)=%v\n", p) + } +} diff --git a/internal/gojs/testdata/time/main.go b/internal/gojs/testdata/time/main.go new file mode 100644 index 00000000000..ff9da80ff70 --- /dev/null +++ b/internal/gojs/testdata/time/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + "time" +) + +func main() { + fmt.Println(time.Local.String()) // trigger initLocal + t := time.Now() // uses walltime + fmt.Println(time.Since(t)) // uses nanotime1 +} diff --git a/internal/gojs/time.go b/internal/gojs/time.go new file mode 100644 index 00000000000..23f76ceb9ff --- /dev/null +++ b/internal/gojs/time.go @@ -0,0 +1,26 @@ +package gojs + +import ( + "context" + + "github.com/tetratelabs/wazero/api" +) + +var ( + // jsDateConstructor returns jsDate. + // + // This is defined as `Get("Date")` in zoneinfo_js.go time.initLocal + jsDateConstructor = newJsVal(refJsDateConstructor, "Date") + + // jsDate is used inline in zoneinfo_js.go for time.initLocal. + // `.Call("getTimezoneOffset").Int()` returns a timezone offset. + jsDate = newJsVal(refJsDate, "jsDate"). + addFunction("getTimezoneOffset", &getTimezoneOffset{}) +) + +type getTimezoneOffset struct{} + +// invoke implements jsFn.invoke +func (*getTimezoneOffset) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + return uint32(0), nil // UTC +} diff --git a/internal/gojs/time_test.go b/internal/gojs/time_test.go new file mode 100644 index 00000000000..670b9000c6b --- /dev/null +++ b/internal/gojs/time_test.go @@ -0,0 +1,22 @@ +package gojs_test + +import ( + _ "embed" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +//go:embed testdata/time/main.go +var timeGo string + +func Test_time(t *testing.T) { + stdout, stderr, err := compileAndRunJsWasm(testCtx, t, timeGo, wazero.NewModuleConfig()) + + require.EqualError(t, err, `module "" closed with exit_code(0)`) + require.Zero(t, stderr) + require.Equal(t, `Local +1ms +`, stdout) +} diff --git a/internal/gojs/util.go b/internal/gojs/util.go new file mode 100644 index 00000000000..928c2fd7510 --- /dev/null +++ b/internal/gojs/util.go @@ -0,0 +1,61 @@ +package gojs + +import ( + "context" + "fmt" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/internal/wasm" +) + +// Debug has unknown use, so stubbed. +// +// See https://github.com/golang/go/blob/go1.19/src/cmd/link/internal/wasm/asm.go#L133-L138 +var Debug = stubFunction(functionDebug) + +// stubFunction stubs functions not used in Go's main source tree. +func stubFunction(name string) *wasm.HostFunc { + return &wasm.HostFunc{ + ExportNames: []string{name}, + Name: name, + ParamTypes: []wasm.ValueType{wasm.ValueTypeI32}, + ParamNames: []string{parameterSp}, + Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeUnreachable, wasm.OpcodeEnd}}, + } +} + +// mustRead is like api.Memory except that it panics if the offset and +// byteCount are out of range. +func mustRead(ctx context.Context, mem api.Memory, fieldName string, offset, byteCount uint32) []byte { + buf, ok := mem.Read(ctx, offset, byteCount) + if !ok { + panic(fmt.Errorf("out of memory reading %s", fieldName)) + } + return buf +} + +// mustReadUint64Le is like api.Memory except that it panics if the offset +// is out of range. +func mustReadUint64Le(ctx context.Context, mem api.Memory, fieldName string, offset uint32) uint64 { + result, ok := mem.ReadUint64Le(ctx, offset) + if !ok { + panic(fmt.Errorf("out of memory reading %s", fieldName)) + } + return result +} + +// mustWrite is like api.Memory except that it panics if the offset +// is out of range. +func mustWrite(ctx context.Context, mem api.Memory, fieldName string, offset uint32, val []byte) { + if ok := mem.Write(ctx, offset, val); !ok { + panic(fmt.Errorf("out of memory writing %s", fieldName)) + } +} + +// mustWriteUint64Le is like api.Memory except that it panics if the offset +// is out of range. +func mustWriteUint64Le(ctx context.Context, mem api.Memory, fieldName string, offset uint32, val uint64) { + if ok := mem.WriteUint64Le(ctx, offset, val); !ok { + panic(fmt.Errorf("out of memory writing %s", fieldName)) + } +} diff --git a/internal/sys/fs.go b/internal/sys/fs.go index df47485abc9..eab104ffe1e 100644 --- a/internal/sys/fs.go +++ b/internal/sys/fs.go @@ -5,6 +5,7 @@ import ( "io" "io/fs" "math" + "path" "sync/atomic" "syscall" ) @@ -100,15 +101,16 @@ func (c *FSContext) OpenedFile(_ context.Context, fd uint32) (*FileEntry, bool) // Ex. allow os.O_RDONLY, os.O_WRONLY, or os.O_RDWR either by config flag or pattern on filename // See #390 func (c *FSContext) OpenFile(_ context.Context, name string /* TODO: flags int, perm int */) (uint32, error) { - // fs.ValidFile cannot start with '/' + // fs.ValidFile cannot be rooted (start with '/') fsOpenPath := name if name[0] == '/' { fsOpenPath = name[1:] } + fsOpenPath = path.Clean(fsOpenPath) // ex. "sub/." -> "sub" f, err := c.fs.Open(fsOpenPath) if err != nil { - return 0, err // Don't wrap the underlying error which is already a PathError! + return 0, err } newFD := c.nextFD() @@ -144,7 +146,7 @@ func (c *FSContext) Close(_ context.Context) (err error) { delete(c.openedFiles, fd) if entry.File != nil { // File is nil for the root filesystem if e := entry.File.Close(); e != nil { - err = e // This means the err returned == the last non-nil error. + err = e // This means err returned == the last non-nil error. } } } diff --git a/internal/wasm/call_context.go b/internal/wasm/call_context.go index 982bb79a969..61d21e9d84d 100644 --- a/internal/wasm/call_context.go +++ b/internal/wasm/call_context.go @@ -36,7 +36,7 @@ type CallContext struct { ns *Namespace // Sys is exposed for use in special imports such as WASI, assemblyscript - // and wasm_exec. + // and gojs. // // # Notes // @@ -192,6 +192,11 @@ func (f *importedFn) Call(ctx context.Context, params ...uint64) (ret []uint64, return f.ce.Call(ctx, mod, params...) } +// GlobalVal is an internal hack to get the lower 64 bits of a global. +func (m *CallContext) GlobalVal(idx Index) uint64 { + return m.module.Globals[idx].Val +} + // ExportedGlobal implements the same method as documented on api.Module. func (m *CallContext) ExportedGlobal(name string) api.Global { exp, err := m.module.getExport(name, ExternTypeGlobal) diff --git a/internal/wasm/host.go b/internal/wasm/host.go index 4a5ecb70899..a3e499ffeb4 100644 --- a/internal/wasm/host.go +++ b/internal/wasm/host.go @@ -1,6 +1,7 @@ package wasm import ( + "errors" "fmt" "sort" "strings" @@ -8,6 +9,24 @@ import ( "github.com/tetratelabs/wazero/internal/wasmdebug" ) +// ProxyFunc is a function defined both in wasm and go. This is used to +// optimize the Go signature or obviate calls based on what can be done +// mechanically in wasm. +type ProxyFunc struct { + // Proxy must be a wasm func + Proxy *HostFunc + // Proxied should be a go func. + Proxied *HostFunc + + // CallBodyPos is the position in Code.Body of the caller to replace the + // real funcIdx of the proxied. + CallBodyPos int +} + +func (p *ProxyFunc) Name() string { + return p.Proxied.Name +} + // HostFunc is a function with an inlined type, used for NewHostModule. // Any corresponding FunctionType will be reused or added to the Module. type HostFunc struct { @@ -131,6 +150,14 @@ func NewHostModule( return } +// maxProxiedFuncIdx is the maximum index where leb128 encoding matches the bit +// of an unsigned literal byte. Using this simplifies host function index +// substitution. +// +// Note: this is 127, not 255 because when the MSB is set, leb128 encoding +// doesn't match the literal byte. +const maxProxiedFuncIdx = 127 + func addFuncs( m *Module, nameToGoFunc map[string]interface{}, @@ -156,6 +183,29 @@ func addFuncs( if hf, ok := v.(*HostFunc); ok { nameToFunc[hf.Name] = hf funcNames = append(funcNames, hf.Name) + } else if pf, ok := v.(*ProxyFunc); ok { + // First, add the proxied function which also gives us the real + // position in the function index namespace, We will need this + // later. We've kept code simpler by limiting the max index to + // what is encodable in a single byte. This is ok as we don't have + // any current use cases for hundreds of proxy functions. + proxiedIdx := len(funcNames) + if proxiedIdx > maxProxiedFuncIdx { + return errors.New("TODO: proxied funcidx larger than one byte") + } + nameToFunc[pf.Proxied.Name] = pf.Proxied + funcNames = append(funcNames, pf.Proxied.Name) + + // Now that we have the real index of the proxied function, + // substitute that for the zero placeholder in the proxy's code + // body. This placeholder is at index CallBodyPos in the slice. + proxyBody := make([]byte, len(pf.Proxy.Code.Body)) + copy(proxyBody, pf.Proxy.Code.Body) + proxyBody[pf.CallBodyPos] = byte(proxiedIdx) + proxy := pf.Proxy.WithWasm(proxyBody) + + nameToFunc[proxy.Name] = proxy + funcNames = append(funcNames, proxy.Name) } else { params, results, code, ftErr := parseGoFunc(v) if ftErr != nil { diff --git a/site/content/languages/_index.md b/site/content/languages/_index.md index ae3fa2185a4..17edd01d734 100644 --- a/site/content/languages/_index.md +++ b/site/content/languages/_index.md @@ -17,6 +17,7 @@ Ex. If your source is in Go, you might compile it with TinyGo. Below are notes wazero contributed so far, in alphabetical order by language. +* [Go](go) Ex. `GOARCH=wasm GOOS=js go build -o X.wasm X.go` * [TinyGo](tinygo) Ex. `tinygo build -o X.wasm -target=wasi X.go` * [Rust](rust) Ex. `rustc -o X.wasm --target wasm32-wasi X.rs` diff --git a/site/content/languages/go.md b/site/content/languages/go.md new file mode 100644 index 00000000000..92663a8e251 --- /dev/null +++ b/site/content/languages/go.md @@ -0,0 +1,242 @@ ++++ +title = "Go" ++++ + +## Introduction + +When `GOARCH=wasm GOOS=js`, Go's compiler targets WebAssembly 1.0 Binary +format (%.wasm). + +Ex. +```bash +$ GOOS=js GOARCH=wasm go build -o my.wasm . +``` + +The operating system is "js", but more specifically it is [wasm_exec.js][1]. +This package runs the `%.wasm` just like `wasm_exec.js` would. + +## Experimental + +It is important to note that while there are some interesting features, such +as HTTP client support, the ABI (host functions imported by Wasm) used is +complicated and custom to Go. For this reason, there are few implementations +outside the web browser. + +Moreover, Go defines js "EXPERIMENTAL... exempt from the Go compatibility +promise." While WebAssembly signatures haven't broken between 1.18 and 1.19, +then have in the past and can in the future. + +For this reason implementations such as wazero's [gojs][14], cannot guarantee +portability from release to release, or that the code will work well in +production. + +Due to lack of adoption, support and relatively high implementation overhead, +most choose [TinyGo](tinygo) to compile source code, even if it supports less +features. + +## Constraints + +`GOARCH=wasm GOOS=js` has a custom ABI which supports a subset of features in +the Go standard library. Notably, the host can implement time, crypto, file +system and HTTP client functions. Even where implemented, certain operations +will have no effect for reasons like ignoring HTTP request properties or fake +values returned (such as the pid). When not supported, many functions return +`syscall.ENOSYS` errors, or the string form: "not implemented on js". + +The impact of this is that end users should expect that compilation is not +enough to ensure a program works in wasm. Use of fake properties or +unimplemented API usage can result in invalid behavior or errors at runtime. +For this reason, all code compiled to wasm should be unit tested with the same +runtime configuration expected in production. + +Here are the more notable parts of Go which will not work when compiled via +`GOARCH=wasm GOOS=js`, resulting in `syscall.ENOSYS` errors: +* Goroutines. Ex. `go func(){}()` +* Raw network access. Ex. `net.Bind` +* File descriptor control (`fnctl`). Ex. `syscall.Pipe` +* Arbitrary syscalls. Ex `syscall.Syscall` +* Process control. Ex. `syscall.Kill` +* Kernel parameters. Ex. `syscall.Sysctl` +* Timezone-specific clock readings. Ex. `syscall.Gettimeofday` + +## Memory + +Memory layout begins with the "zero page" of size `runtime.minLegalPointer` +(4KB) which matches the `ssa.minZeroPage` in the compiler. It is then followed +by 8KB reserved for args and environment variables. This means the data section +begins at [ld.wasmMinDataAddr][12], offset 12288. + +## System Calls + +"syscall/js.*" are host functions for managing the JavaScript object graph, +including functions to make and finalize objects, arrays and numbers +(`js.Value`). + +Each `js.Value` has a `js.ref`, which is either a numeric literal or an object +reference depending on its 64-bit bit pattern. When an object, the first 31 +bits are its identifier. + +There are several pre-defined values with constant `js.ref` patterns. These are +either constants, globals or otherwise needed in initializers. + +For example, the "global" value includes properties like "fs" and "process" +which implement [system calls][7] needed for functions like `os.Getuid`. + +Notably, not all system calls are implemented as some are stubbed by the +compiler to return zero values or `syscall.ENOSYS`. This means not all Go code +compiled to wasm will operate. For example, you cannot launch processes. + +Details beyond this are best looking at the source code of [js.go][5], or its +unit tests. + +## Concurrency + +Please read our overview of WebAssembly and +[concurrency]({{< ref "_index.md#concurrency" >}}). In short, the current +WebAssembly specification does not support parallel processing. + +Some internal code may seem strange knowing this. For example, Go's [function +wrapper][9] used for `GOOS=js` is implemented using locks. Seeing this, you may +feel the host side of this code (`_makeFuncWrapper`) should lock its ID +namespace for parallel use as well. + +Digging deeper, you'll notice the [atomics][10] defined by `GOARCH=wasm` are +not actually implemented with locks, rather it is awaiting the ["Threads" +proposal][11]. + +In summary, while goroutines are supported in `GOARCH=wasm GOOS=js`, they won't +be able to run in parallel until the WebAssembly Specification includes atomics +and Go's compiler is updated to use them. + +## Error handling + +There are several `js.Value` used to implement `GOARCH=wasm GOOS=js` including +the global, file system, HTTP round tripping, processes, etc. All of these have +functions that may return an error on `js.Value.Call`. + +However, `js.Value.Call` does not return an error result. Internally, this +dispatches to the wasm imported function `valueCall`, and interprets its two +results: the real result and a boolean, represented by an integer. + +When false, `js.Value.Call` panics with a `js.Error` constructed from the first +result. This result must be an object with one of the below properties: + +* JavaScript (GOOS=js): the string property "message" can be anything. +* Syscall error (GOARCH=wasm): the string property "code" is constrained. + * The code must be like "EIO" in [errnoByCode][13] to avoid a panic. + +Details beyond this are best looking at the source code of [js.go][5], or its +unit tests. + +## Identifying wasm compiled by Go + +If you have a `%.wasm` file compiled by Go (via [asm.go][2]), it has a custom +section named "go.buildid". + +You can verify this with wasm-objdump, a part of [wabt][3]: +``` +$ wasm-objdump --section=go.buildid -x my.wasm + +example3.wasm: file format wasm 0x1 + +Section Details: + +Custom: +- name: "go.buildid" +``` + +## Module Exports + +Until [wasmexport][4] is implemented, the [compiled][2] WebAssembly exports are +always the same: + +* "mem" - (memory 265) 265 = data section plus 16MB +* "run" - (func (param $argc i32) (param $argv i32)) the entrypoint +* "resume" - (func) continues work after a timer delay +* "getsp" - (func (result i32)) returns the stack pointer + +## Module Imports + +Go's [compiles][3] all WebAssembly imports in the module "go", and only +functions are imported. + +Except for the "debug" function, all function names are prefixed by their go +package. Here are the defaults: + +* "debug" - is always function index zero, but it has unknown use. +* "runtime.*" - supports system-call like functionality `GOARCH=wasm` +* "syscall/js.*" - supports the JavaScript model `GOOS=js` + +## PC_B calling conventions + +The assembly `CallImport` instruction doesn't compile signatures to WebAssembly +function types, invoked by the `call` instruction. + +Instead, the compiler generates the same signature for all functions: a single +parameter of the stack pointer, and invokes them via `call.indirect`. + +Specifically, any function compiled with `CallImport` has the same function +type: `(func (param $sp i32))`. `$sp` is the base memory offset to read and +write parameters to the stack (at 8 byte strides even if the value is 32-bit). + +So, implementors need to read the actual parameters from memory. Similarly, if +there are results, the implementation must write those to memory. + +For example, `func walltime() (sec int64, nsec int32)` writes its results to +memory at offsets `sp+8` and `sp+16` respectively. + +Note: WebAssembly compatible calling conventions has been discussed and +[attempted](https://go-review.googlesource.com/c/go/+/350737) in Go before. + +## Go-defined exported functions + +[Several functions][6] differ in calling convention by using WebAssembly type +signatures instead of the single SP parameter summarized above. Functions used +by the host have a "wasm_export_" prefix, which is stripped. For example, +"wasm_export_run" is exported as "run", defined in [rt0_js_wasm.s][7] + +Here is an overview of the Go-defined exported functions: + * "run" - Accepts "argc" and "argv" i32 params and begins the "wasm_pc_f_loop" + * "resume" - Nullary function that resumes execution until it needs an event. + * "getsp" - Returns the i32 stack pointer (SP) + +## User-defined Host Functions + +Users can define their own "go" module function imports by defining a func +without a body in their source and a `%_wasm.s` or `%_js.s` file that uses the +`CallImport` instruction. + +For example, given `func logString(msg string)` and the below assembly: +```assembly +#include "textflag.h" + +TEXT ·logString(SB), NOSPLIT, $0 +CallImport +RET +``` + +If the package was `main`, the WebAssembly function name would be +"main.logString". If it was `util` and your `go.mod` module was +"github.com/user/me", the WebAssembly function name would be +"github.com/user/me/util.logString". + +Regardless of whether the function import was built-in to Go, or defined by an +end user, all imports use `CallImport` conventions. Since these compile to a +signature unrelated to the source, more care is needed implementing the host +side, to ensure the proper count of parameters are read and results written to +the Go stack. + +[1]: https://github.com/golang/go/blob/go1.19/misc/wasm/wasm_exec.js +[2]: https://github.com/golang/go/blob/go1.19/src/cmd/link/internal/wasm/asm.go +[3]: https://github.com/WebAssembly/wabt +[4]: https://github.com/golang/proposal/blob/go1.19/design/42372-wasmexport.md +[5]: https://github.com/golang/go/blob/go1.19/src/syscall/js/js.go +[6]: https://github.com/golang/go/blob/go1.19/src/cmd/internal/obj/wasm/wasmobj.go#L794-L812 +[7]: https://github.com/golang/go/blob/go1.19/src/runtime/rt0_js_wasm.s#L17-L21 +[8]: https://github.com/golang/go/blob/go1.19/src/syscall/syscall_js.go#L292-L306 +[9]: https://github.com/golang/go/blob/go1.19/src/syscall/js/func.go#L41-L44 +[10]: https://github.com/golang/go/blob/go1.19/src/runtime/internal/atomic/atomic_wasm.go#L5-L6 +[11]: https://github.com/WebAssembly/proposals +[12]: https://github.com/golang/go/blob/go1.19/src/cmd/link/internal/ld/data.go#L2457 +[13]: https://github.com/golang/go/blob/go1.19/src/syscall/tables_js.go#L371-L494 +[14]: https://github.com/tetratelabs/wazero/tree/main/examples/gojs