Repository: tetratelabs/wazero
Pull request #409 (2022-03-24 10:34:26)

wasi: stops enforcing _start function export
Currently, we have custom code in wapc-go because our library forces a
failure when a module that uses WASI doesn't define a "_start" function.
Using the same pragmatism that resulted in us not enforcing the WASI
table, this makes the "_start" function optional. This doesn't add a
flag as the spec is not a proper version anyway (snapshot-01), so
there's no need to further complicate configuration.

If a "_start" function exists, we enforce it is of the proper signature
and succeeds. Otherwise, we allow it to be absent.



Patch:
diff --git a/RATIONALE.md b/RATIONALE.md
index 3e1e679a..e3955995 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -87,7 +87,22 @@ runtime vs interpreting Wasm directly (the `naivevm` interpreter).
 Note: `microwasm` was never specified formally, and only exists in a historical codebase of wasmtime:
 https://github.com/bytecodealliance/wasmtime/blob/v0.29.0/crates/lightbeam/src/microwasm.rs
 
-## Why is `SysConfig` decoupled from WASI?
+## WASI
+
+### Why aren't all WASI rules enforced?
+
+The [snapshot-01](https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md) version of WASI has a
+number of rules for a "command module", but only the memory export rule is enforced. If a "_start" function exists, it
+is enforced to be the correct signature and succeed, but the export itself isn't enforced. It follows that this means
+exports are not required to be contained to a "_start" function invocation. Finally, the "__indirect_function_table"
+export is also not enforced.
+
+The reason for the exceptions are that implementations aren't following the rules. For example, TinyGo doesn't export
+"__indirect_function_table", so crashing on this would make wazero unable to run TinyGo modules. Similarly, modules
+loaded by wapc-go don't always define a "_start" function. Since "snapshot-01" is not a proper version, and certainly
+not a W3C recommendation, there's no sense in breaking users over matters like this.
+
+### Why is `SysConfig` decoupled from WASI?
 
 WebAssembly System Interfaces (WASI) is a formalization of a practice that can be done anyway: Define a host function to
 access a system interface, such as writing to STDOUT. WASI stalled at snapshot-01 and as of early 2022, is being
diff --git a/internal/wasi/wasi.go b/internal/wasi/wasi.go
index 2b2bbfb6..fb34951f 100644
--- a/internal/wasi/wasi.go
+++ b/internal/wasi/wasi.go
@@ -1505,9 +1505,9 @@ func openFileEntry(rootFS fs.FS, pathName string) (*internalwasm.FileEntry, wasi
 }
 
 func ValidateWASICommand(module *internalwasm.Module, moduleName string) error {
-	if start, err := requireExport(module, moduleName, FunctionStart, internalwasm.ExternTypeFunc); err != nil {
-		return err
-	} else {
+	// The snapshot-01 specification requires a "_start" function, but we don't enforce it is present. We only enforce
+	// if present, it is valid. In practice, not all modules importing WASI define a "_start" function (ex. wapc-go).
+	if start, err := requireExport(module, moduleName, FunctionStart, internalwasm.ExternTypeFunc); err == nil {
 		// TODO: this should be verified during decode so that errors have the correct source positions
 		ft := module.TypeOfFunction(start.Index)
 		if ft == nil {
diff --git a/wasi.go b/wasi.go
index f746f723..bd4dae0d 100644
--- a/wasi.go
+++ b/wasi.go
@@ -40,9 +40,9 @@ func StartWASICommandFromSource(r Runtime, source []byte) (wasm.Module, error) {
 	}
 }
 
-// StartWASICommand instantiates the module and starts its WASI Command function ("_start"). The return value are all
-// exported functions in the module. This errs if the module doesn't comply with prerequisites, or any instantiation
-// or function call error.
+// StartWASICommand instantiates the module and starts its WASI Command function ("_start") if present. The return value
+// are all exported functions in the module. This errs if the module doesn't export a memory named "memory", or there
+// are any instantiation or function call errors. On success, other modules can import wasi.ModuleSnapshotPreview1.
 //
 // Ex.
 //	r := wazero.NewRuntime()
@@ -53,13 +53,17 @@ func StartWASICommandFromSource(r Runtime, source []byte) (wasm.Module, error) {
 //	module, _ := StartWASICommand(r, decoded)
 //	defer module.Close()
 //
-// Prerequisites of the "Current Unstable ABI" from wasi.ModuleSnapshotPreview1:
-// * "_start" is an exported nullary function and does not export "_initialize"
-// * "memory" is an exported memory.
+// ## "memory" export
+// WASI snapshot-01 requires exporting a memory named "memory", and wazero enforces this as nearly all functions use
+// memory to implement multiple returns. StartWASICommand errs if there is no memory exported as "memory".
+//
+// ## "_start" function export
+// WASI snapshot-01 requires exporting a function named "_start" for WASI command, but wazero does not enforce this. If it is defined,
+// it is called directly after any module-defined start function, in the runtime context (RuntimeConfig.WithContext).
+//
+// ## "__indirect_function_table" function export
+// WASI snapshot-01 requires exporting a table named "__indirect_function_table", but wazero does not enforce this.
 //
-// Note: "_start" is invoked in the RuntimeConfig.Context.
-// Note: Exporting "__indirect_function_table" is mentioned as required, but not enforced here.
-// Note: The wasm.Functions return value does not restrict exports after "_start" as allowed in the specification.
 // Note: All TinyGo Wasm are WASI commands. They initialize memory on "_start" and import "fd_write" to implement panic.
 // See StartWASICommandWithConfig
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/design/application-abi.md#current-unstable-abi
@@ -97,7 +101,8 @@ func startWASICommandWithSysContext(r Runtime, module *Module, sys *internalwasm
 
 	internal, ok := r.(*runtime)
 	if !ok {
-		return nil, fmt.Errorf("unsupported Runtime implementation: %s", r)
+		err = fmt.Errorf("unsupported Runtime implementation: %s", r)
+		return
 	}
 
 	if mod, err = internal.store.Instantiate(internal.ctx, module.module, module.name, sys); err != nil {
@@ -105,8 +110,11 @@ func startWASICommandWithSysContext(r Runtime, module *Module, sys *internalwasm
 	}
 
 	start := mod.ExportedFunction(internalwasi.FunctionStart)
+	if start == nil {
+		return
+	}
 	if _, err = start.Call(mod.WithContext(internal.ctx)); err != nil {
-		return nil, fmt.Errorf("module[%s] function[%s] failed: %w", module.name, internalwasi.FunctionStart, err)
+		err = fmt.Errorf("module[%s] function[%s] failed: %w", module.name, internalwasi.FunctionStart, err)
 	}
-	return mod, nil
+	return
 }


Test patch:
diff --git a/wasi_test.go b/wasi_test.go
index 324d3a23..28a8985f 100644
--- a/wasi_test.go
+++ b/wasi_test.go
@@ -11,7 +11,24 @@ import (
 	"github.com/tetratelabs/wazero/wasm"
 )
 
-func TestStartWASICommand_UsesStoreContext(t *testing.T) {
+// TestStartWASICommand_DoesntEnforce_Start ensures wapc-go work when modules import WASI, but don't export "_start".
+func TestStartWASICommand_DoesntEnforce_Start(t *testing.T) {
+	r := NewRuntime()
+
+	wasi, err := r.InstantiateModule(WASISnapshotPreview1())
+	require.NoError(t, err)
+	defer wasi.Close()
+
+	// Start the module as a WASI command. This will fail if the context wasn't as intended.
+	mod, err := StartWASICommandFromSource(r, []byte(`(module $wasi_test.go
+	(memory 1)
+	(export "memory" (memory 0))
+)`))
+	require.NoError(t, err)
+	require.NoError(t, mod.Close())
+}
+
+func TestStartWASICommand_UsesRuntimeContext(t *testing.T) {
 	type key string
 	config := NewRuntimeConfig().WithContext(context.WithValue(context.Background(), key("wa"), "zero"))
 	r := NewRuntimeWithConfig(config)
@@ -23,23 +40,24 @@ func TestStartWASICommand_UsesStoreContext(t *testing.T) {
 		require.Equal(t, config.ctx, ctx.Context())
 	}
 
-	_, err := r.NewModuleBuilder("").ExportFunction("start", start).Instantiate()
+	host, err := r.NewModuleBuilder("").ExportFunction("start", start).Instantiate()
 	require.NoError(t, err)
+	defer host.Close()
 
-	_, err = r.InstantiateModule(WASISnapshotPreview1())
+	wasi, err := r.InstantiateModule(WASISnapshotPreview1())
 	require.NoError(t, err)
+	defer wasi.Close()
 
-	decoded, err := r.CompileModule([]byte(`(module $wasi_test.go
+	// Start the module as a WASI command. This will fail if the context wasn't as intended.
+	mod, err := StartWASICommandFromSource(r, []byte(`(module $wasi_test.go
 	(import "" "start" (func $start))
 	(memory 1)
 	(export "_start" (func $start))
 	(export "memory" (memory 0))
 )`))
 	require.NoError(t, err)
+	defer mod.Close()
 
-	// Start the module as a WASI command. This will fail if the context wasn't as intended.
-	_, err = StartWASICommand(r, decoded)
-	require.NoError(t, err)
 	require.True(t, calledStart)
 }