Repository: tetratelabs/wazero
Pull request #1074 (2023-01-29 06:23:42)

logging: adds exit scope and fixes mtim bug
This allows you to specify the exit scope amongst existing logging scopes, both in API and the CLI.

e.g for the CLI.
```bash
$ wazero run --hostlogging=exit,filesystem --mount=.:/:ro cat.wasm
```

e.g. for Go
```go
loggingCtx := context.WithValue(testCtx, experimental.FunctionListenerFactoryKey{},
	logging.NewHostLoggingListenerFactory(&log, logging.LogScopeExit|logging.LogScopeFilesystem))
```

This is helpful to know if the wasm called exit or if it exited implicitly. This is one of the few host functions that exists in three places: assemblyscript, gojs and wasi.


Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index 3cd75be6..33c273d8 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -127,7 +127,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	var hostlogging logScopesFlag
 	flags.Var(&hostlogging, "hostlogging",
 		"A comma-separated list of host function scopes to log to stderr. "+
-			"This may be specified multiple times. Supported values: clock,filesystem,poll,random")
+			"This may be specified multiple times. Supported values: clock,exit,filesystem,poll,random")
 
 	cacheDir := cacheDirFlag(flags)
 
@@ -374,6 +374,8 @@ func (f *logScopesFlag) Set(input string) error {
 			continue
 		case "clock":
 			*f |= logScopesFlag(logging.LogScopeClock)
+		case "exit":
+			*f |= logScopesFlag(logging.LogScopeExit)
 		case "filesystem":
 			*f |= logScopesFlag(logging.LogScopeFilesystem)
 		case "poll":
diff --git a/experimental/logging/log_listener.go b/experimental/logging/log_listener.go
index d47ac9b7..95993783 100644
--- a/experimental/logging/log_listener.go
+++ b/experimental/logging/log_listener.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
+	aslogging "github.com/tetratelabs/wazero/internal/assemblyscript/logging"
 	gologging "github.com/tetratelabs/wazero/internal/gojs/logging"
 	"github.com/tetratelabs/wazero/internal/logging"
 	"github.com/tetratelabs/wazero/internal/wasi_snapshot_preview1"
@@ -33,6 +34,10 @@ const (
 	LogScopeNone = logging.LogScopeNone
 	// LogScopeClock enables logging for functions such as `clock_time_get`.
 	LogScopeClock = logging.LogScopeClock
+	// LogScopeExit enables logging for functions such as `proc_exit`.
+	//
+	// Note: This includes functions that both log and exit. e.g. `abort`.
+	LogScopeExit = logging.LogScopeExit
 	// LogScopeFilesystem enables logging for functions such as `path_open`.
 	//
 	// Note: This doesn't log writes to the console.
@@ -110,11 +115,10 @@ func (f *loggingListenerFactory) NewListener(fnd api.FunctionDefinition) experim
 		}
 		pSampler, pLoggers, rLoggers = gologging.Config(fnd, f.scopes)
 	case "env":
-		// Special-case AssemblyScript which has only one relevant function.
-		if fnd.ExportNames()[0] == "seed" && !f.scopes.IsEnabled(LogScopeRandom) {
+		if !aslogging.IsInLogScope(fnd, f.scopes) {
 			return nil
 		}
-		pLoggers, rLoggers = logging.Config(fnd)
+		pSampler, pLoggers, rLoggers = aslogging.Config(fnd)
 	default:
 		// We don't know the scope of the function, so compare against all.
 		if f.scopes != logging.LogScopeAll {
diff --git a/imports/assemblyscript/assemblyscript.go b/imports/assemblyscript/assemblyscript.go
index 33ee70a9..2b7cd6c9 100644
--- a/imports/assemblyscript/assemblyscript.go
+++ b/imports/assemblyscript/assemblyscript.go
@@ -35,6 +35,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
+	. "github.com/tetratelabs/wazero/internal/assemblyscript"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/sys"
@@ -42,10 +43,6 @@ import (
 
 const (
 	i32, f64 = wasm.ValueTypeI32, wasm.ValueTypeF64
-
-	functionAbort = "abort"
-	functionTrace = "trace"
-	functionSeed  = "seed"
 )
 
 // MustInstantiate calls Instantiate or panics on error.
@@ -141,7 +138,7 @@ func (e *functionExporter) ExportFunctions(builder wazero.HostModuleBuilder) {
 //
 // See https://github.com/AssemblyScript/assemblyscript/blob/v0.26.7/std/assembly/builtins.ts#L2508
 var abortMessageEnabled = &wasm.HostFunc{
-	ExportNames: []string{functionAbort},
+	ExportNames: []string{AbortName},
 	Name:        "~lib/builtins/abort",
 	ParamTypes:  []api.ValueType{i32, i32, i32, i32},
 	ParamNames:  []string{"message", "fileName", "lineNumber", "columnNumber"},
@@ -153,7 +150,7 @@ var abortMessageEnabled = &wasm.HostFunc{
 
 var abortMessageDisabled = abortMessageEnabled.WithGoModuleFunc(abort)
 
-// abortWithMessage implements functionAbort
+// abortWithMessage implements AbortName
 func abortWithMessage(ctx context.Context, mod api.Module, stack []uint64) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 	mem := mod.Memory()
@@ -173,7 +170,7 @@ func abortWithMessage(ctx context.Context, mod api.Module, stack []uint64) {
 	abort(ctx, mod, stack)
 }
 
-// abortWithMessage implements functionAbort ignoring the message.
+// abortWithMessage implements AbortName ignoring the message.
 func abort(ctx context.Context, mod api.Module, _ []uint64) {
 	// AssemblyScript expects the exit code to be 255
 	// See https://github.com/AssemblyScript/wasi-shim/blob/v0.1.0/assembly/wasi_internal.ts#L59
@@ -191,7 +188,7 @@ var traceDisabled = traceStdout.WithWasm([]byte{wasm.OpcodeEnd})
 
 // traceStdout implements trace to the configured Stdout.
 var traceStdout = &wasm.HostFunc{
-	ExportNames: []string{functionTrace},
+	ExportNames: []string{TraceName},
 	Name:        "~lib/builtins/trace",
 	ParamTypes:  []api.ValueType{i32, i32, f64, f64, f64, f64, f64},
 	ParamNames:  []string{"message", "nArgs", "arg0", "arg1", "arg2", "arg3", "arg4"},
@@ -277,7 +274,7 @@ func formatFloat(f float64) string {
 //
 // See https://github.com/AssemblyScript/assemblyscript/blob/v0.26.7/std/assembly/builtins.ts#L2531
 var seed = &wasm.HostFunc{
-	ExportNames: []string{functionSeed},
+	ExportNames: []string{SeedName},
 	Name:        "~lib/builtins/seed",
 	ResultTypes: []api.ValueType{f64},
 	ResultNames: []string{"rand"},
diff --git a/internal/assemblyscript/logging/logging.go b/internal/assemblyscript/logging/logging.go
new file mode 100644
index 00000000..ffc127b2
--- /dev/null
+++ b/internal/assemblyscript/logging/logging.go
@@ -0,0 +1,37 @@
+package logging
+
+import (
+	"github.com/tetratelabs/wazero/api"
+	. "github.com/tetratelabs/wazero/internal/assemblyscript"
+	"github.com/tetratelabs/wazero/internal/logging"
+)
+
+func isExitFunction(fnd api.FunctionDefinition) bool {
+	return fnd.ExportNames()[0] == AbortName
+}
+
+func isRandomFunction(fnd api.FunctionDefinition) bool {
+	return fnd.ExportNames()[0] == SeedName
+}
+
+// IsInLogScope returns true if the current function is in any of the scopes.
+func IsInLogScope(fnd api.FunctionDefinition, scopes logging.LogScopes) bool {
+	if scopes.IsEnabled(logging.LogScopeExit) {
+		if isExitFunction(fnd) {
+			return true
+		}
+	}
+
+	if scopes.IsEnabled(logging.LogScopeRandom) {
+		if isRandomFunction(fnd) {
+			return true
+		}
+	}
+
+	return scopes == logging.LogScopeAll
+}
+
+func Config(fnd api.FunctionDefinition) (pSampler logging.ParamSampler, pLoggers []logging.ParamLogger, rLoggers []logging.ResultLogger) {
+	pLoggers, rLoggers = logging.Config(fnd)
+	return
+}
diff --git a/internal/assemblyscript/names.go b/internal/assemblyscript/names.go
new file mode 100644
index 00000000..549da3c6
--- /dev/null
+++ b/internal/assemblyscript/names.go
@@ -0,0 +1,7 @@
+package assemblyscript
+
+const (
+	AbortName = "abort"
+	TraceName = "trace"
+	SeedName  = "seed"
+)
diff --git a/internal/gojs/logging/logging.go b/internal/gojs/logging/logging.go
index 6509a2c4..8685b4d4 100644
--- a/internal/gojs/logging/logging.go
+++ b/internal/gojs/logging/logging.go
@@ -27,6 +27,12 @@ func IsInLogScope(fnd api.FunctionDefinition, scopes logging.LogScopes) bool {
 		}
 	}
 
+	if scopes.IsEnabled(logging.LogScopeExit) {
+		if fnd.Name() == custom.NameRuntimeWasmExit {
+			return true
+		}
+	}
+
 	if scopes.IsEnabled(logging.LogScopeFilesystem) {
 		if fnd.Name() == custom.NameSyscallValueCall {
 			return true // e.g. fs.open
@@ -54,11 +60,6 @@ func IsInLogScope(fnd api.FunctionDefinition, scopes logging.LogScopes) bool {
 
 func Config(fnd api.FunctionDefinition, scopes logging.LogScopes) (pSampler logging.ParamSampler, pLoggers []logging.ParamLogger, rLoggers []logging.ResultLogger) {
 	switch fnd.Name() {
-	// Don't log NameRuntimeWasmWrite as it is used in panics
-	case custom.NameSyscallValueCall:
-		pSampler = (&syscallValueCallParamSampler{scopes: scopes}).isSampled
-		pLoggers = []logging.ParamLogger{syscallValueCallParamLogger}
-		rLoggers = []logging.ResultLogger{syscallValueCallResultLogger}
 	case custom.NameRuntimeGetRandomData:
 		pLoggers = []logging.ParamLogger{runtimeGetRandomDataParamLogger}
 		// no results
@@ -74,6 +75,15 @@ func Config(fnd api.FunctionDefinition, scopes logging.LogScopes) (pSampler logg
 	case custom.NameRuntimeClearTimeoutEvent:
 		pLoggers = []logging.ParamLogger{runtimeClearTimeoutEventParamLogger}
 		// no results
+	case custom.NameRuntimeWasmExit:
+		pLoggers = []logging.ParamLogger{runtimeWasmExitParamLogger}
+		// no results
+	case custom.NameRuntimeWasmWrite:
+		return // Don't log NameRuntimeWasmWrite as it is used in panics
+	case custom.NameSyscallValueCall:
+		pSampler = (&syscallValueCallParamSampler{scopes: scopes}).isSampled
+		pLoggers = []logging.ParamLogger{syscallValueCallParamLogger}
+		rLoggers = []logging.ResultLogger{syscallValueCallResultLogger}
 	default: // TODO: make generic logger for gojs
 	}
 	return
@@ -92,6 +102,10 @@ func runtimeClearTimeoutEventParamLogger(_ context.Context, mod api.Module, w lo
 	writeParameter(w, custom.NameRuntimeClearTimeoutEvent, mod, params, 0)
 }
 
+func runtimeWasmExitParamLogger(_ context.Context, mod api.Module, w logging.Writer, params []uint64) {
+	writeParameter(w, custom.NameRuntimeWasmExit, mod, params, 0)
+}
+
 func writeParameter(w logging.Writer, funcName string, mod api.Module, params []uint64, paramIdx int) {
 	paramNames := custom.NameSection[funcName].ParamNames
 
diff --git a/internal/gojs/runtime.go b/internal/gojs/runtime.go
index 997f514f..d93be861 100644
--- a/internal/gojs/runtime.go
+++ b/internal/gojs/runtime.go
@@ -26,7 +26,7 @@ func wasmExit(ctx context.Context, mod api.Module, stack goarch.Stack) {
 	code := stack.ParamUint32(0)
 
 	getState(ctx).close()
-	_ = mod.CloseWithExitCode(ctx, code) // TODO: should ours be signed bit (like -1 == 255)?
+	_ = mod.CloseWithExitCode(ctx, code)
 }
 
 // WasmWrite implements runtime.wasmWrite which supports runtime.write and
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index 7ddb6d8d..01739a36 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -39,6 +39,7 @@ type LogScopes uint64
 const (
 	LogScopeNone            = LogScopes(0)
 	LogScopeClock LogScopes = 1 << iota
+	LogScopeExit
 	LogScopeFilesystem
 	LogScopePoll
 	LogScopeRandom
@@ -49,6 +50,8 @@ func scopeName(s LogScopes) string {
 	switch s {
 	case LogScopeClock:
 		return "clock"
+	case LogScopeExit:
+		return "exit"
 	case LogScopeFilesystem:
 		return "filesystem"
 	case LogScopePoll:
diff --git a/internal/wasi_snapshot_preview1/logging/logging.go b/internal/wasi_snapshot_preview1/logging/logging.go
index 378775ab..62392016 100644
--- a/internal/wasi_snapshot_preview1/logging/logging.go
+++ b/internal/wasi_snapshot_preview1/logging/logging.go
@@ -18,6 +18,10 @@ func isClockFunction(fnd api.FunctionDefinition) bool {
 	return strings.HasPrefix(fnd.Name(), "clock_")
 }
 
+func isExitFunction(fnd api.FunctionDefinition) bool {
+	return fnd.Name() == ProcExitName
+}
+
 func isFilesystemFunction(fnd api.FunctionDefinition) bool {
 	switch {
 	case strings.HasPrefix(fnd.Name(), "path_"):
@@ -44,6 +48,12 @@ func IsInLogScope(fnd api.FunctionDefinition, scopes logging.LogScopes) bool {
 		}
 	}
 
+	if scopes.IsEnabled(logging.LogScopeExit) {
+		if isExitFunction(fnd) {
+			return true
+		}
+	}
+
 	if scopes.IsEnabled(logging.LogScopeFilesystem) {
 		if isFilesystemFunction(fnd) {
 			return true
@@ -210,7 +220,7 @@ func (i logFilestat) Log(_ context.Context, mod api.Module, w logging.Writer, pa
 		w.WriteString(",size=")              //nolint
 		writeI64(w, le.Uint64(buf[32:]))
 		w.WriteString(",mtim=") //nolint
-		writeI64(w, le.Uint64(buf[40:]))
+		writeI64(w, le.Uint64(buf[48:]))
 		w.WriteString("}") //nolint
 	}
 }


Test patch:
diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go
index a3713c04..e7445b21 100644
--- a/cmd/wazero/wazero_test.go
+++ b/cmd/wazero/wazero_test.go
@@ -189,6 +189,7 @@ func TestRun(t *testing.T) {
 	bearStat, err := os.Stat(bearPath)
 	require.NoError(t, err)
 	bearMtime := bearStat.ModTime().UnixMilli()
+	bearMtimeNano := bearStat.ModTime().UnixNano()
 	// The file is world read, but windows cannot see that and reports world
 	// write. Hence, we save off the current interpretation of mode for
 	// comparison.
@@ -251,19 +252,20 @@ func TestRun(t *testing.T) {
 			expectedStdout: "pooh\n",
 		},
 		{
-			name:       "wasi hostlogging=random",
-			wasm:       wasmWasiRandomGet,
-			wazeroOpts: []string{"--hostlogging=random"},
-			expectedStderr: `==> wasi_snapshot_preview1.random_get(buf=0,buf_len=1000)
-<== errno=ESUCCESS
-`,
+			name:       "wasi hostlogging=exit",
+			wasm:       wasmCatTinygo,
+			wazeroOpts: []string{"--hostlogging=exit", fmt.Sprintf("--mount=%s:/animals:ro", bearDir)},
+			wasmArgs:   []string{"/animals/not-bear.txt"},
+			expectedStderr: `==> wasi_snapshot_preview1.proc_exit(rval=1)
+`, // ^^ proc_exit panics, which short-circuits the logger. Hence, no "<==".
+			expectedExitCode: 1,
 		},
 		{
 			name:       "wasi hostlogging=filesystem",
 			wasm:       wasmCatTinygo,
 			wazeroOpts: []string{"--hostlogging=filesystem", fmt.Sprintf("--mount=%s:/animals:ro", bearDir)},
-			wasmArgs:   []string{"/animals/not-bear.txt"},
-			expectedStderr: `==> wasi_snapshot_preview1.fd_prestat_get(fd=3)
+			wasmArgs:   []string{"/animals/bear.txt"},
+			expectedStderr: fmt.Sprintf(`==> wasi_snapshot_preview1.fd_prestat_get(fd=3)
 <== (prestat={pr_name_len=1},errno=ESUCCESS)
 ==> wasi_snapshot_preview1.fd_prestat_dir_name(fd=3)
 <== (path=/,errno=ESUCCESS)
@@ -271,10 +273,26 @@ func TestRun(t *testing.T) {
 <== (prestat=,errno=EBADF)
 ==> wasi_snapshot_preview1.fd_fdstat_get(fd=3)
 <== (stat={filetype=DIRECTORY,fdflags=,fs_rights_base=,fs_rights_inheriting=},errno=ESUCCESS)
-==> wasi_snapshot_preview1.path_open(fd=3,dirflags=SYMLINK_FOLLOW,path=animals/not-bear.txt,oflags=,fs_rights_base=,fs_rights_inheriting=,fdflags=)
-<== (opened_fd=,errno=ENOENT)
-`, // ^^ intentionally miss the file name to avoid variable mtim in logs
-			expectedExitCode: 1,
+==> wasi_snapshot_preview1.path_open(fd=3,dirflags=SYMLINK_FOLLOW,path=animals/bear.txt,oflags=,fs_rights_base=,fs_rights_inheriting=,fdflags=)
+<== (opened_fd=4,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_filestat_get(fd=4)
+<== (filestat={filetype=REGULAR_FILE,size=5,mtim=%d},errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_read(fd=4,iovs=64776,iovs_len=1)
+<== (nread=5,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_read(fd=4,iovs=64776,iovs_len=1)
+<== (nread=0,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_close(fd=4)
+<== errno=ESUCCESS
+`, bearMtimeNano),
+			expectedStdout: "pooh\n",
+		},
+		{
+			name:       "wasi hostlogging=random",
+			wasm:       wasmWasiRandomGet,
+			wazeroOpts: []string{"--hostlogging=random"},
+			expectedStderr: `==> wasi_snapshot_preview1.random_get(buf=0,buf_len=1000)
+<== errno=ESUCCESS
+`,
 		},
 		{
 			name:           "GOARCH=wasm GOOS=js",
@@ -290,6 +308,16 @@ func TestRun(t *testing.T) {
 			wasmArgs:       []string{"/bear.txt"},
 			expectedStdout: "pooh\n",
 		},
+		{
+			name:       "GOARCH=wasm GOOS=js hostlogging=exit",
+			wasm:       wasmCatGo,
+			wazeroOpts: []string{"--hostlogging=exit", fmt.Sprintf("--mount=%s:/animals:ro", bearDir)},
+			wasmArgs:   []string{"/animals/not-bear.txt"},
+			expectedStderr: `==> go.runtime.wasmExit(code=1)
+<==
+`,
+			expectedExitCode: 1,
+		},
 		{
 			name:           "GOARCH=wasm GOOS=js hostlogging=filesystem",
 			wasm:           wasmCatGo,
diff --git a/imports/assemblyscript/assemblyscript_test.go b/imports/assemblyscript/assemblyscript_test.go
index 1c7c3a55..4ea66a6e 100644
--- a/imports/assemblyscript/assemblyscript_test.go
+++ b/imports/assemblyscript/assemblyscript_test.go
@@ -16,6 +16,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	. "github.com/tetratelabs/wazero/experimental"
 	"github.com/tetratelabs/wazero/experimental/logging"
+	. "github.com/tetratelabs/wazero/internal/assemblyscript"
 	"github.com/tetratelabs/wazero/internal/testing/proxy"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/u64"
@@ -49,12 +50,12 @@ func TestAbort(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			var stderr bytes.Buffer
-			mod, r, log := requireProxyModule(t, tc.exporter, wazero.NewModuleConfig().WithStderr(&stderr), logging.LogScopeAll)
+			mod, r, log := requireProxyModule(t, tc.exporter, wazero.NewModuleConfig().WithStderr(&stderr), logging.LogScopeExit)
 			defer r.Close(testCtx)
 
 			messageOff, filenameOff := writeAbortMessageAndFileName(t, mod.Memory(), encodeUTF16("message"), encodeUTF16("filename"))
 
-			_, err := mod.ExportedFunction(functionAbort).
+			_, err := mod.ExportedFunction(AbortName).
 				Call(testCtx, uint64(messageOff), uint64(filenameOff), uint64(1), uint64(2))
 			require.Error(t, err)
 			sysErr, ok := err.(*sys.ExitError)
@@ -107,7 +108,7 @@ func TestAbort_Error(t *testing.T) {
 
 			messageOff, filenameOff := writeAbortMessageAndFileName(t, mod.Memory(), tc.messageUTF16, tc.fileNameUTF16)
 
-			_, err := mod.ExportedFunction(functionAbort).
+			_, err := mod.ExportedFunction(AbortName).
 				Call(testCtx, uint64(messageOff), uint64(filenameOff), uint64(1), uint64(2))
 			require.Error(t, err)
 			sysErr, ok := err.(*sys.ExitError)
@@ -148,7 +149,7 @@ func TestSeed(t *testing.T) {
 			mod, r, log := requireProxyModule(t, NewFunctionExporter(), wazero.NewModuleConfig(), tc.scopes)
 			defer r.Close(testCtx)
 
-			ret, err := mod.ExportedFunction(functionSeed).Call(testCtx)
+			ret, err := mod.ExportedFunction(SeedName).Call(testCtx)
 			require.NoError(t, err)
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
@@ -188,7 +189,7 @@ wasm stack trace:
 			mod, r, log := requireProxyModule(t, NewFunctionExporter(), wazero.NewModuleConfig().WithRandSource(tc.source), logging.LogScopeAll)
 			defer r.Close(testCtx)
 
-			_, err := mod.ExportedFunction(functionSeed).Call(testCtx)
+			_, err := mod.ExportedFunction(SeedName).Call(testCtx)
 			require.EqualError(t, err, tc.expectedErr)
 			require.Equal(t, `
 ==> env.~lib/builtins/seed()
@@ -317,7 +318,7 @@ func TestFunctionExporter_Trace(t *testing.T) {
 			ok = mod.Memory().Write(uint32(4), message)
 			require.True(t, ok)
 
-			_, err := mod.ExportedFunction(functionTrace).Call(testCtx, tc.params...)
+			_, err := mod.ExportedFunction(TraceName).Call(testCtx, tc.params...)
 			require.NoError(t, err)
 			require.Equal(t, tc.expected, out.String())
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
diff --git a/internal/assemblyscript/logging/logging_test.go b/internal/assemblyscript/logging/logging_test.go
new file mode 100644
index 00000000..1024e7ca
--- /dev/null
+++ b/internal/assemblyscript/logging/logging_test.go
@@ -0,0 +1,95 @@
+package logging
+
+import (
+	"testing"
+
+	"github.com/tetratelabs/wazero/api"
+	. "github.com/tetratelabs/wazero/internal/assemblyscript"
+	"github.com/tetratelabs/wazero/internal/logging"
+	"github.com/tetratelabs/wazero/internal/testing/require"
+	"github.com/tetratelabs/wazero/internal/wasm"
+)
+
+type testFunctionDefinition struct {
+	name string
+	*wasm.FunctionDefinition
+}
+
+// ExportNames implements the same method as documented on api.FunctionDefinition.
+func (f *testFunctionDefinition) ExportNames() []string {
+	return []string{f.name}
+}
+
+func TestIsInLogScope(t *testing.T) {
+	abort := &testFunctionDefinition{name: AbortName}
+	seed := &testFunctionDefinition{name: SeedName}
+	tests := []struct {
+		name     string
+		fnd      api.FunctionDefinition
+		scopes   logging.LogScopes
+		expected bool
+	}{
+		{
+			name:     "abort in LogScopeExit",
+			fnd:      abort,
+			scopes:   logging.LogScopeExit,
+			expected: true,
+		},
+		{
+			name:     "abort not in LogScopeFilesystem",
+			fnd:      abort,
+			scopes:   logging.LogScopeFilesystem,
+			expected: false,
+		},
+		{
+			name:     "abort in LogScopeExit|LogScopeFilesystem",
+			fnd:      abort,
+			scopes:   logging.LogScopeExit | logging.LogScopeFilesystem,
+			expected: true,
+		},
+		{
+			name:     "abort in LogScopeAll",
+			fnd:      abort,
+			scopes:   logging.LogScopeAll,
+			expected: true,
+		},
+		{
+			name:     "abort not in LogScopeNone",
+			fnd:      abort,
+			scopes:   logging.LogScopeNone,
+			expected: false,
+		},
+		{
+			name:     "seed not in LogScopeFilesystem",
+			fnd:      seed,
+			scopes:   logging.LogScopeFilesystem,
+			expected: false,
+		},
+		{
+			name:     "seed in LogScopeRandom|LogScopeFilesystem",
+			fnd:      seed,
+			scopes:   logging.LogScopeRandom | logging.LogScopeFilesystem,
+			expected: true,
+		},
+		{
+			name:     "seed in LogScopeAll",
+			fnd:      seed,
+			scopes:   logging.LogScopeAll,
+			expected: true,
+		},
+		{
+			name:     "seed not in LogScopeNone",
+			fnd:      seed,
+			scopes:   logging.LogScopeNone,
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.expected, IsInLogScope(tc.fnd, tc.scopes))
+		})
+	}
+}
diff --git a/internal/gojs/logging/logging_test.go b/internal/gojs/logging/logging_test.go
index c2479c9c..8bec9d3d 100644
--- a/internal/gojs/logging/logging_test.go
+++ b/internal/gojs/logging/logging_test.go
@@ -22,6 +22,7 @@ func (f *testFunctionDefinition) Name() string {
 
 func TestIsInLogScope(t *testing.T) {
 	runtimeGetRandomData := &testFunctionDefinition{name: custom.NameRuntimeGetRandomData}
+	runtimeWasmExit := &testFunctionDefinition{name: custom.NameRuntimeWasmExit}
 	syscallValueCall := &testFunctionDefinition{name: custom.NameSyscallValueCall}
 	tests := []struct {
 		name     string
@@ -29,6 +30,36 @@ func TestIsInLogScope(t *testing.T) {
 		scopes   logging.LogScopes
 		expected bool
 	}{
+		{
+			name:     "runtimeWasmExit in LogScopeExit",
+			fnd:      runtimeWasmExit,
+			scopes:   logging.LogScopeExit,
+			expected: true,
+		},
+		{
+			name:     "runtimeWasmExit not in LogScopeFilesystem",
+			fnd:      runtimeWasmExit,
+			scopes:   logging.LogScopeFilesystem,
+			expected: false,
+		},
+		{
+			name:     "runtimeWasmExit in LogScopeExit|LogScopeFilesystem",
+			fnd:      runtimeWasmExit,
+			scopes:   logging.LogScopeExit | logging.LogScopeFilesystem,
+			expected: true,
+		},
+		{
+			name:     "runtimeWasmExit not in LogScopeNone",
+			fnd:      runtimeWasmExit,
+			scopes:   logging.LogScopeNone,
+			expected: false,
+		},
+		{
+			name:     "runtimeWasmExit in LogScopeAll",
+			fnd:      runtimeWasmExit,
+			scopes:   logging.LogScopeAll,
+			expected: true,
+		},
 		{
 			name:     "runtimeGetRandomData in LogScopeRandom",
 			fnd:      runtimeGetRandomData,
diff --git a/internal/gojs/misc_test.go b/internal/gojs/misc_test.go
index 826b207d..42672aa3 100644
--- a/internal/gojs/misc_test.go
+++ b/internal/gojs/misc_test.go
@@ -13,6 +13,23 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
+func Test_exit(t *testing.T) {
+	t.Parallel()
+
+	var log bytes.Buffer
+	loggingCtx := context.WithValue(testCtx, experimental.FunctionListenerFactoryKey{},
+		logging.NewHostLoggingListenerFactory(&log, logging.LogScopeExit))
+
+	stdout, stderr, err := compileAndRun(loggingCtx, "exit", wazero.NewModuleConfig())
+
+	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)
+<==
+`, log.String()) // Note: gojs doesn't panic on exit, so you see "<=="
+}
+
 func Test_goroutine(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/gojs/testdata/main.go b/internal/gojs/testdata/main.go
index 2f88dce5..a78f0d22 100644
--- a/internal/gojs/testdata/main.go
+++ b/internal/gojs/testdata/main.go
@@ -25,6 +25,8 @@ func main() {
 		argsenv.Main()
 	case "crypto":
 		crypto.Main()
+	case "exit":
+		os.Exit(255)
 	case "fs":
 		fs.Main()
 	case "testfs":
diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go
index ad693d99..04ea7aba 100644
--- a/internal/logging/logging_test.go
+++ b/internal/logging/logging_test.go
@@ -52,6 +52,7 @@ func TestLogScopes_String(t *testing.T) {
 		{name: "none", scopes: LogScopeNone, expected: ""},
 		{name: "any", scopes: LogScopeAll, expected: "all"},
 		{name: "clock", scopes: LogScopeClock, expected: "clock"},
+		{name: "exit", scopes: LogScopeExit, expected: "exit"},
 		{name: "filesystem", scopes: LogScopeFilesystem, expected: "filesystem"},
 		{name: "poll", scopes: LogScopePoll, expected: "poll"},
 		{name: "random", scopes: LogScopeRandom, expected: "random"},
diff --git a/internal/wasi_snapshot_preview1/logging/logging_test.go b/internal/wasi_snapshot_preview1/logging/logging_test.go
index 4b97f94e..14329e22 100644
--- a/internal/wasi_snapshot_preview1/logging/logging_test.go
+++ b/internal/wasi_snapshot_preview1/logging/logging_test.go
@@ -24,6 +24,7 @@ func TestIsInLogScope(t *testing.T) {
 	clockTimeGet := &testFunctionDefinition{name: ClockTimeGetName}
 	fdRead := &testFunctionDefinition{name: FdReadName}
 	pollOneoff := &testFunctionDefinition{name: PollOneoffName}
+	procExit := &testFunctionDefinition{name: ProcExitName}
 	randomGet := &testFunctionDefinition{name: RandomGetName}
 	tests := []struct {
 		name     string
@@ -122,11 +123,35 @@ func TestIsInLogScope(t *testing.T) {
 			expected: false,
 		},
 		{
-			name:     "randomGet in LogScopeRandom",
-			fnd:      randomGet,
-			scopes:   logging.LogScopeRandom,
+			name:     "procExit in LogScopeExit",
+			fnd:      procExit,
+			scopes:   logging.LogScopeExit,
+			expected: true,
+		},
+		{
+			name:     "procExit not in LogScopeFilesystem",
+			fnd:      procExit,
+			scopes:   logging.LogScopeFilesystem,
+			expected: false,
+		},
+		{
+			name:     "procExit in LogScopeExit|LogScopeFilesystem",
+			fnd:      procExit,
+			scopes:   logging.LogScopeExit | logging.LogScopeFilesystem,
 			expected: true,
 		},
+		{
+			name:     "procExit in LogScopeAll",
+			fnd:      procExit,
+			scopes:   logging.LogScopeAll,
+			expected: true,
+		},
+		{
+			name:     "procExit not in LogScopeNone",
+			fnd:      procExit,
+			scopes:   logging.LogScopeNone,
+			expected: false,
+		},
 		{
 			name:     "randomGet not in LogScopeFilesystem",
 			fnd:      randomGet,