Repository: tetratelabs/wazero (go)

Issue:
An error occurred running zig wasi
An error occurred when build `wasi` using `zig build-exe src/main.zig -target wasm32-wasi`:
```sh
failed to instantiate module: module "" closed with exit_code(0)
```
It runs successfully when remove the `_start` function, as follows: 
```go
config := wazero.NewModuleConfig().WithStartFunctions()
mod, err := r.InstantiateWithConfig(ctx, addWasm, config)
...
```


Pull request description:
Avoids returning ExitError on exit code zero, and optimizes for no allocations
This removes the need to handle ExitError for success (exit code zero). It also removes the module name from the struct which allows us to re-use the same error object for the common case of success.

Fixes #1283

Patch:
diff --git a/imports/assemblyscript/assemblyscript.go b/imports/assemblyscript/assemblyscript.go
index b7eaa536..78103f67 100644
--- a/imports/assemblyscript/assemblyscript.go
+++ b/imports/assemblyscript/assemblyscript.go
@@ -177,7 +177,7 @@ func abort(ctx context.Context, mod api.Module, _ []uint64) {
 	_ = mod.CloseWithExitCode(ctx, exitCode)
 
 	// Prevent any code from executing after this function.
-	panic(sys.NewExitError(mod.Name(), exitCode))
+	panic(sys.NewExitError(exitCode))
 }
 
 // traceDisabled ignores the input.
diff --git a/imports/wasi_snapshot_preview1/proc.go b/imports/wasi_snapshot_preview1/proc.go
index 20b161d2..07c963ef 100644
--- a/imports/wasi_snapshot_preview1/proc.go
+++ b/imports/wasi_snapshot_preview1/proc.go
@@ -37,7 +37,7 @@ func procExitFn(ctx context.Context, mod api.Module, params []uint64) {
 	// Prevent any code from executing after this function. For example, LLVM
 	// inserts unreachable instructions after calls to exit.
 	// See: https://github.com/emscripten-core/emscripten/issues/12322
-	panic(sys.NewExitError(mod.Name(), exitCode))
+	panic(sys.NewExitError(exitCode))
 }
 
 // procRaise is stubbed and will never be supported, as it was removed.
diff --git a/internal/wasm/call_context.go b/internal/wasm/call_context.go
index 84e4f388..e868dd39 100644
--- a/internal/wasm/call_context.go
+++ b/internal/wasm/call_context.go
@@ -61,7 +61,7 @@ type CallContext struct {
 // FailIfClosed returns a sys.ExitError if CloseWithExitCode was called.
 func (m *CallContext) FailIfClosed() (err error) {
 	if closed := atomic.LoadUint64(&m.Closed); closed != 0 {
-		return sys.NewExitError(m.module.Name, uint32(closed>>32)) // Unpack the high order bits as the exit code.
+		return sys.NewExitError(uint32(closed >> 32)) // Unpack the high order bits as the exit code.
 	}
 	return nil
 }
diff --git a/runtime.go b/runtime.go
index 2d8f9461..ba34a324 100644
--- a/runtime.go
+++ b/runtime.go
@@ -34,11 +34,14 @@ type Runtime interface {
 	//
 	//	mod, _ := r.Instantiate(ctx, wasm)
 	//
-	// See InstantiateWithConfig for configuration overrides.
+	// # Notes
+	//
+	//   - See notes on InstantiateModule for error scenarios.
+	//   - See InstantiateWithConfig for configuration overrides.
 	Instantiate(ctx context.Context, source []byte) (api.Module, error)
 
 	// InstantiateWithConfig instantiates a module from the WebAssembly binary
-	// (%.wasm) or errs if invalid.
+	// (%.wasm) or errs for reasons including exit or validation.
 	//
 	// Here's an example:
 	//	ctx := context.Background()
@@ -50,11 +53,12 @@ type Runtime interface {
 	//
 	// # Notes
 	//
+	//   - See notes on InstantiateModule for error scenarios.
+	//   - If you aren't overriding defaults, use Instantiate.
 	//   - This is a convenience utility that chains CompileModule with
 	//     InstantiateModule. To instantiate the same source multiple times,
 	//     use CompileModule as InstantiateModule avoids redundant decoding
 	//     and/or compilation.
-	//   - If you aren't overriding defaults, use Instantiate.
 	InstantiateWithConfig(ctx context.Context, source []byte, config ModuleConfig) (api.Module, error)
 
 	// NewHostModuleBuilder lets you create modules out of functions defined in Go.
@@ -87,15 +91,25 @@ type Runtime interface {
 	// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#name-section%E2%91%A0
 	CompileModule(ctx context.Context, binary []byte) (CompiledModule, error)
 
-	// InstantiateModule instantiates the module or errs if the configuration was invalid.
+	// InstantiateModule instantiates the module or errs for reasons including
+	// exit or validation.
 	//
 	// Here's an example:
-	//	mod, _ := n.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName("prod"))
+	//	mod, _ := n.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().
+	//		WithName("prod"))
 	//
-	// While CompiledModule is pre-validated, there are a few situations which can cause an error:
+	// # Errors
+	//
+	// While CompiledModule is pre-validated, there are a few situations which
+	// can cause an error:
 	//   - The module name is already in use.
-	//   - The module has a table element initializer that resolves to an index outside the Table minimum size.
+	//   - The module has a table element initializer that resolves to an index
+	//     outside the Table minimum size.
 	//   - The module has a start function, and it failed to execute.
+	//   - The module was compiled to WASI and exited with a non-zero exit
+	//     code, you'll receive a sys.ExitError.
+	//   - RuntimeConfig.WithCloseOnContextDone was enabled and a context
+	//     cancellation or deadline triggered before a start function returned.
 	InstantiateModule(ctx context.Context, compiled CompiledModule, config ModuleConfig) (api.Module, error)
 
 	// CloseWithExitCode closes all the modules that have been initialized in this Runtime with the provided exit code.
@@ -294,6 +308,11 @@ func (r *runtime) InstantiateModule(
 		if code.closeWithModule {
 			_ = code.Close(ctx) // don't overwrite the error
 		}
+		if se, ok := err.(*sys.ExitError); ok {
+			if se.ExitCode() == 0 { // Don't err on success.
+				err = nil
+			}
+		}
 		return
 	}
 
@@ -310,7 +329,11 @@ func (r *runtime) InstantiateModule(
 		}
 		if _, err = start.Call(ctx); err != nil {
 			_ = mod.Close(ctx) // Don't leak the module on error.
-			if _, ok := err.(*sys.ExitError); ok {
+
+			if se, ok := err.(*sys.ExitError); ok {
+				if se.ExitCode() == 0 { // Don't err on success.
+					err = nil
+				}
 				return // Don't wrap an exit error
 			}
 			err = fmt.Errorf("module[%s] function[%s] failed: %w", name, fn, err)
diff --git a/sys/error.go b/sys/error.go
index 5c5ecd14..9aea47b1 100644
--- a/sys/error.go
+++ b/sys/error.go
@@ -25,7 +25,7 @@ const (
 //	main := module.ExportedFunction("main")
 //	if err := main(ctx); err != nil {
 //		if exitErr, ok := err.(*sys.ExitError); ok {
-//			// If your main function expects to exit, this could be ok if Code == 0
+//			// This means your module exited with non-zero code!
 //		}
 //	--snip--
 //
@@ -37,17 +37,18 @@ const (
 //
 // Note: In the case of context cancellation or timeout, the api.Module from which the api.Function created is closed.
 type ExitError struct {
-	moduleName string
-	exitCode   uint32
+	// Note: this is a struct not a uint32 type as it was originally one and
+	// we don't want to break call-sites that cast into it.
+	exitCode uint32
 }
 
-func NewExitError(moduleName string, exitCode uint32) *ExitError {
-	return &ExitError{moduleName: moduleName, exitCode: exitCode}
-}
+var exitZero = &ExitError{}
 
-// ModuleName is the api.Module that was closed.
-func (e *ExitError) ModuleName() string {
-	return e.moduleName
+func NewExitError(exitCode uint32) *ExitError {
+	if exitCode == 0 {
+		return exitZero
+	}
+	return &ExitError{exitCode: exitCode}
 }
 
 // ExitCode returns zero on success, and an arbitrary value otherwise.
@@ -59,18 +60,18 @@ func (e *ExitError) ExitCode() uint32 {
 func (e *ExitError) Error() string {
 	switch e.exitCode {
 	case ExitCodeContextCanceled:
-		return fmt.Sprintf("module %q closed with %s", e.moduleName, context.Canceled)
+		return fmt.Sprintf("module closed with %s", context.Canceled)
 	case ExitCodeDeadlineExceeded:
-		return fmt.Sprintf("module %q closed with %s", e.moduleName, context.DeadlineExceeded)
+		return fmt.Sprintf("module closed with %s", context.DeadlineExceeded)
 	default:
-		return fmt.Sprintf("module %q closed with exit_code(%d)", e.moduleName, e.exitCode)
+		return fmt.Sprintf("module closed with exit_code(%d)", e.exitCode)
 	}
 }
 
 // Is allows use via errors.Is
 func (e *ExitError) Is(err error) bool {
 	if target, ok := err.(*ExitError); ok {
-		return e.moduleName == target.moduleName && e.exitCode == target.exitCode
+		return e.exitCode == target.exitCode
 	}
 	return false
 }


Test patch:
diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go
index 8d567c73..cde24da2 100644
--- a/cmd/wazero/wazero_test.go
+++ b/cmd/wazero/wazero_test.go
@@ -459,7 +459,7 @@ func TestRun(t *testing.T) {
 			name:             "timeout: a binary that exceeds the deadline should print an error",
 			wazeroOpts:       []string{"-timeout=1ms"},
 			wasm:             wasmInfiniteLoop,
-			expectedStderr:   "error: module \"\" closed with context deadline exceeded (timeout 1ms)\n",
+			expectedStderr:   "error: module closed with context deadline exceeded (timeout 1ms)\n",
 			expectedExitCode: int(sys.ExitCodeDeadlineExceeded),
 			test: func(t *testing.T) {
 				require.NoError(t, err)
diff --git a/context_done_example_test.go b/context_done_example_test.go
index 9f57c084..76a33847 100644
--- a/context_done_example_test.go
+++ b/context_done_example_test.go
@@ -45,7 +45,7 @@ func ExampleRuntimeConfig_WithCloseOnContextDone_context_timeout() {
 	fmt.Println(err)
 
 	// Output:
-	//	module "malicious_wasm" closed with context deadline exceeded
+	//	module closed with context deadline exceeded
 }
 
 // ExampleRuntimeConfig_WithCloseOnContextDone_context_cancel demonstrates how to ensure the termination
@@ -82,7 +82,7 @@ func ExampleRuntimeConfig_WithCloseOnContextDone_context_cancel() {
 	fmt.Println(err)
 
 	// Output:
-	//	module "malicious_wasm" closed with context canceled
+	//	module closed with context canceled
 }
 
 // ExampleRuntimeConfig_WithCloseOnContextDone_moduleClose demonstrates how to ensure the termination
@@ -120,5 +120,5 @@ func ExampleRuntimeConfig_WithCloseOnContextDone_moduleClose() {
 	fmt.Println(err)
 
 	// Output:
-	//	module "malicious_wasm" closed with exit_code(1)
+	//	module closed with exit_code(1)
 }
diff --git a/imports/emscripten/emscripten_test.go b/imports/emscripten/emscripten_test.go
index 78abb017..cf62e9d0 100644
--- a/imports/emscripten/emscripten_test.go
+++ b/imports/emscripten/emscripten_test.go
@@ -11,7 +11,6 @@ import (
 	"github.com/tetratelabs/wazero/experimental/logging"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/testing/require"
-	"github.com/tetratelabs/wazero/sys"
 )
 
 // growWasm was compiled from testdata/grow.cc
@@ -45,10 +44,9 @@ func TestGrow(t *testing.T) {
 	_, err := Instantiate(ctx, r)
 	require.NoError(t, err)
 
-	// Emscripten exits main with zero by default
+	// Emscripten exits main with zero by default, which coerces to nul.
 	_, err = r.Instantiate(ctx, growWasm)
-	require.Error(t, err)
-	require.Zero(t, err.(*sys.ExitError).ExitCode())
+	require.Nil(t, err)
 
 	// We expect the memory no-op memory growth hook to be invoked as wasm.
 	require.Contains(t, log.String(), "==> env.emscripten_notify_memory_growth(memory_index=0)")
diff --git a/imports/wasi_snapshot_preview1/wasi_test.go b/imports/wasi_snapshot_preview1/wasi_test.go
index a4e30d1a..63705c67 100644
--- a/imports/wasi_snapshot_preview1/wasi_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_test.go
@@ -72,7 +72,7 @@ func TestNewFunctionExporter(t *testing.T) {
 		_, err = r.Instantiate(testCtx, exitOnStartWasm)
 
 		// Ensure the modified function was used!
-		require.Zero(t, err.(*sys.ExitError).ExitCode())
+		require.Nil(t, err)
 	})
 }
 
diff --git a/internal/gojs/argsenv_test.go b/internal/gojs/argsenv_test.go
index 615837c1..d0ea2a0c 100644
--- a/internal/gojs/argsenv_test.go
+++ b/internal/gojs/argsenv_test.go
@@ -15,7 +15,7 @@ func Test_argsAndEnv(t *testing.T) {
 		return moduleConfig.WithEnv("c", "d").WithEnv("a", "b"), config.NewConfig()
 	})
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Zero(t, stderr)
 	require.Equal(t, `
 args 0 = test
diff --git a/internal/gojs/crypto_test.go b/internal/gojs/crypto_test.go
index c4c58194..8e1d46c8 100644
--- a/internal/gojs/crypto_test.go
+++ b/internal/gojs/crypto_test.go
@@ -20,7 +20,7 @@ func Test_crypto(t *testing.T) {
 	stdout, stderr, err := compileAndRun(loggingCtx, "crypto", defaultConfig)
 
 	require.Zero(t, stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, `7a0c9f9f0d
 `, stdout)
 	require.Equal(t, `==> go.runtime.getRandomData(r_len=32)
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 81def6d3..5fc3674f 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -20,7 +20,7 @@ func Test_fs(t *testing.T) {
 	})
 
 	require.Zero(t, stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, `sub mode drwxr-xr-x
 /animals.txt mode -rw-r--r--
 animals.txt mode -rw-r--r--
@@ -50,7 +50,7 @@ func Test_testfs(t *testing.T) {
 	})
 
 	require.Zero(t, stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Zero(t, stdout)
 }
 
@@ -67,7 +67,7 @@ func Test_writefs(t *testing.T) {
 	})
 
 	require.Zero(t, stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 
 	if platform.CompilerSupported() {
 		//  Note: as of Go 1.19, only the Sec field is set on update in fs_js.go.
diff --git a/internal/gojs/http_test.go b/internal/gojs/http_test.go
index 90ae0cf6..5ba1e261 100644
--- a/internal/gojs/http_test.go
+++ b/internal/gojs/http_test.go
@@ -46,7 +46,7 @@ func Test_http(t *testing.T) {
 		return moduleConfig.WithEnv("BASE_URL", "http://host"), config
 	})
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	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
diff --git a/internal/gojs/misc_test.go b/internal/gojs/misc_test.go
index e3ca36a6..6de70c6b 100644
--- a/internal/gojs/misc_test.go
+++ b/internal/gojs/misc_test.go
@@ -23,7 +23,7 @@ func Test_exit(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(loggingCtx, "exit", defaultConfig)
 
-	require.EqualError(t, err, `module "" closed with exit_code(255)`)
+	require.EqualError(t, err, `module closed with exit_code(255)`)
 	require.Zero(t, stderr)
 	require.Zero(t, stdout)
 	require.Equal(t, `==> go.runtime.wasmExit(code=255)
@@ -36,7 +36,7 @@ func Test_goroutine(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(testCtx, "goroutine", defaultConfig)
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Zero(t, stderr)
 	require.Equal(t, `producer
 consumer
@@ -52,7 +52,7 @@ func Test_mem(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(loggingCtx, "mem", defaultConfig)
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Zero(t, stderr)
 	require.Zero(t, stdout)
 
@@ -71,7 +71,7 @@ func Test_stdio(t *testing.T) {
 	})
 
 	require.Equal(t, "stderr 6\n", stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, "stdout 6\n", stdout)
 }
 
@@ -89,7 +89,7 @@ func Test_stdio_large(t *testing.T) {
 		return defaultConfig(moduleConfig.WithStdin(bytes.NewReader(input)))
 	})
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, fmt.Sprintf("stderr %d\n", size), stderr)
 	require.Equal(t, fmt.Sprintf("stdout %d\n", size), stdout)
 
@@ -107,7 +107,7 @@ func Test_gc(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(testCtx, "gc", defaultConfig)
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, "", stderr)
 	require.Equal(t, "before gc\nafter gc\n", stdout)
 }
diff --git a/internal/gojs/process_test.go b/internal/gojs/process_test.go
index 1e2a9c1c..efa6cb49 100644
--- a/internal/gojs/process_test.go
+++ b/internal/gojs/process_test.go
@@ -18,7 +18,7 @@ func Test_process(t *testing.T) {
 	})
 
 	require.Zero(t, stderr)
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Equal(t, `syscall.Getpid()=1
 syscall.Getppid()=0
 syscall.Getuid()=0
diff --git a/internal/gojs/time_test.go b/internal/gojs/time_test.go
index 3e83fbca..5784ec89 100644
--- a/internal/gojs/time_test.go
+++ b/internal/gojs/time_test.go
@@ -19,7 +19,7 @@ func Test_time(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(loggingCtx, "time", defaultConfig)
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.EqualError(t, err, `module closed with exit_code(0)`)
 	require.Zero(t, stderr)
 	require.Equal(t, `Local
 1ms
diff --git a/internal/integration_test/engine/adhoc_test.go b/internal/integration_test/engine/adhoc_test.go
index bd6a8ebf..ab3c19a6 100644
--- a/internal/integration_test/engine/adhoc_test.go
+++ b/internal/integration_test/engine/adhoc_test.go
@@ -3,7 +3,6 @@ package adhoc
 import (
 	"context"
 	_ "embed"
-	"fmt"
 	"math"
 	"strconv"
 	"testing"
@@ -115,7 +114,7 @@ func testEnsureTerminationOnClose(t *testing.T, r wazero.Runtime) {
 		}()
 		_, err = infinite.Call(ctx)
 		require.Error(t, err)
-		require.Contains(t, err.Error(), fmt.Sprintf("module \"%s\" closed with context canceled", t.Name()))
+		require.Contains(t, err.Error(), "module closed with context canceled")
 	})
 
 	t.Run("context cancel in advance", func(t *testing.T) {
@@ -124,7 +123,7 @@ func testEnsureTerminationOnClose(t *testing.T, r wazero.Runtime) {
 		cancel()
 		_, err = infinite.Call(ctx)
 		require.Error(t, err)
-		require.Contains(t, err.Error(), fmt.Sprintf("module \"%s\" closed with context canceled", t.Name()))
+		require.Contains(t, err.Error(), "module closed with context canceled")
 	})
 
 	t.Run("context timeout", func(t *testing.T) {
@@ -133,7 +132,7 @@ func testEnsureTerminationOnClose(t *testing.T, r wazero.Runtime) {
 		defer cancel()
 		_, err = infinite.Call(ctx)
 		require.Error(t, err)
-		require.Contains(t, err.Error(), fmt.Sprintf("module \"%s\" closed with context deadline exceeded", t.Name()))
+		require.Contains(t, err.Error(), "module closed with context deadline exceeded")
 	})
 
 	t.Run("explicit close of module", func(t *testing.T) {
@@ -144,7 +143,7 @@ func testEnsureTerminationOnClose(t *testing.T, r wazero.Runtime) {
 		}()
 		_, err = infinite.Call(context.Background())
 		require.Error(t, err)
-		require.Contains(t, err.Error(), fmt.Sprintf("module \"%s\" closed with exit_code(2)", t.Name()))
+		require.Contains(t, err.Error(), "module closed with exit_code(2)")
 	})
 }
 
@@ -688,11 +687,11 @@ func testCloseInFlight(t *testing.T, r wazero.Runtime) {
 			var expectedErr error
 			if tc.closeImported != 0 && tc.closeImporting != 0 {
 				// When both modules are closed, importing is the better one to choose in the error message.
-				expectedErr = sys.NewExitError(importing.Name(), tc.closeImporting)
+				expectedErr = sys.NewExitError(tc.closeImporting)
 			} else if tc.closeImported != 0 {
-				expectedErr = sys.NewExitError(imported.Name(), tc.closeImported)
+				expectedErr = sys.NewExitError(tc.closeImported)
 			} else if tc.closeImporting != 0 {
-				expectedErr = sys.NewExitError(importing.Name(), tc.closeImporting)
+				expectedErr = sys.NewExitError(tc.closeImporting)
 			} else {
 				t.Fatal("invalid test case")
 			}
diff --git a/internal/integration_test/engine/hammer_test.go b/internal/integration_test/engine/hammer_test.go
index 6ccfcff4..a6b2deba 100644
--- a/internal/integration_test/engine/hammer_test.go
+++ b/internal/integration_test/engine/hammer_test.go
@@ -99,7 +99,7 @@ func closeModuleWhileInUse(t *testing.T, r wazero.Runtime, closeFn func(imported
 	i := importing // pin the module used inside goroutines
 	hammer.NewHammer(t, P, 1).Run(func(name string) {
 		// In all cases, the importing module is closed, so the error should have that as its module name.
-		requireFunctionCallExits(t, i.Name(), i.ExportedFunction("call_return_input"))
+		requireFunctionCallExits(t, i.ExportedFunction("call_return_input"))
 	}, func() { // When all functions are in-flight, re-assign the modules.
 		imported, importing = closeFn(imported, importing)
 		// Unblock all the calls
@@ -122,7 +122,7 @@ func requireFunctionCall(t *testing.T, fn api.Function) {
 	require.Equal(t, uint64(3), res[0])
 }
 
-func requireFunctionCallExits(t *testing.T, moduleName string, fn api.Function) {
+func requireFunctionCallExits(t *testing.T, fn api.Function) {
 	_, err := fn.Call(testCtx, 3)
-	require.Equal(t, sys.NewExitError(moduleName, 0), err)
+	require.Equal(t, sys.NewExitError(0), err)
 }
diff --git a/internal/testing/require/require.go b/internal/testing/require/require.go
index b926c11c..909ac704 100644
--- a/internal/testing/require/require.go
+++ b/internal/testing/require/require.go
@@ -42,6 +42,10 @@ func Contains(t TestingT, s, substr string, formatWithArgs ...interface{}) {
 //
 //   - formatWithArgs are optional. When the first is a string that contains '%', it is treated like fmt.Sprintf.
 func Equal(t TestingT, expected, actual interface{}, formatWithArgs ...interface{}) {
+	if expected == nil {
+		Nil(t, actual)
+		return
+	}
 	if equal(expected, actual) {
 		return
 	}
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index 829a992d..79c9f23d 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -271,7 +271,7 @@ func TestCallContext_CloseModuleOnCanceledOrTimeout(t *testing.T) {
 		defer done()
 
 		err := cc.FailIfClosed()
-		require.EqualError(t, err, "module \"test\" closed with context deadline exceeded")
+		require.EqualError(t, err, "module closed with context deadline exceeded")
 	})
 
 	t.Run("cancel", func(t *testing.T) {
@@ -286,7 +286,7 @@ func TestCallContext_CloseModuleOnCanceledOrTimeout(t *testing.T) {
 
 		time.Sleep(time.Second)
 		err := cc.FailIfClosed()
-		require.EqualError(t, err, "module \"test\" closed with context canceled")
+		require.EqualError(t, err, "module closed with context canceled")
 	})
 
 	t.Run("timeout over cancel", func(t *testing.T) {
@@ -316,7 +316,7 @@ func TestCallContext_CloseModuleOnCanceledOrTimeout(t *testing.T) {
 
 		time.Sleep(time.Second)
 		err := cc.FailIfClosed()
-		require.EqualError(t, err, "module \"test\" closed with context canceled")
+		require.EqualError(t, err, "module closed with context canceled")
 	})
 
 	t.Run("cancel works", func(t *testing.T) {
@@ -359,7 +359,7 @@ func TestCallContext_CloseWithCtxErr(t *testing.T) {
 		cc.CloseWithCtxErr(ctx)
 
 		err := cc.FailIfClosed()
-		require.EqualError(t, err, "module \"test\" closed with context canceled")
+		require.EqualError(t, err, "module closed with context canceled")
 	})
 
 	t.Run("context timeout", func(t *testing.T) {
@@ -373,7 +373,7 @@ func TestCallContext_CloseWithCtxErr(t *testing.T) {
 		cc.CloseWithCtxErr(ctx)
 
 		err := cc.FailIfClosed()
-		require.EqualError(t, err, "module \"test\" closed with context deadline exceeded")
+		require.EqualError(t, err, "module closed with context deadline exceeded")
 	})
 
 	t.Run("no error", func(t *testing.T) {
diff --git a/runtime_test.go b/runtime_test.go
index 1b5dfe92..331d6026 100644
--- a/runtime_test.go
+++ b/runtime_test.go
@@ -471,32 +471,73 @@ func TestRuntime_InstantiateModule_ExitError(t *testing.T) {
 	r := NewRuntime(testCtx)
 	defer r.Close(testCtx)
 
-	start := func(ctx context.Context, m api.Module) {
-		require.NoError(t, m.CloseWithExitCode(ctx, 2))
+	tests := []struct {
+		name        string
+		exitCode    uint32
+		export      bool
+		expectedErr error
+	}{
+		{
+			name:     "start: exit code 0",
+			exitCode: 0,
+		},
+		{
+			name:        "start: exit code 2",
+			exitCode:    2,
+			expectedErr: sys.NewExitError(2),
+		},
+		{
+			name:     "_start: exit code 0",
+			exitCode: 0,
+			export:   true,
+		},
+		{
+			name:        "_start: exit code 2",
+			exitCode:    2,
+			export:      true,
+			expectedErr: sys.NewExitError(2),
+		},
 	}
 
-	_, err := r.NewHostModuleBuilder("env").
-		NewFunctionBuilder().WithFunc(start).Export("exit").
-		Instantiate(testCtx)
-	require.NoError(t, err)
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			start := func(ctx context.Context, m api.Module) {
+				require.NoError(t, m.CloseWithExitCode(ctx, tc.exitCode))
+			}
 
-	one := uint32(1)
-	binary := binaryencoding.EncodeModule(&wasm.Module{
-		TypeSection:     []wasm.FunctionType{{}},
-		ImportSection:   []wasm.Import{{Module: "env", Name: "exit", Type: wasm.ExternTypeFunc, DescFunc: 0}},
-		FunctionSection: []wasm.Index{0},
-		CodeSection: []wasm.Code{
-			{Body: []byte{wasm.OpcodeCall, 0, wasm.OpcodeEnd}}, // Call the imported env.start.
-		},
-		StartSection: &one,
-	})
+			env, err := r.NewHostModuleBuilder("env").
+				NewFunctionBuilder().WithFunc(start).Export("exit").
+				Instantiate(testCtx)
+			require.NoError(t, err)
+			defer env.Close(testCtx)
 
-	// Instantiate the module, which calls the start function.
-	_, err = r.InstantiateWithConfig(testCtx, binary,
-		NewModuleConfig().WithName("call-exit"))
+			mod := &wasm.Module{
+				TypeSection:     []wasm.FunctionType{{}},
+				ImportSection:   []wasm.Import{{Module: "env", Name: "exit", Type: wasm.ExternTypeFunc, DescFunc: 0}},
+				FunctionSection: []wasm.Index{0},
+				CodeSection: []wasm.Code{
+					{Body: []byte{wasm.OpcodeCall, 0, wasm.OpcodeEnd}}, // Call the imported env.start.
+				},
+			}
+			if tc.export {
+				mod.ExportSection = []wasm.Export{
+					{Name: "_start", Type: wasm.ExternTypeFunc, Index: 1},
+				}
+			} else {
+				one := uint32(1)
+				mod.StartSection = &one
+			}
+			binary := binaryencoding.EncodeModule(mod)
+
+			// Instantiate the module, which calls the start function.
+			_, err = r.InstantiateWithConfig(testCtx, binary,
+				NewModuleConfig().WithName("call-exit"))
 
-	// Ensure the exit error propagated and didn't wrap.
-	require.Equal(t, err, sys.NewExitError("call-exit", 2))
+			// Ensure the exit error propagated and didn't wrap.
+			require.Equal(t, tc.expectedErr, err)
+		})
+	}
 }
 
 func TestRuntime_CloseWithExitCode(t *testing.T) {
@@ -559,10 +600,10 @@ func TestRuntime_CloseWithExitCode(t *testing.T) {
 
 			// Modules closed so calls fail
 			_, err = func1.Call(testCtx)
-			require.ErrorIs(t, err, sys.NewExitError("mod1", tc.exitCode))
+			require.ErrorIs(t, err, sys.NewExitError(tc.exitCode))
 
 			_, err = func2.Call(testCtx)
-			require.ErrorIs(t, err, sys.NewExitError("mod2", tc.exitCode))
+			require.ErrorIs(t, err, sys.NewExitError(tc.exitCode))
 		})
 	}
 }
diff --git a/sys/error_test.go b/sys/error_test.go
index ae601b54..b1b278c6 100644
--- a/sys/error_test.go
+++ b/sys/error_test.go
@@ -8,8 +8,7 @@ import (
 )
 
 type notExitError struct {
-	moduleName string
-	exitCode   uint32
+	exitCode uint32
 }
 
 func (e *notExitError) Error() string {
@@ -17,7 +16,7 @@ func (e *notExitError) Error() string {
 }
 
 func TestIs(t *testing.T) {
-	err := NewExitError("some module", 2)
+	err := NewExitError(2)
 	tests := []struct {
 		name    string
 		target  error
@@ -28,26 +27,15 @@ func TestIs(t *testing.T) {
 			target:  err,
 			matches: true,
 		},
-		{
-			name:    "same content",
-			target:  NewExitError("some module", 2),
-			matches: true,
-		},
-		{
-			name:    "different module name",
-			target:  NewExitError("not some module", 2),
-			matches: false,
-		},
 		{
 			name:    "different exit code",
-			target:  NewExitError("some module", 0),
+			target:  NewExitError(1),
 			matches: false,
 		},
 		{
 			name: "different type",
 			target: &notExitError{
-				moduleName: "some module",
-				exitCode:   2,
+				exitCode: 2,
 			},
 			matches: false,
 		},
@@ -64,18 +52,18 @@ func TestIs(t *testing.T) {
 
 func TestExitError_Error(t *testing.T) {
 	t.Run("timeout", func(t *testing.T) {
-		err := NewExitError("foo", ExitCodeDeadlineExceeded)
+		err := NewExitError(ExitCodeDeadlineExceeded)
 		require.Equal(t, ExitCodeDeadlineExceeded, err.ExitCode())
-		require.EqualError(t, err, "module \"foo\" closed with context deadline exceeded")
+		require.EqualError(t, err, "module closed with context deadline exceeded")
 	})
 	t.Run("cancel", func(t *testing.T) {
-		err := NewExitError("foo", ExitCodeContextCanceled)
+		err := NewExitError(ExitCodeContextCanceled)
 		require.Equal(t, ExitCodeContextCanceled, err.ExitCode())
-		require.EqualError(t, err, "module \"foo\" closed with context canceled")
+		require.EqualError(t, err, "module closed with context canceled")
 	})
 	t.Run("normal", func(t *testing.T) {
-		err := NewExitError("foo", 123)
+		err := NewExitError(123)
 		require.Equal(t, uint32(123), err.ExitCode())
-		require.EqualError(t, err, "module \"foo\" closed with exit_code(123)")
+		require.EqualError(t, err, "module closed with exit_code(123)")
 	})
 }