Repository: tetratelabs/wazero
Pull request #410 (2022-03-25 07:54:17)

wasi: exposes CloseWithExitCode to correct implementation of proc_exit
Before, when the WASI function "proc_exit" was invoked, not only did the
module stay alive, but also only the calling goroutine would receive an
error with the exit code.

```go
panic(wasi.ExitCode(exitCode))
```

Now, this corrects the implementation to actually shutdown the module,
and ensure any callers can see the exit code, not just the caller who
invoked exit.

```go
_ = m.CloseWithExitCode(exitCode)
```

The changes needed for this were numerous, but allow other
implementations, such as AssemblyScript abort handlers, to close the
same way:
* Adds `Module.CloseWithExitCode` which allows propagating an exit code
  to any callers of exported functions.
* Dispatches `Module.Close` to `Module.CloseWithExitCode(0)`
* Replace `wasi.ExitCode` with `sys.ExitError` which is returned to any
  in-flight callers. This formalizes the concept regardless of WASI.
* Ensures all "close-once" guards are tested on engines.
* Reuses the numeric field used for the closed flag to store the exit_code atomically.
* Weave in exit errors into existing test cases to reduce code heft.

This also folds the partially maintained WASI RATIONALE into the
top-level doc.


codefromthecrypt: I can't tell if the CI fail is a race condition or a field visibility problem. I'll switch the impl to use atomic just in case
codefromthecrypt: ok switched impl to pack exit code into high-order bits and read them back atomically. Anything left I think is a legit race I think and I'll update tests as needed if they fail again.
codefromthecrypt: ok externalized the lock. One interesting thing is since we are using loaduint64 in go, we probably could in JIT if we wanted JIT to figure out if it should close or not.

Meanwhile, I need to backfill some concurrency tests before marking out of draft: tomorrow or sunday.
codefromthecrypt: looking into the flakiness now. It is contained by singleModuleHighConcurrency test and fails because sometimes no error ends up returned. I'll look to see if I enforce this properly.
codefromthecrypt: at first glance there's more race going on than needed to prove the concept. "delay" while launching 1000 goroutines is very racey. I don't think we are necessarily trying to test race, rather contention when a many goroutines are outstanding while a module is closed. By changing this to a synchronous barrier, we can be far more precise in tests because we can count exactly how many should see an error and fail if that didn't happen have to make guesses due to inherent race.

I'll do that now.
codefromthecrypt: I'll bump this again after https://github.com/tetratelabs/wazero/pull/411 as the new tests will be easier to troubleshoot if failed.
codefromthecrypt: OK I owe new concurrency tests on internalwasm.Store that prove regardless of engine we both close only once, but also new calls to a closed module fail early. Otherwise, I think this is ready for review because the test flakes are gone.
codefromthecrypt: I think that even though the logic will be duplicated, it is better to keep the guard internal to the engine implementations vs extract it just to avoid copying a test twice. I'll make that change
codefromthecrypt: rebased over last few PRs. now interpreter and jit have the same internal guards and there is base infrastructure so we can prove it.

next step: backfill enginetests to cover exit code logic. eta tomorrow
codefromthecrypt: rebased and updated hammer tests. next step is to backfill normal unit tests.
codefromthecrypt: ok I added new tests into the existing ad-hoc file rather than move them in the same change as this is big enough already. I can do more code organization PRs later.
codefromthecrypt: CI isn't all that reliable, but here's the run before:

```
JIT  2392832	       540.5 ns/op
interpreter   517441	      2416 ns/op
wasmer-go   977470	      1304 ns/op
wasmtime-go   467770	      3083 ns/op
go-wasm3  1280509	       910.2 ns/op
```

and here's the run after
```
JIT  1713376	       663.9 ns/op
interpreter   372816	      3179 ns/op
wasmer-go   714549	      1702 ns/op
wasmtime-go   390840	      3837 ns/op
go-wasm3  1000000	      1197 ns/op
```

you'll notice everything was slower last time, but we're still a lot faster. my laptop is equally unreliable on a end-to-end, and in any case it isn't effecting the end result of being significantly faster than others.

On my host best in 3 before is:
```
JIT  2722590	       440.4 ns/op
interpreter   614865	      1915 ns/op
wasmer-go  1000000	      1208 ns/op
wasmtime-go   654991	      2259 ns/op
go-wasm3  1411844	       823.7 ns/op
```

and best in 3 after is
```
JIT  2680701	       437.6 ns/op
interpreter   656510	      1877 ns/op
wasmer-go  1000000	      1146 ns/op
wasmtime-go   644338	      2256 ns/op
go-wasm3  1670329	       725.3 ns/op
```

codefromthecrypt: have to redo-some of this code as once I added the module name into the error message, it became clear something was awry.
codefromthecrypt: ok I fixed error imprecision limited to one module deep. I think going deeper than that is nastier than we need to do at the moment. Note: while code looks duplicated it is because the internal structure of the interpreter and jit look a little similar. They are in fact different, and that's why tests are used to enforce behavior vs types.
codefromthecrypt: hmm.. flakey I'll look into it.
codefromthecrypt: I'll rebase this again after #419 as I think part of that found the flake cause (engine was shared between parallel tests by accident)
codefromthecrypt: figured out what was going on. basically we have no implementation to propagate imported module's closed status. I left a TODO as addressing that is too much.

Patch:
diff --git a/RATIONALE.md b/RATIONALE.md
index 6d19cfd6..ff6a0d31 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -89,6 +89,10 @@ https://github.com/bytecodealliance/wasmtime/blob/v0.29.0/crates/lightbeam/src/m
 
 ## WASI
 
+Unfortunately, (WASI Snapshot Preview 1)[https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md] is not formally defined enough, and has APIs with ambiguous semantics.
+This section describes how Wazero interprets and implements the semantics of several WASI APIs that may be interpreted differently by different wasm runtimes.
+Those APIs may affect the portability of a WASI application.
+
 ### 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
@@ -159,6 +163,23 @@ file descriptors if resources aren't managed properly. In other words, blind cop
 violate isolation or endanger the embedding process. In summary, we try to be similar to normal Go code, but often need
 act differently and document `SysConfig` is more about emulating, not necessarily performing real system calls.
 
+### FdPrestatDirName
+
+`FdPrestatDirName` is a WASI function to return the path of the pre-opened directory of a file descriptor.
+It has the following three parameters, and the third `pathLen` has ambiguous semantics.
+
+- `fd` - a file descriptor
+- `path` - the offset for the result path
+- `pathLen` - In wazero, `FdPrestatDirName` writes the result path string to `path` offset for the exact length of `pathLen`.
+
+Wasmer considers `pathLen` to be the maximum length instead of the exact length that should be written.
+See https://github.com/wasmerio/wasmer/blob/3463c51268ed551933392a4063bd4f8e7498b0f6/lib/wasi/src/syscalls/mod.rs#L764
+
+The semantics in wazero follows that of wasmtime.
+See https://github.com/bytecodealliance/wasmtime/blob/2ca01ae9478f199337cf743a6ab543e8c3f3b238/crates/wasi-common/src/snapshots/preview_1.rs#L578-L582
+
+Their semantics match when `pathLen` == the length of `path`, so in practice this difference won't matter match.
+
 ## Implementation limitations
 
 WebAssembly 1.0 (20191205) specification allows runtimes to [limit certain aspects of Wasm module or execution](https://www.w3.org/TR/2019/REC-2019/REC-wasm-core-1-20191205/#a2-implementation-limitations).
diff --git a/internal/wasi/RATIONALE.md b/internal/wasi/RATIONALE.md
deleted file mode 100644
index 9f206620..00000000
--- a/internal/wasi/RATIONALE.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Notable rationale of the WASI implementation in wazero
-
-## Semantics of WASI API
-
-Unfortunately, (WASI Snapshot Preview 1)[https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md] is not formally defined enough, and has APIs with ambiguous semantics.
-This section describes how Wazero interprets and implements the semantics of several WASI APIs that may be interpreted differently by different wasm runtimes.
-Those APIs may affect the portability of a WASI application.
-
-### FdPrestatDirName
-
-`FdPrestatDirName` is a WASI function to return the path of the pre-opened directory of a file descriptor.
-It has the following three parameters, and the third `pathLen` has ambiguous semantics.
-
-- `fd` - a file descriptor
-- `path` - the offset for the result path
-- `pathLen` - In wazero, `FdPrestatDirName` writes the result path string to `path` offset for the exact length of `pathLen`.
-
-Wasmer considers `pathLen` to be the maximum length instead of the exact length that should be written.
-See https://github.com/wasmerio/wasmer/blob/3463c51268ed551933392a4063bd4f8e7498b0f6/lib/wasi/src/syscalls/mod.rs#L764
-
-The semantics in wazero follows that of wasmtime.
-See https://github.com/bytecodealliance/wasmtime/blob/2ca01ae9478f199337cf743a6ab543e8c3f3b238/crates/wasi-common/src/snapshots/preview_1.rs#L578-L582
-
-Their semantics match when `pathLen` == the length of `path`, so in practice this difference won't matter match.
diff --git a/internal/wasi/wasi.go b/internal/wasi/wasi.go
index fb34951f..96a24eb7 100644
--- a/internal/wasi/wasi.go
+++ b/internal/wasi/wasi.go
@@ -429,11 +429,11 @@ type SnapshotPreview1 interface {
 	//
 	// * argv - is the offset to begin writing argument offsets in uint32 little-endian encoding.
 	//   * ArgsSizesGet result argc * 4 bytes are written to this offset
-	// * argvBuf - is the offset to write the null terminated arguments to ctx.Memory
+	// * argvBuf - is the offset to write the null terminated arguments to m.Memory
 	//   * ArgsSizesGet result argv_buf_size bytes are written to this offset
 	//
 	// For example, if ArgsSizesGet wrote argc=2 and argvBufSize=5 for arguments: "a" and "bc"
-	//    parameters argv=7 and argvBuf=1, this function writes the below to `ctx.Memory`:
+	//    parameters argv=7 and argvBuf=1, this function writes the below to `m.Memory`:
 	//
 	//               argvBufSize          uint32le    uint32le
 	//            +----------------+     +--------+  +--------+
@@ -448,7 +448,7 @@ type SnapshotPreview1 interface {
 	// See ArgsSizesGet
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#args_get
 	// See https://en.wikipedia.org/wiki/Null-terminated_string
-	ArgsGet(ctx wasm.Module, argv, argvBuf uint32) wasi.Errno
+	ArgsGet(m wasm.Module, argv, argvBuf uint32) wasi.Errno
 
 	// ArgsSizesGet is the WASI function named FunctionArgsSizesGet that reads command-line argument data (WithArgs)
 	// sizes.
@@ -457,11 +457,11 @@ type SnapshotPreview1 interface {
 	// corresponding sizes in uint32 little-endian encoding. If either are invalid due to memory constraints, this
 	// returns ErrnoFault.
 	//
-	// * resultArgc - is the offset to write the argument count to ctx.Memory
-	// * resultArgvBufSize - is the offset to write the null-terminated argument length to ctx.Memory
+	// * resultArgc - is the offset to write the argument count to m.Memory
+	// * resultArgvBufSize - is the offset to write the null-terminated argument length to m.Memory
 	//
 	// For example, if WithArgs are []string{"a","bc"} and
-	//    parameters resultArgc=1 and resultArgvBufSize=6, this function writes the below to `ctx.Memory`:
+	//    parameters resultArgc=1 and resultArgvBufSize=6, this function writes the below to `m.Memory`:
 	//
 	//                   uint32le       uint32le
 	//                  +--------+     +--------+
@@ -476,7 +476,7 @@ type SnapshotPreview1 interface {
 	// See ArgsGet
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#args_sizes_get
 	// See https://en.wikipedia.org/wiki/Null-terminated_string
-	ArgsSizesGet(ctx wasm.Module, resultArgc, resultArgvBufSize uint32) wasi.Errno
+	ArgsSizesGet(m wasm.Module, resultArgc, resultArgvBufSize uint32) wasi.Errno
 
 	// EnvironGet is the WASI function named FunctionEnvironGet that reads environment variables. (WithEnviron)
 	//
@@ -485,12 +485,12 @@ type SnapshotPreview1 interface {
 	//
 	// * environ - is the offset to begin writing environment variables offsets in uint32 little-endian encoding.
 	//   * EnvironSizesGet result environc * 4 bytes are written to this offset
-	// * environBuf - is the offset to write the environment variables to ctx.Memory
+	// * environBuf - is the offset to write the environment variables to m.Memory
 	//   * the format is the same as os.Environ, null terminated "key=val" entries
 	//   * EnvironSizesGet result environBufSize bytes are written to this offset
 	//
 	// For example, if EnvironSizesGet wrote environc=2 and environBufSize=9 for environment variables: "a=b", "b=cd"
-	//   and parameters environ=11 and environBuf=1, this function writes the below to `ctx.Memory`:
+	//   and parameters environ=11 and environBuf=1, this function writes the below to `m.Memory`:
 	//
 	//                           environBufSize                 uint32le    uint32le
 	//              +------------------------------------+     +--------+  +--------+
@@ -504,7 +504,7 @@ type SnapshotPreview1 interface {
 	// See EnvironSizesGet
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#environ_get
 	// See https://en.wikipedia.org/wiki/Null-terminated_string
-	EnvironGet(ctx wasm.Module, environ, environBuf uint32) wasi.Errno
+	EnvironGet(m wasm.Module, environ, environBuf uint32) wasi.Errno
 
 	// EnvironSizesGet is the WASI function named FunctionEnvironSizesGet that reads environment variable
 	// (WithEnviron) sizes.
@@ -513,11 +513,11 @@ type SnapshotPreview1 interface {
 	// corresponding sizes in uint32 little-endian encoding. If either are invalid due to memory constraints, this
 	// returns ErrnoFault.
 	//
-	// * resultEnvironc - is the offset to write the environment variable count to ctx.Memory
-	// * resultEnvironBufSize - is the offset to write the null-terminated environment variable length to ctx.Memory
+	// * resultEnvironc - is the offset to write the environment variable count to m.Memory
+	// * resultEnvironBufSize - is the offset to write the null-terminated environment variable length to m.Memory
 	//
 	// For example, if WithEnviron is []string{"a=b","b=cd"} and
-	//    parameters resultEnvironc=1 and resultEnvironBufSize=6, this function writes the below to `ctx.Memory`:
+	//    parameters resultEnvironc=1 and resultEnvironBufSize=6, this function writes the below to `m.Memory`:
 	//
 	//                   uint32le       uint32le
 	//                  +--------+     +--------+
@@ -533,20 +533,20 @@ type SnapshotPreview1 interface {
 	// See EnvironGet
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#environ_sizes_get
 	// See https://en.wikipedia.org/wiki/Null-terminated_string
-	EnvironSizesGet(ctx wasm.Module, resultEnvironc, resultEnvironBufSize uint32) wasi.Errno
+	EnvironSizesGet(m wasm.Module, resultEnvironc, resultEnvironBufSize uint32) wasi.Errno
 
 	// ClockResGet is the WASI function named FunctionClockResGet and is stubbed for GrainLang per #271
-	ClockResGet(ctx wasm.Module, id uint32, resultResolution uint32) wasi.Errno
+	ClockResGet(m wasm.Module, id uint32, resultResolution uint32) wasi.Errno
 
 	// ClockTimeGet is the WASI function named FunctionClockTimeGet that returns the time value of a clock (time.Now).
 	//
 	// * id - The clock id for which to return the time.
 	// * precision - The maximum lag (exclusive) that the returned time value may have, compared to its actual value.
-	// * resultTimestamp - the offset to write the timestamp to ctx.Memory
+	// * resultTimestamp - the offset to write the timestamp to m.Memory
 	//   * the timestamp is epoch nanoseconds encoded as a uint64 little-endian encoding.
 	//
 	// For example, if time.Now returned exactly midnight UTC 2022-01-01 (1640995200000000000), and
-	//   parameters resultTimestamp=1, this function writes the below to `ctx.Memory`:
+	//   parameters resultTimestamp=1, this function writes the below to `m.Memory`:
 	//
 	//                                      uint64le
 	//                    +------------------------------------------+
@@ -558,13 +558,13 @@ type SnapshotPreview1 interface {
 	// Note: This is similar to `clock_gettime` in POSIX.
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-clock_time_getid-clockid-precision-timestamp---errno-timestamp
 	// See https://linux.die.net/man/3/clock_gettime
-	ClockTimeGet(ctx wasm.Module, id uint32, precision uint64, resultTimestamp uint32) wasi.Errno
+	ClockTimeGet(m wasm.Module, id uint32, precision uint64, resultTimestamp uint32) wasi.Errno
 
 	// FdAdvise is the WASI function named FunctionFdAdvise and is stubbed for GrainLang per #271
-	FdAdvise(ctx wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno
+	FdAdvise(m wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno
 
 	// FdAllocate is the WASI function named FunctionFdAllocate and is stubbed for GrainLang per #271
-	FdAllocate(ctx wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno
+	FdAllocate(m wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno
 
 	// FdClose is the WASI function to close a file descriptor. This returns ErrnoBadf if the fd is invalid.
 	//
@@ -574,10 +574,10 @@ type SnapshotPreview1 interface {
 	// Note: This is similar to `close` in POSIX.
 	// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_close
 	// See https://linux.die.net/man/3/close
-	FdClose(ctx wasm.Module, fd uint32) wasi.Errno
+	FdClose(m wasm.Module, fd uint32) wasi.Errno
 
 	// FdDatasync is the WASI function named FunctionFdDatasync and is stubbed for GrainLang per #271
-	FdDatasync(ctx wasm.Module, fd uint32) wasi.Errno
+	FdDatasync(m wasm.Module, fd uint32) wasi.Errno
 
 	// FdFdstatGet is the WASI function to return the attributes of a file descriptor.
 	//
@@ -596,7 +596,7 @@ type SnapshotPreview1 interface {
 	// * fs_right_inheriting 8 bytes, to indicate the maximum rights of the fd
 	//
 	// For example, with a file corresponding with `fd` was a directory (=3) opened with `fd_read` right (=1) and no fs_flags (=0),
-	//    parameter resultFdstat=1, this function writes the below to `ctx.Memory`:
+	//    parameter resultFdstat=1, this function writes the below to `m.Memory`:
 	//
 	//                   uint16le   padding            uint64le                uint64le
 	//          uint8 --+  +--+  +-----------+  +--------------------+  +--------------------+
@@ -611,25 +611,25 @@ type SnapshotPreview1 interface {
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fdstat
 	// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_fdstat_get
 	// See https://linux.die.net/man/3/fsync
-	FdFdstatGet(ctx wasm.Module, fd, resultFdstat uint32) wasi.Errno
+	FdFdstatGet(m wasm.Module, fd, resultFdstat uint32) wasi.Errno
 
 	// FdFdstatSetFlags is the WASI function named FunctionFdFdstatSetFlags and is stubbed for GrainLang per #271
-	FdFdstatSetFlags(ctx wasm.Module, fd uint32, flags uint32) wasi.Errno
+	FdFdstatSetFlags(m wasm.Module, fd uint32, flags uint32) wasi.Errno
 
 	// FdFdstatSetRights is the WASI function named FunctionFdFdstatSetRights and is stubbed for GrainLang per #271
-	FdFdstatSetRights(ctx wasm.Module, fd uint32, fsRightsBase, fsRightsInheriting uint64) wasi.Errno
+	FdFdstatSetRights(m wasm.Module, fd uint32, fsRightsBase, fsRightsInheriting uint64) wasi.Errno
 
 	// FdFilestatGet is the WASI function named FunctionFdFilestatGet
-	FdFilestatGet(ctx wasm.Module, fd uint32, resultBuf uint32) wasi.Errno
+	FdFilestatGet(m wasm.Module, fd uint32, resultBuf uint32) wasi.Errno
 
 	// FdFilestatSetSize is the WASI function named FunctionFdFilestatSetSize
-	FdFilestatSetSize(ctx wasm.Module, fd uint32, size uint64) wasi.Errno
+	FdFilestatSetSize(m wasm.Module, fd uint32, size uint64) wasi.Errno
 
 	// FdFilestatSetTimes is the WASI function named FunctionFdFilestatSetTimes
-	FdFilestatSetTimes(ctx wasm.Module, fd uint32, atim, mtim uint64, fstFlags uint32) wasi.Errno
+	FdFilestatSetTimes(m wasm.Module, fd uint32, atim, mtim uint64, fstFlags uint32) wasi.Errno
 
 	// FdPread is the WASI function named FunctionFdPread
-	FdPread(ctx wasm.Module, fd, iovs uint32, offset uint64, resultNread uint32) wasi.Errno
+	FdPread(m wasm.Module, fd, iovs uint32, offset uint64, resultNread uint32) wasi.Errno
 
 	// FdPrestatGet is the WASI function to return the prestat data of a file descriptor.
 	//
@@ -644,7 +644,7 @@ type SnapshotPreview1 interface {
 	// which is tag zero. This simplifies the byte layout to 4 empty bytes followed by the uint32le encoded path length.
 	//
 	// For example, the directory name corresponding with `fd` was "/tmp" and
-	//    parameter resultPrestat=1, this function writes the below to `ctx.Memory`:
+	//    parameter resultPrestat=1, this function writes the below to `m.Memory`:
 	//
 	//                     padding   uint32le
 	//          uint8 --+  +-----+  +--------+
@@ -658,12 +658,12 @@ type SnapshotPreview1 interface {
 	// See FdPrestatDirName
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#prestat
 	// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_prestat_get
-	FdPrestatGet(ctx wasm.Module, fd uint32, resultPrestat uint32) wasi.Errno
+	FdPrestatGet(m wasm.Module, fd uint32, resultPrestat uint32) wasi.Errno
 
 	// FdPrestatDirName is the WASI function to return the path of the pre-opened directory of a file descriptor.
 	//
 	// * fd - the file descriptor to get the path of the pre-opened directory
-	// * path - the offset in `ctx.Memory` to write the result path
+	// * path - the offset in `m.Memory` to write the result path
 	// * pathLen - the count of bytes to write to `path`
 	//   * This should match the uint32le FdPrestatGet writes to offset `resultPrestat`+4
 	//
@@ -673,7 +673,7 @@ type SnapshotPreview1 interface {
 	// * wasi.ErrnoNametoolong - if `pathLen` is longer than the actual length of the result path
 	//
 	// For example, the directory name corresponding with `fd` was "/tmp" and
-	//    parameters path=1 pathLen=4 (correct), this function will write the below to `ctx.Memory`:
+	//    parameters path=1 pathLen=4 (correct), this function will write the below to `m.Memory`:
 	//
 	//                  pathLen
 	//              +--------------+
@@ -684,19 +684,19 @@ type SnapshotPreview1 interface {
 	// Note: ImportFdPrestatDirName shows this signature in the WebAssembly 1.0 (20191205) Text Format.
 	// See FdPrestatGet
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_prestat_dir_name
-	FdPrestatDirName(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno
+	FdPrestatDirName(m wasm.Module, fd, path, pathLen uint32) wasi.Errno
 	// TODO: FdPrestatDirName may have to return ErrnoNotdir if the type of the prestat data of `fd` is not a PrestatDir.
 
 	// FdPwrite is the WASI function named FunctionFdPwrite
-	FdPwrite(ctx wasm.Module, fd, iovs uint32, offset uint64, resultNwritten uint32) wasi.Errno
+	FdPwrite(m wasm.Module, fd, iovs uint32, offset uint64, resultNwritten uint32) wasi.Errno
 
 	// FdRead is the WASI function to read from a file descriptor.
 	//
 	// * fd - an opened file descriptor to read data from
-	// * iovs - the offset in `ctx.Memory` to read offset, size pairs representing where to write file data.
+	// * iovs - the offset in `m.Memory` to read offset, size pairs representing where to write file data.
 	//   * Both offset and length are encoded as uint32le.
 	// * iovsCount - the count of memory offset, size pairs to read sequentially starting at iovs.
-	// * resultSize - the offset in `ctx.Memory` to write the number of bytes read
+	// * resultSize - the offset in `m.Memory` to write the number of bytes read
 	//
 	// The wasi.Errno returned is wasi.ErrnoSuccess except the following error conditions:
 	// * wasi.ErrnoBadf - if `fd` is invalid
@@ -704,7 +704,7 @@ type SnapshotPreview1 interface {
 	// * wasi.ErrnoIo - if an IO related error happens during the operation
 	//
 	// For example, this function needs to first read `iovs` to determine where to write contents. If
-	//    parameters iovs=1 iovsCount=2, this function reads two offset/length pairs from `ctx.Memory`:
+	//    parameters iovs=1 iovsCount=2, this function reads two offset/length pairs from `m.Memory`:
 	//
 	//                      iovs[0]                  iovs[1]
 	//              +---------------------+   +--------------------+
@@ -717,7 +717,7 @@ type SnapshotPreview1 interface {
 	//     offset --+   length --+   offset --+  length --+
 	//
 	// If the contents of the `fd` parameter was "wazero" (6 bytes) and
-	//    parameter resultSize=26, this function writes the below to `ctx.Memory`:
+	//    parameter resultSize=26, this function writes the below to `m.Memory`:
 	//
 	//                       iovs[0].length        iovs[1].length
 	//                      +--------------+       +----+       uint32le
@@ -733,13 +733,13 @@ type SnapshotPreview1 interface {
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_read
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#iovec
 	// See https://linux.die.net/man/3/readv
-	FdRead(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
+	FdRead(m wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
 
 	// FdReaddir is the WASI function named FunctionFdReaddir
-	FdReaddir(ctx wasm.Module, fd, buf, bufLen uint32, cookie uint64, resultBufused uint32) wasi.Errno
+	FdReaddir(m wasm.Module, fd, buf, bufLen uint32, cookie uint64, resultBufused uint32) wasi.Errno
 
 	// FdRenumber is the WASI function named FunctionFdRenumber
-	FdRenumber(ctx wasm.Module, fd, to uint32) wasi.Errno
+	FdRenumber(m wasm.Module, fd, to uint32) wasi.Errno
 
 	// FdSeek is the WASI function to move the offset of a file descriptor.
 	//
@@ -749,17 +749,17 @@ type SnapshotPreview1 interface {
 	//   * If io.SeekStart, new offset == `offset`.
 	//   * If io.SeekCurrent, new offset == existing offset + `offset`.
 	//   * If io.SeekEnd, new offset == file size of `fd` + `offset`.
-	// * resultNewoffset: the offset in `ctx.Memory` to write the new offset to, relative to start of the file
+	// * resultNewoffset: the offset in `m.Memory` to write the new offset to, relative to start of the file
 	//
 	// The wasi.Errno returned is wasi.ErrnoSuccess except the following error conditions:
 	// * wasi.ErrnoBadf - if `fd` is invalid
-	// * wasi.ErrnoFault - if `resultNewoffset` is an invalid offset in `ctx.Memory` due to the memory constraint
+	// * wasi.ErrnoFault - if `resultNewoffset` is an invalid offset in `m.Memory` due to the memory constraint
 	// * wasi.ErrnoInval - if `whence` is an invalid value
 	// * wasi.ErrnoIo - if other error happens during the operation of the underying file system
 	//
 	// For example, if fd 3 is a file with offset 0, and
 	//   parameters fd=3, offset=4, whence=0 (=io.SeekStart), resultNewOffset=1,
-	//   this function writes the below to `ctx.Memory`:
+	//   this function writes the below to `m.Memory`:
 	//
 	//                           uint64le
 	//                    +--------------------+
@@ -772,21 +772,21 @@ type SnapshotPreview1 interface {
 	// Note: This is similar to `lseek` in POSIX.
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_seek
 	// See https://linux.die.net/man/3/lseek
-	FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno
+	FdSeek(m wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno
 
 	// FdSync is the WASI function named FunctionFdSync
-	FdSync(ctx wasm.Module, fd uint32) wasi.Errno
+	FdSync(m wasm.Module, fd uint32) wasi.Errno
 
 	// FdTell is the WASI function named FunctionFdTell
-	FdTell(ctx wasm.Module, fd, resultOffset uint32) wasi.Errno
+	FdTell(m wasm.Module, fd, resultOffset uint32) wasi.Errno
 
 	// FdWrite is the WASI function to write to a file descriptor.
 	//
 	// * fd - an opened file descriptor to write data to
-	// * iovs - the offset in `ctx.Memory` to read offset, size pairs representing the data to write to `fd`
+	// * iovs - the offset in `m.Memory` to read offset, size pairs representing the data to write to `fd`
 	//   * Both offset and length are encoded as uint32le.
 	// * iovsCount - the count of memory offset, size pairs to read sequentially starting at iovs.
-	// * resultSize - the offset in `ctx.Memory` to write the number of bytes written
+	// * resultSize - the offset in `m.Memory` to write the number of bytes written
 	//
 	// The wasi.Errno returned is wasi.ErrnoSuccess except the following error conditions:
 	// * wasi.ErrnoBadf - if `fd` is invalid
@@ -794,7 +794,7 @@ type SnapshotPreview1 interface {
 	// * wasi.ErrnoIo - if an IO related error happens during the operation
 	//
 	// For example, this function needs to first read `iovs` to determine what to write to `fd`. If
-	//    parameters iovs=1 iovsCount=2, this function reads two offset/length pairs from `ctx.Memory`:
+	//    parameters iovs=1 iovsCount=2, this function reads two offset/length pairs from `m.Memory`:
 	//
 	//                      iovs[0]                  iovs[1]
 	//              +---------------------+   +--------------------+
@@ -806,7 +806,7 @@ type SnapshotPreview1 interface {
 	//              |            |            |           |
 	//     offset --+   length --+   offset --+  length --+
 	//
-	// This function reads those chunks `ctx.Memory` into the `fd` sequentially.
+	// This function reads those chunks `m.Memory` into the `fd` sequentially.
 	//
 	//                       iovs[0].length        iovs[1].length
 	//                      +--------------+       +----+
@@ -815,7 +815,7 @@ type SnapshotPreview1 interface {
 	//     iovs[0].offset --^                      ^
 	//                            iovs[1].offset --+
 	//
-	// Since "wazero" was written, if parameter resultSize=26, this function writes the below to `ctx.Memory`:
+	// Since "wazero" was written, if parameter resultSize=26, this function writes the below to `m.Memory`:
 	//
 	//                      uint32le
 	//                     +--------+
@@ -829,31 +829,31 @@ type SnapshotPreview1 interface {
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#ciovec
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_write
 	// See https://linux.die.net/man/3/writev
-	FdWrite(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
+	FdWrite(m wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
 
 	// PathCreateDirectory is the WASI function named FunctionPathCreateDirectory
-	PathCreateDirectory(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno
+	PathCreateDirectory(m wasm.Module, fd, path, pathLen uint32) wasi.Errno
 
 	// PathFilestatGet is the WASI function named FunctionPathFilestatGet
-	PathFilestatGet(ctx wasm.Module, fd, flags, path, pathLen, resultBuf uint32) wasi.Errno
+	PathFilestatGet(m wasm.Module, fd, flags, path, pathLen, resultBuf uint32) wasi.Errno
 
 	// PathFilestatSetTimes is the WASI function named FunctionPathFilestatSetTimes
-	PathFilestatSetTimes(ctx wasm.Module, fd, flags, path, pathLen uint32, atim, mtime uint64, fstFlags uint32) wasi.Errno
+	PathFilestatSetTimes(m wasm.Module, fd, flags, path, pathLen uint32, atim, mtime uint64, fstFlags uint32) wasi.Errno
 
 	// PathLink is the WASI function named FunctionPathLink
-	PathLink(ctx wasm.Module, oldFd, oldFlags, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno
+	PathLink(m wasm.Module, oldFd, oldFlags, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno
 
 	// PathOpen is the WASI function to open a file or directory. This returns ErrnoBadf if the fd is invalid.
 	//
 	// * fd - the file descriptor of a directory that `path` is relative to
 	// * dirflags - flags to indicate how to resolve `path`
-	// * path - the offset in `ctx.Memory` to read the path string from
+	// * path - the offset in `m.Memory` to read the path string from
 	// * pathLen - the length of `path`
 	// * oFlags - the open flags to indicate the method by which to open the file
 	// * fsRightsBase - the rights of the newly created file descriptor for `path`
 	// * fsRightsInheriting - the rights of the file descriptors derived from the newly created file descriptor for `path`
 	// * fdFlags - the file descriptor flags
-	// * resultOpenedFd - the offset in `ctx.Memory` to write the newly created file descriptor to.
+	// * resultOpenedFd - the offset in `m.Memory` to write the newly created file descriptor to.
 	//     * The result FD value is guaranteed to be less than 2**31
 	//
 	// The wasi.Errno returned is wasi.ErrnoSuccess except the following error conditions:
@@ -865,7 +865,7 @@ type SnapshotPreview1 interface {
 	// * wasi.ErrnoIo - if other error happens during the operation of the underying file system.
 	//
 	// For example, this function needs to first read `path` to determine the file to open.
-	//    If parameters `path` = 1, `pathLen` = 6, and the path is "wazero", PathOpen reads the path from `ctx.Memory`:
+	//    If parameters `path` = 1, `pathLen` = 6, and the path is "wazero", PathOpen reads the path from `m.Memory`:
 	//
 	//                   pathLen
 	//               +------------------------+
@@ -874,7 +874,7 @@ type SnapshotPreview1 interface {
 	//        path --^
 	//
 	// Then, if parameters resultOpenedFd = 8, and this function opened a new file descriptor 5 with the given flags,
-	// this function writes the blow to `ctx.Memory`:
+	// this function writes the blow to `m.Memory`:
 	//
 	//                          uint32le
 	//                         +--------+
@@ -887,51 +887,49 @@ type SnapshotPreview1 interface {
 	// Note: The returned file descriptor is not guaranteed to be the lowest-numbered file
 	// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#path_open
 	// See https://linux.die.net/man/3/openat
-	PathOpen(ctx wasm.Module, fd, dirflags, path, pathLen, oflags uint32, fsRightsBase, fsRightsInheriting uint32, fdflags, resultOpenedFd uint32) wasi.Errno
+	PathOpen(m wasm.Module, fd, dirflags, path, pathLen, oflags uint32, fsRightsBase, fsRightsInheriting uint32, fdflags, resultOpenedFd uint32) wasi.Errno
 
 	// PathReadlink is the WASI function named FunctionPathReadlink
-	PathReadlink(ctx wasm.Module, fd, path, pathLen, buf, bufLen, resultBufused uint32) wasi.Errno
+	PathReadlink(m wasm.Module, fd, path, pathLen, buf, bufLen, resultBufused uint32) wasi.Errno
 
 	// PathRemoveDirectory is the WASI function named FunctionPathRemoveDirectory
-	PathRemoveDirectory(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno
+	PathRemoveDirectory(m wasm.Module, fd, path, pathLen uint32) wasi.Errno
 
 	// PathRename is the WASI function named FunctionPathRename
-	PathRename(ctx wasm.Module, fd, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno
+	PathRename(m wasm.Module, fd, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno
 
 	// PathSymlink is the WASI function named FunctionPathSymlink
-	PathSymlink(ctx wasm.Module, oldPath, oldPathLen, fd, newPath, newPathLen uint32) wasi.Errno
+	PathSymlink(m wasm.Module, oldPath, oldPathLen, fd, newPath, newPathLen uint32) wasi.Errno
 
 	// PathUnlinkFile is the WASI function named FunctionPathUnlinkFile
-	PathUnlinkFile(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno
+	PathUnlinkFile(m wasm.Module, fd, path, pathLen uint32) wasi.Errno
 
 	// PollOneoff is the WASI function named FunctionPollOneoff
-	PollOneoff(ctx wasm.Module, in, out, nsubscriptions, resultNevents uint32) wasi.Errno
+	PollOneoff(m wasm.Module, in, out, nsubscriptions, resultNevents uint32) wasi.Errno
 
 	// ProcExit is the WASI function that terminates the execution of the module with an exit code.
 	// An exit code of 0 indicates successful termination. The meanings of other values are not defined by WASI.
 	//
 	// * rval - The exit code.
 	//
-	// In wazero, if ProcExit is called, the calling function returns immediately, returning the given exit code as the error.
-	// You can get the exit code by casting the error to wasi.ExitCode.
-	// See wasi.ExitCode
+	// In wazero, this calls wasm.Module CloseWithExitCode.
 	//
 	// Note: ImportProcExit shows this signature in the WebAssembly 1.0 (20191205) Text Format.
 	// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#proc_exit
-	ProcExit(rval uint32)
+	ProcExit(m wasm.Module, rval uint32)
 
 	// ProcRaise is the WASI function named FunctionProcRaise
-	ProcRaise(ctx wasm.Module, sig uint32) wasi.Errno
+	ProcRaise(m wasm.Module, sig uint32) wasi.Errno
 
 	// SchedYield is the WASI function named FunctionSchedYield
-	SchedYield(ctx wasm.Module) wasi.Errno
+	SchedYield(m wasm.Module) wasi.Errno
 
 	// RandomGet is the WASI function named FunctionRandomGet that write random data in buffer (rand.Read()).
 	//
-	// * buf - is the ctx.Memory offset to write random values
+	// * buf - is the m.Memory offset to write random values
 	// * bufLen - size of random data in bytes
 	//
-	// For example, if underlying random source was seeded like `rand.NewSource(42)`, we expect `ctx.Memory` to contain:
+	// For example, if underlying random source was seeded like `rand.NewSource(42)`, we expect `m.Memory` to contain:
 	//
 	//                             bufLen (5)
 	//                    +--------------------------+
@@ -941,16 +939,16 @@ type SnapshotPreview1 interface {
 	//
 	// Note: ImportRandomGet shows this signature in the WebAssembly 1.0 (20191205) Text Format.
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-random_getbuf-pointeru8-bufLen-size---errno
-	RandomGet(ctx wasm.Module, buf, bufLen uint32) wasi.Errno
+	RandomGet(m wasm.Module, buf, bufLen uint32) wasi.Errno
 
 	// SockRecv is the WASI function named FunctionSockRecv
-	SockRecv(ctx wasm.Module, fd, riData, riDataCount, riFlags, resultRoDataLen, resultRoFlags uint32) wasi.Errno
+	SockRecv(m wasm.Module, fd, riData, riDataCount, riFlags, resultRoDataLen, resultRoFlags uint32) wasi.Errno
 
 	// SockSend is the WASI function named FunctionSockSend
-	SockSend(ctx wasm.Module, fd, siData, siDataCount, siFlags, resultSoDataLen uint32) wasi.Errno
+	SockSend(m wasm.Module, fd, siData, siDataCount, siFlags, resultSoDataLen uint32) wasi.Errno
 
 	// SockShutdown is the WASI function named FunctionSockShutdown
-	SockShutdown(ctx wasm.Module, fd, how uint32) wasi.Errno
+	SockShutdown(m wasm.Module, fd, how uint32) wasi.Errno
 }
 
 type wasiAPI struct {
@@ -1017,15 +1015,15 @@ func SnapshotPreview1Functions() (a *wasiAPI, nameToGoFunc map[string]interface{
 }
 
 // ArgsGet implements SnapshotPreview1.ArgsGet
-func (a *wasiAPI) ArgsGet(ctx wasm.Module, argv, argvBuf uint32) wasi.Errno {
-	sys := sysContext(ctx)
-	return writeOffsetsAndNullTerminatedValues(ctx.Memory(), sys.Args(), argv, argvBuf)
+func (a *wasiAPI) ArgsGet(m wasm.Module, argv, argvBuf uint32) wasi.Errno {
+	sys := sysCtx(m)
+	return writeOffsetsAndNullTerminatedValues(m.Memory(), sys.Args(), argv, argvBuf)
 }
 
 // ArgsSizesGet implements SnapshotPreview1.ArgsSizesGet
-func (a *wasiAPI) ArgsSizesGet(ctx wasm.Module, resultArgc, resultArgvBufSize uint32) wasi.Errno {
-	sys := sysContext(ctx)
-	mem := ctx.Memory()
+func (a *wasiAPI) ArgsSizesGet(m wasm.Module, resultArgc, resultArgvBufSize uint32) wasi.Errno {
+	sys := sysCtx(m)
+	mem := m.Memory()
 
 	if !mem.WriteUint32Le(resultArgc, uint32(len(sys.Args()))) {
 		return wasi.ErrnoFault
@@ -1037,15 +1035,15 @@ func (a *wasiAPI) ArgsSizesGet(ctx wasm.Module, resultArgc, resultArgvBufSize ui
 }
 
 // EnvironGet implements SnapshotPreview1.EnvironGet
-func (a *wasiAPI) EnvironGet(ctx wasm.Module, environ uint32, environBuf uint32) wasi.Errno {
-	sys := sysContext(ctx)
-	return writeOffsetsAndNullTerminatedValues(ctx.Memory(), sys.Environ(), environ, environBuf)
+func (a *wasiAPI) EnvironGet(m wasm.Module, environ uint32, environBuf uint32) wasi.Errno {
+	sys := sysCtx(m)
+	return writeOffsetsAndNullTerminatedValues(m.Memory(), sys.Environ(), environ, environBuf)
 }
 
 // EnvironSizesGet implements SnapshotPreview1.EnvironSizesGet
-func (a *wasiAPI) EnvironSizesGet(ctx wasm.Module, resultEnvironc uint32, resultEnvironBufSize uint32) wasi.Errno {
-	sys := sysContext(ctx)
-	mem := ctx.Memory()
+func (a *wasiAPI) EnvironSizesGet(m wasm.Module, resultEnvironc uint32, resultEnvironBufSize uint32) wasi.Errno {
+	sys := sysCtx(m)
+	mem := m.Memory()
 
 	if !mem.WriteUint32Le(resultEnvironc, uint32(len(sys.Environ()))) {
 		return wasi.ErrnoFault
@@ -1058,32 +1056,32 @@ func (a *wasiAPI) EnvironSizesGet(ctx wasm.Module, resultEnvironc uint32, result
 }
 
 // ClockResGet implements SnapshotPreview1.ClockResGet
-func (a *wasiAPI) ClockResGet(ctx wasm.Module, id uint32, resultResolution uint32) wasi.Errno {
+func (a *wasiAPI) ClockResGet(m wasm.Module, id uint32, resultResolution uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // ClockTimeGet implements SnapshotPreview1.ClockTimeGet
-func (a *wasiAPI) ClockTimeGet(ctx wasm.Module, id uint32, precision uint64, resultTimestamp uint32) wasi.Errno {
+func (a *wasiAPI) ClockTimeGet(m wasm.Module, id uint32, precision uint64, resultTimestamp uint32) wasi.Errno {
 	// TODO: id and precision are currently ignored.
-	if !ctx.Memory().WriteUint64Le(resultTimestamp, a.timeNowUnixNano()) {
+	if !m.Memory().WriteUint64Le(resultTimestamp, a.timeNowUnixNano()) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
 }
 
 // FdAdvise implements SnapshotPreview1.FdAdvise
-func (a *wasiAPI) FdAdvise(ctx wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno {
+func (a *wasiAPI) FdAdvise(m wasm.Module, fd uint32, offset, len uint64, resultAdvice uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdAllocate implements SnapshotPreview1.FdAllocate
-func (a *wasiAPI) FdAllocate(ctx wasm.Module, fd uint32, offset, len uint64) wasi.Errno {
+func (a *wasiAPI) FdAllocate(m wasm.Module, fd uint32, offset, len uint64) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdClose implements SnapshotPreview1.FdClose
-func (a *wasiAPI) FdClose(ctx wasm.Module, fd uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdClose(m wasm.Module, fd uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	if ok, err := sys.CloseFile(fd); err != nil {
 		return wasi.ErrnoIo
@@ -1095,13 +1093,13 @@ func (a *wasiAPI) FdClose(ctx wasm.Module, fd uint32) wasi.Errno {
 }
 
 // FdDatasync implements SnapshotPreview1.FdDatasync
-func (a *wasiAPI) FdDatasync(ctx wasm.Module, fd uint32) wasi.Errno {
+func (a *wasiAPI) FdDatasync(m wasm.Module, fd uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdFdstatGet implements SnapshotPreview1.FdFdstatGet
-func (a *wasiAPI) FdFdstatGet(ctx wasm.Module, fd uint32, resultStat uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdFdstatGet(m wasm.Module, fd uint32, resultStat uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	if _, ok := sys.OpenedFile(fd); !ok {
 		return wasi.ErrnoBadf
@@ -1110,8 +1108,8 @@ func (a *wasiAPI) FdFdstatGet(ctx wasm.Module, fd uint32, resultStat uint32) was
 }
 
 // FdPrestatGet implements SnapshotPreview1.FdPrestatGet
-func (a *wasiAPI) FdPrestatGet(ctx wasm.Module, fd uint32, resultPrestat uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdPrestatGet(m wasm.Module, fd uint32, resultPrestat uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	entry, ok := sys.OpenedFile(fd)
 	if !ok {
@@ -1119,11 +1117,11 @@ func (a *wasiAPI) FdPrestatGet(ctx wasm.Module, fd uint32, resultPrestat uint32)
 	}
 
 	// Zero-value 8-bit tag, and 3-byte zero-value paddings, which is uint32le(0) in short.
-	if !ctx.Memory().WriteUint32Le(resultPrestat, uint32(0)) {
+	if !m.Memory().WriteUint32Le(resultPrestat, uint32(0)) {
 		return wasi.ErrnoFault
 	}
 	// Write the length of the directory name at offset 4.
-	if !ctx.Memory().WriteUint32Le(resultPrestat+4, uint32(len(entry.Path))) {
+	if !m.Memory().WriteUint32Le(resultPrestat+4, uint32(len(entry.Path))) {
 		return wasi.ErrnoFault
 	}
 
@@ -1131,39 +1129,39 @@ func (a *wasiAPI) FdPrestatGet(ctx wasm.Module, fd uint32, resultPrestat uint32)
 }
 
 // FdFdstatSetFlags implements SnapshotPreview1.FdFdstatSetFlags
-func (a *wasiAPI) FdFdstatSetFlags(ctx wasm.Module, fd uint32, flags uint32) wasi.Errno {
+func (a *wasiAPI) FdFdstatSetFlags(m wasm.Module, fd uint32, flags uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdFdstatSetRights implements SnapshotPreview1.FdFdstatSetRights
 // Note: This will never be implemented per https://github.com/WebAssembly/WASI/issues/469#issuecomment-1045251844
-func (a *wasiAPI) FdFdstatSetRights(ctx wasm.Module, fd uint32, fsRightsBase, fsRightsInheriting uint64) wasi.Errno {
+func (a *wasiAPI) FdFdstatSetRights(m wasm.Module, fd uint32, fsRightsBase, fsRightsInheriting uint64) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdFilestatGet implements SnapshotPreview1.FdFilestatGet
-func (a *wasiAPI) FdFilestatGet(ctx wasm.Module, fd uint32, resultBuf uint32) wasi.Errno {
+func (a *wasiAPI) FdFilestatGet(m wasm.Module, fd uint32, resultBuf uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdFilestatSetSize implements SnapshotPreview1.FdFilestatSetSize
-func (a *wasiAPI) FdFilestatSetSize(ctx wasm.Module, fd uint32, size uint64) wasi.Errno {
+func (a *wasiAPI) FdFilestatSetSize(m wasm.Module, fd uint32, size uint64) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdFilestatSetTimes implements SnapshotPreview1.FdFilestatSetTimes
-func (a *wasiAPI) FdFilestatSetTimes(ctx wasm.Module, fd uint32, atim, mtim uint64, fstFlags uint32) wasi.Errno {
+func (a *wasiAPI) FdFilestatSetTimes(m wasm.Module, fd uint32, atim, mtim uint64, fstFlags uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdPread implements SnapshotPreview1.FdPread
-func (a *wasiAPI) FdPread(ctx wasm.Module, fd, iovs, iovsCount uint32, offset uint64, resultNread uint32) wasi.Errno {
+func (a *wasiAPI) FdPread(m wasm.Module, fd, iovs, iovsCount uint32, offset uint64, resultNread uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdPrestatDirName implements SnapshotPreview1.FdPrestatDirName
-func (a *wasiAPI) FdPrestatDirName(ctx wasm.Module, fd uint32, pathPtr uint32, pathLen uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdPrestatDirName(m wasm.Module, fd uint32, pathPtr uint32, pathLen uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	f, ok := sys.OpenedFile(fd)
 	if !ok {
@@ -1176,20 +1174,20 @@ func (a *wasiAPI) FdPrestatDirName(ctx wasm.Module, fd uint32, pathPtr uint32, p
 	}
 
 	// TODO: FdPrestatDirName may have to return ErrnoNotdir if the type of the prestat data of `fd` is not a PrestatDir.
-	if !ctx.Memory().Write(pathPtr, []byte(f.Path)[:pathLen]) {
+	if !m.Memory().Write(pathPtr, []byte(f.Path)[:pathLen]) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
 }
 
 // FdPwrite implements SnapshotPreview1.FdPwrite
-func (a *wasiAPI) FdPwrite(ctx wasm.Module, fd, iovs, iovsCount uint32, offset uint64, resultNwritten uint32) wasi.Errno {
+func (a *wasiAPI) FdPwrite(m wasm.Module, fd, iovs, iovsCount uint32, offset uint64, resultNwritten uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdRead implements SnapshotPreview1.FdRead
-func (a *wasiAPI) FdRead(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdRead(m wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	var reader io.Reader
 
@@ -1204,15 +1202,15 @@ func (a *wasiAPI) FdRead(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32
 	var nread uint32
 	for i := uint32(0); i < iovsCount; i++ {
 		iovPtr := iovs + i*8
-		offset, ok := ctx.Memory().ReadUint32Le(iovPtr)
+		offset, ok := m.Memory().ReadUint32Le(iovPtr)
 		if !ok {
 			return wasi.ErrnoFault
 		}
-		l, ok := ctx.Memory().ReadUint32Le(iovPtr + 4)
+		l, ok := m.Memory().ReadUint32Le(iovPtr + 4)
 		if !ok {
 			return wasi.ErrnoFault
 		}
-		b, ok := ctx.Memory().Read(offset, l)
+		b, ok := m.Memory().Read(offset, l)
 		if !ok {
 			return wasi.ErrnoFault
 		}
@@ -1224,25 +1222,25 @@ func (a *wasiAPI) FdRead(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32
 			return wasi.ErrnoIo
 		}
 	}
-	if !ctx.Memory().WriteUint32Le(resultSize, nread) {
+	if !m.Memory().WriteUint32Le(resultSize, nread) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
 }
 
 // FdReaddir implements SnapshotPreview1.FdReaddir
-func (a *wasiAPI) FdReaddir(ctx wasm.Module, fd, buf, bufLen uint32, cookie uint64, resultBufused uint32) wasi.Errno {
+func (a *wasiAPI) FdReaddir(m wasm.Module, fd, buf, bufLen uint32, cookie uint64, resultBufused uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdRenumber implements SnapshotPreview1.FdRenumber
-func (a *wasiAPI) FdRenumber(ctx wasm.Module, fd, to uint32) wasi.Errno {
+func (a *wasiAPI) FdRenumber(m wasm.Module, fd, to uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdSeek implements SnapshotPreview1.FdSeek
-func (a *wasiAPI) FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdSeek(m wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	var seeker io.Seeker
 	// Check to see if the file descriptor is available
@@ -1261,7 +1259,7 @@ func (a *wasiAPI) FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint3
 		return wasi.ErrnoIo
 	}
 
-	if !ctx.Memory().WriteUint32Le(resultNewoffset, uint32(newOffset)) {
+	if !m.Memory().WriteUint32Le(resultNewoffset, uint32(newOffset)) {
 		return wasi.ErrnoFault
 	}
 
@@ -1269,18 +1267,18 @@ func (a *wasiAPI) FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint3
 }
 
 // FdSync implements SnapshotPreview1.FdSync
-func (a *wasiAPI) FdSync(ctx wasm.Module, fd uint32) wasi.Errno {
+func (a *wasiAPI) FdSync(m wasm.Module, fd uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdTell implements SnapshotPreview1.FdTell
-func (a *wasiAPI) FdTell(ctx wasm.Module, fd, resultOffset uint32) wasi.Errno {
+func (a *wasiAPI) FdTell(m wasm.Module, fd, resultOffset uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // FdWrite implements SnapshotPreview1.FdWrite
-func (a *wasiAPI) FdWrite(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
-	sys := sysContext(ctx)
+func (a *wasiAPI) FdWrite(m wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
+	sys := sysCtx(m)
 
 	var writer io.Writer
 
@@ -1302,15 +1300,15 @@ func (a *wasiAPI) FdWrite(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint3
 	var nwritten uint32
 	for i := uint32(0); i < iovsCount; i++ {
 		iovPtr := iovs + i*8
-		offset, ok := ctx.Memory().ReadUint32Le(iovPtr)
+		offset, ok := m.Memory().ReadUint32Le(iovPtr)
 		if !ok {
 			return wasi.ErrnoFault
 		}
-		l, ok := ctx.Memory().ReadUint32Le(iovPtr + 4)
+		l, ok := m.Memory().ReadUint32Le(iovPtr + 4)
 		if !ok {
 			return wasi.ErrnoFault
 		}
-		b, ok := ctx.Memory().Read(offset, l)
+		b, ok := m.Memory().Read(offset, l)
 		if !ok {
 			return wasi.ErrnoFault
 		}
@@ -1320,44 +1318,44 @@ func (a *wasiAPI) FdWrite(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint3
 		}
 		nwritten += uint32(n)
 	}
-	if !ctx.Memory().WriteUint32Le(resultSize, nwritten) {
+	if !m.Memory().WriteUint32Le(resultSize, nwritten) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
 }
 
 // PathCreateDirectory implements SnapshotPreview1.PathCreateDirectory
-func (a *wasiAPI) PathCreateDirectory(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathCreateDirectory(m wasm.Module, fd, path, pathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathFilestatGet implements SnapshotPreview1.PathFilestatGet
-func (a *wasiAPI) PathFilestatGet(ctx wasm.Module, fd, flags, path, pathLen, resultBuf uint32) wasi.Errno {
+func (a *wasiAPI) PathFilestatGet(m wasm.Module, fd, flags, path, pathLen, resultBuf uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathFilestatSetTimes implements SnapshotPreview1.PathFilestatSetTimes
-func (a *wasiAPI) PathFilestatSetTimes(ctx wasm.Module, fd, flags, path, pathLen uint32, atim, mtime uint64, fstFlags uint32) wasi.Errno {
+func (a *wasiAPI) PathFilestatSetTimes(m wasm.Module, fd, flags, path, pathLen uint32, atim, mtime uint64, fstFlags uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathLink implements SnapshotPreview1.PathLink
-func (a *wasiAPI) PathLink(ctx wasm.Module, oldFd, oldFlags, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathLink(m wasm.Module, oldFd, oldFlags, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathOpen implements SnapshotPreview1.PathOpen
 // Note: Rights will never be implemented per https://github.com/WebAssembly/WASI/issues/469#issuecomment-1045251844
-func (a *wasiAPI) PathOpen(ctx wasm.Module, fd, dirflags, pathPtr, pathLen, oflags uint32, fsRightsBase,
+func (a *wasiAPI) PathOpen(m wasm.Module, fd, dirflags, pathPtr, pathLen, oflags uint32, fsRightsBase,
 	fsRightsInheriting uint64, fdflags, resultOpenedFd uint32) (errno wasi.Errno) {
-	sys := sysContext(ctx)
+	sys := sysCtx(m)
 
 	dir, ok := sys.OpenedFile(fd)
 	if !ok || dir.FS == nil {
 		return wasi.ErrnoBadf
 	}
 
-	b, ok := ctx.Memory().Read(pathPtr, pathLen)
+	b, ok := m.Memory().Read(pathPtr, pathLen)
 	if !ok {
 		return wasi.ErrnoFault
 	}
@@ -1373,7 +1371,7 @@ func (a *wasiAPI) PathOpen(ctx wasm.Module, fd, dirflags, pathPtr, pathLen, ofla
 	if newFD, ok := sys.OpenFile(entry); !ok {
 		_ = entry.File.Close()
 		return wasi.ErrnoIo
-	} else if !ctx.Memory().WriteUint32Le(resultOpenedFd, newFD) {
+	} else if !m.Memory().WriteUint32Le(resultOpenedFd, newFD) {
 		_ = entry.File.Close()
 		return wasi.ErrnoFault
 	}
@@ -1381,54 +1379,52 @@ func (a *wasiAPI) PathOpen(ctx wasm.Module, fd, dirflags, pathPtr, pathLen, ofla
 }
 
 // PathReadlink implements SnapshotPreview1.PathReadlink
-func (a *wasiAPI) PathReadlink(ctx wasm.Module, fd, path, pathLen, buf, bufLen, resultBufused uint32) wasi.Errno {
+func (a *wasiAPI) PathReadlink(m wasm.Module, fd, path, pathLen, buf, bufLen, resultBufused uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathRemoveDirectory implements SnapshotPreview1.PathRemoveDirectory
-func (a *wasiAPI) PathRemoveDirectory(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathRemoveDirectory(m wasm.Module, fd, path, pathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathRename implements SnapshotPreview1.PathRename
-func (a *wasiAPI) PathRename(ctx wasm.Module, fd, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathRename(m wasm.Module, fd, oldPath, oldPathLen, newFd, newPath, newPathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathSymlink implements SnapshotPreview1.PathSymlink
-func (a *wasiAPI) PathSymlink(ctx wasm.Module, oldPath, oldPathLen, fd, newPath, newPathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathSymlink(m wasm.Module, oldPath, oldPathLen, fd, newPath, newPathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PathUnlinkFile implements SnapshotPreview1.PathUnlinkFile
-func (a *wasiAPI) PathUnlinkFile(ctx wasm.Module, fd, path, pathLen uint32) wasi.Errno {
+func (a *wasiAPI) PathUnlinkFile(m wasm.Module, fd, path, pathLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // PollOneoff implements SnapshotPreview1.PollOneoff
-func (a *wasiAPI) PollOneoff(ctx wasm.Module, in, out, nsubscriptions, resultNevents uint32) wasi.Errno {
+func (a *wasiAPI) PollOneoff(m wasm.Module, in, out, nsubscriptions, resultNevents uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // ProcExit implements SnapshotPreview1.ProcExit
-func (a *wasiAPI) ProcExit(exitCode uint32) {
-	// Panic in a host function is caught by the engines, and the value of the panic is returned as the error of the CallFunction.
-	// See the document of SnapshotPreview1.ProcExit.
-	panic(wasi.ExitCode(exitCode))
+func (a *wasiAPI) ProcExit(m wasm.Module, exitCode uint32) {
+	_ = m.CloseWithExitCode(exitCode)
 }
 
 // ProcRaise implements SnapshotPreview1.ProcRaise
-func (a *wasiAPI) ProcRaise(ctx wasm.Module, sig uint32) wasi.Errno {
+func (a *wasiAPI) ProcRaise(m wasm.Module, sig uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // SchedYield implements SnapshotPreview1.SchedYield
-func (a *wasiAPI) SchedYield(ctx wasm.Module) wasi.Errno {
+func (a *wasiAPI) SchedYield(m wasm.Module) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // RandomGet implements SnapshotPreview1.RandomGet
-func (a *wasiAPI) RandomGet(ctx wasm.Module, buf uint32, bufLen uint32) (errno wasi.Errno) {
+func (a *wasiAPI) RandomGet(m wasm.Module, buf uint32, bufLen uint32) (errno wasi.Errno) {
 	randomBytes := make([]byte, bufLen)
 	err := a.randSource(randomBytes)
 	if err != nil {
@@ -1436,7 +1432,7 @@ func (a *wasiAPI) RandomGet(ctx wasm.Module, buf uint32, bufLen uint32) (errno w
 		return wasi.ErrnoIo
 	}
 
-	if !ctx.Memory().Write(buf, randomBytes) {
+	if !m.Memory().Write(buf, randomBytes) {
 		return wasi.ErrnoFault
 	}
 
@@ -1444,17 +1440,17 @@ func (a *wasiAPI) RandomGet(ctx wasm.Module, buf uint32, bufLen uint32) (errno w
 }
 
 // SockRecv implements SnapshotPreview1.SockRecv
-func (a *wasiAPI) SockRecv(ctx wasm.Module, fd, riData, riDataCount, riFlags, resultRoDataLen, resultRoFlags uint32) wasi.Errno {
+func (a *wasiAPI) SockRecv(m wasm.Module, fd, riData, riDataCount, riFlags, resultRoDataLen, resultRoFlags uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // SockSend implements SnapshotPreview1.SockSend
-func (a *wasiAPI) SockSend(ctx wasm.Module, fd, siData, siDataCount, siFlags, resultSoDataLen uint32) wasi.Errno {
+func (a *wasiAPI) SockSend(m wasm.Module, fd, siData, siDataCount, siFlags, resultSoDataLen uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
 // SockShutdown implements SnapshotPreview1.SockShutdown
-func (a *wasiAPI) SockShutdown(ctx wasm.Module, fd, how uint32) wasi.Errno {
+func (a *wasiAPI) SockShutdown(m wasm.Module, fd, how uint32) wasi.Errno {
 	return wasi.ErrnoNosys // stubbed for GrainLang per #271
 }
 
@@ -1477,9 +1473,9 @@ func NewAPI() *wasiAPI {
 	}
 }
 
-func sysContext(ctx wasm.Module) *internalwasm.SysContext {
-	if internal, ok := ctx.(*internalwasm.ModuleContext); !ok {
-		panic(fmt.Errorf("unsupported wasm.Module implementation: %v", ctx))
+func sysCtx(m wasm.Module) *internalwasm.SysContext {
+	if internal, ok := m.(*internalwasm.ModuleContext); !ok {
+		panic(fmt.Errorf("unsupported wasm.Module implementation: %v", m))
 	} else {
 		return internal.Sys()
 	}
diff --git a/internal/wasm/engine.go b/internal/wasm/engine.go
index c397baad..bbb88c02 100644
--- a/internal/wasm/engine.go
+++ b/internal/wasm/engine.go
@@ -1,7 +1,5 @@
 package internalwasm
 
-import "io"
-
 // Engine is a Store-scoped mechanism to compile functions declared or imported by a module.
 // This is a top-level type implemented by an interpreter or JIT compiler.
 type Engine interface {
@@ -28,7 +26,7 @@ type ModuleEngine interface {
 	// The ctx's context.Context will be the outer-most ancestor of the argument to wasm.Function.
 	Call(ctx *ModuleContext, f *FunctionInstance, params ...uint64) (results []uint64, err error)
 
-	// Closer releases the resources allocated by functions in this ModuleEngine.
-	io.Closer
-	// ^^ io.Closer not due to I/O, but to allow future static analysis to catch leaks (unclosed Closers).
+	// CloseWithExitCode releases the resources allocated by functions in this ModuleEngine and ensures new calls (Call)
+	// return a sys.ExitError with the given code. This returns false if already closed.
+	CloseWithExitCode(exitCode uint32) (bool, error)
 }
diff --git a/internal/wasm/interpreter/interpreter.go b/internal/wasm/interpreter/interpreter.go
index 8a5f4c91..8148bf3a 100644
--- a/internal/wasm/interpreter/interpreter.go
+++ b/internal/wasm/interpreter/interpreter.go
@@ -9,11 +9,13 @@ import (
 	"runtime/debug"
 	"strings"
 	"sync"
+	"sync/atomic"
 
 	"github.com/tetratelabs/wazero/internal/moremath"
 	wasm "github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/internal/wasm/buildoptions"
 	"github.com/tetratelabs/wazero/internal/wazeroir"
+	"github.com/tetratelabs/wazero/sys"
 )
 
 var callStackCeiling = buildoptions.CallStackCeiling
@@ -59,8 +61,16 @@ type moduleEngine struct {
 	compiledFunctions []*compiledFunction
 
 	// parentEngine holds *engine from which this module engine is created from.
-	parentEngine           *engine
-	importedFunctionCounts int
+	parentEngine          *engine
+	importedFunctionCount uint32
+
+	// closed is the pointer used both to guard moduleEngine.CloseWithExitCode and to store the exit code.
+	//
+	// The update value is 1 + exitCode << 32. This ensures an exit code of zero isn't mistaken for never closed.
+	//
+	// Note: Exclusively reading and updating this with atomics guarantees cross-goroutine observations.
+	// See /RATIONALE.md
+	closed uint64
 }
 
 // callEngine holds context per moduleEngine.Call, and shared across all the
@@ -152,11 +162,12 @@ type interpreterOp struct {
 
 // NewModuleEngine implements the same method as documented on internalwasm.Engine.
 func (e *engine) NewModuleEngine(name string, importedFunctions, moduleFunctions []*wasm.FunctionInstance, table *wasm.TableInstance, tableInit map[wasm.Index]wasm.Index) (wasm.ModuleEngine, error) {
+	imported := uint32(len(importedFunctions))
 	me := &moduleEngine{
-		name:                   name,
-		compiledFunctions:      make([]*compiledFunction, 0, len(importedFunctions)+len(moduleFunctions)),
-		parentEngine:           e,
-		importedFunctionCounts: len(importedFunctions),
+		name:                  name,
+		compiledFunctions:     make([]*compiledFunction, 0, imported+uint32(len(moduleFunctions))),
+		parentEngine:          e,
+		importedFunctionCount: imported,
 	}
 
 	for idx, f := range importedFunctions {
@@ -202,7 +213,7 @@ func (e *engine) NewModuleEngine(name string, importedFunctions, moduleFunctions
 // Release implements internalwasm.Engine Release
 func (me *moduleEngine) Release() error {
 	// Release all the function instances declared in this module.
-	for _, cf := range me.compiledFunctions[me.importedFunctionCounts:] {
+	for _, cf := range me.compiledFunctions[me.importedFunctionCount:] {
 		me.parentEngine.deleteCompiledFunction(cf.funcInstance)
 	}
 	return nil
@@ -489,9 +500,13 @@ func (me *moduleEngine) Name() string {
 
 // Call implements the same method as documented on internalwasm.ModuleEngine.
 func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance, params ...uint64) (results []uint64, err error) {
+	// Note: The input parameters are pre-validated, so a compiled function is only absent on close. Updates to
+	// compiledFunctions on close aren't locked, neither is this read.
 	compiled := me.compiledFunctions[f.Index]
-	if compiled == nil {
-		err = fmt.Errorf("function not compiled")
+	if compiled == nil { // Lazy check the cause as it could be because the module was already closed.
+		if err = failIfClosed(me); err == nil {
+			panic(fmt.Errorf("BUG: %s.compiledFunctions[%d] was nil before close", me.name, f.Index))
+		}
 		return
 	}
 
@@ -503,6 +518,12 @@ func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance,
 
 	ce := me.newCallEngine()
 	defer func() {
+		// If the module closed during the call, and the call didn't err for another reason, set an ExitError.
+		if err == nil {
+			err = failIfClosed(me)
+		}
+		// TODO: ^^ Will not fail if the function was imported from a closed module.
+
 		if v := recover(); v != nil {
 			if buildoptions.IsDebugMode {
 				debug.PrintStack()
@@ -547,6 +568,14 @@ func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance,
 	return
 }
 
+// failIfClosed returns a sys.ExitError if moduleEngine.CloseWithExitCode was called.
+func failIfClosed(me *moduleEngine) error {
+	if closed := atomic.LoadUint64(&me.closed); closed != 0 {
+		return sys.NewExitError(me.name, uint32(closed>>32)) // Unpack the high order bits as the exit code.
+	}
+	return nil
+}
+
 func (ce *callEngine) callHostFunc(ctx *wasm.ModuleContext, f *compiledFunction) {
 	tp := f.hostFn.Type()
 	in := make([]reflect.Value, tp.NumIn())
@@ -1566,15 +1595,19 @@ func (ce *callEngine) callNativeFunc(ctx *wasm.ModuleContext, f *compiledFunctio
 	ce.popFrame()
 }
 
-// Close implements the same method as documented on internalwasm.ModuleEngine.
-func (me *moduleEngine) Close() error {
+// CloseWithExitCode implements the same method as documented on internalwasm.ModuleEngine.
+func (me *moduleEngine) CloseWithExitCode(exitCode uint32) (bool, error) {
+	closed := uint64(1) + uint64(exitCode)<<32 // Store exitCode as high-order bits.
+	if !atomic.CompareAndSwapUint64(&me.closed, 0, closed) {
+		return false, nil
+	}
 	me.doClose()
-	return nil
+	return true, nil
 }
 
 // doClose releases all the function instances declared in this module.
 func (me *moduleEngine) doClose() {
-	for _, cf := range me.compiledFunctions[me.importedFunctionCounts:] {
+	for _, cf := range me.compiledFunctions[me.importedFunctionCount:] {
 		me.parentEngine.deleteCompiledFunction(cf.funcInstance)
 	}
 }
diff --git a/internal/wasm/jit/engine.go b/internal/wasm/jit/engine.go
index 19a4bb54..392fd4cf 100644
--- a/internal/wasm/jit/engine.go
+++ b/internal/wasm/jit/engine.go
@@ -14,6 +14,7 @@ import (
 	wasm "github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/internal/wasm/buildoptions"
 	"github.com/tetratelabs/wazero/internal/wazeroir"
+	"github.com/tetratelabs/wazero/sys"
 )
 
 type (
@@ -37,8 +38,8 @@ type (
 		compiledFunctions []*compiledFunction
 
 		// parentEngine holds *engine from which this module engine is created from.
-		parentEngine           *engine
-		importedFunctionCounts int
+		parentEngine          *engine
+		importedFunctionCount uint32
 
 		// closed is the pointer used both to guard moduleEngine.CloseWithExitCode and to store the exit code.
 		//
@@ -356,12 +357,12 @@ func releaseCompiledFunction(compiledFn *compiledFunction) {
 
 // NewModuleEngine implements the same method as documented on internalwasm.Engine.
 func (e *engine) NewModuleEngine(name string, importedFunctions, moduleFunctions []*wasm.FunctionInstance, table *wasm.TableInstance, tableInit map[wasm.Index]wasm.Index) (wasm.ModuleEngine, error) {
-	imported := len(importedFunctions)
+	imported := uint32(len(importedFunctions))
 	me := &moduleEngine{
-		name:                   name,
-		compiledFunctions:      make([]*compiledFunction, 0, imported+len(moduleFunctions)),
-		parentEngine:           e,
-		importedFunctionCounts: imported,
+		name:                  name,
+		compiledFunctions:     make([]*compiledFunction, 0, imported+uint32(len(moduleFunctions))),
+		parentEngine:          e,
+		importedFunctionCount: imported,
 	}
 
 	for idx, f := range importedFunctions {
@@ -427,7 +428,7 @@ func (e *engine) NewModuleEngine(name string, importedFunctions, moduleFunctions
 //    * Host functions are the only unknowns (ex can do I/O) so they may need to be tracked.
 func (me *moduleEngine) doClose() {
 	// Release all the function instances declared in this module.
-	for _, cf := range me.compiledFunctions[me.importedFunctionCounts:] {
+	for _, cf := range me.compiledFunctions[me.importedFunctionCount:] {
 		// NOTE: we still rely on the finalizer of cf until the notes on this function are addressed.
 		me.parentEngine.deleteCompiledFunction(cf.source)
 	}
@@ -459,9 +460,13 @@ func (me *moduleEngine) Name() string {
 
 // Call implements the same method as documented on internalwasm.ModuleEngine.
 func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance, params ...uint64) (results []uint64, err error) {
+	// Note: The input parameters are pre-validated, so a compiled function is only absent on close. Updates to
+	// compiledFunctions on close aren't locked, neither is this read.
 	compiled := me.compiledFunctions[f.Index]
-	if compiled == nil {
-		err = fmt.Errorf("function not compiled")
+	if compiled == nil { // Lazy check the cause as it could be because the module was already closed.
+		if err = failIfClosed(me); err == nil {
+			panic(fmt.Errorf("BUG: %s.compiledFunctions[%d] was nil before close", me.name, f.Index))
+		}
 		return
 	}
 
@@ -478,6 +483,12 @@ func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance,
 	// and we have to make sure that all the runtime errors, including the one happening inside
 	// host functions, will be captured as errors, not panics.
 	defer func() {
+		// If the module closed during the call, and the call didn't err for another reason, set an ExitError.
+		if err == nil {
+			err = failIfClosed(me)
+		}
+		// TODO: ^^ Will not fail if the function was imported from a closed module.
+
 		if v := recover(); v != nil {
 			if buildoptions.IsDebugMode {
 				debug.PrintStack()
@@ -522,15 +533,24 @@ func (me *moduleEngine) Call(ctx *wasm.ModuleContext, f *wasm.FunctionInstance,
 	return
 }
 
-// Close implements the same method as documented on internalwasm.ModuleEngine.
-func (me *moduleEngine) Close() error {
-	if !atomic.CompareAndSwapUint64(&me.closed, 0, 1) {
-		return nil
+// failIfClosed returns a sys.ExitError if moduleEngine.CloseWithExitCode was called.
+func failIfClosed(me *moduleEngine) error {
+	if closed := atomic.LoadUint64(&me.closed); closed != 0 {
+		return sys.NewExitError(me.name, uint32(closed>>32)) // Unpack the high order bits as the exit code.
 	}
-	me.doClose()
 	return nil
 }
 
+// CloseWithExitCode implements the same method as documented on internalwasm.ModuleEngine.
+func (me *moduleEngine) CloseWithExitCode(exitCode uint32) (bool, error) {
+	closed := uint64(1) + uint64(exitCode)<<32 // Store exitCode as high-order bits.
+	if !atomic.CompareAndSwapUint64(&me.closed, 0, closed) {
+		return false, nil
+	}
+	me.doClose()
+	return true, nil
+}
+
 func NewEngine() wasm.Engine {
 	return newEngine()
 }
diff --git a/internal/wasm/module_context.go b/internal/wasm/module_context.go
index 228d3e28..abcb41b7 100644
--- a/internal/wasm/module_context.go
+++ b/internal/wasm/module_context.go
@@ -64,14 +64,17 @@ func (m *ModuleContext) WithContext(ctx context.Context) publicwasm.Module {
 	return m
 }
 
-// Close implements the same method as documented on wasm.Module
-// Note: When there are multiple errors, the error returned is the last one.
+// Close implements the same method as documented on wasm.Module.
 func (m *ModuleContext) Close() (err error) {
-	err = m.store.CloseModule(m.Name())
-	if sys := m.sys; sys == nil { // ex from ModuleBuilder
-		return
-	} else if err2 := m.sys.Close(); err2 != nil {
-		err = err2
+	return m.CloseWithExitCode(0)
+}
+
+// CloseWithExitCode implements the same method as documented on wasm.Module.
+func (m *ModuleContext) CloseWithExitCode(exitCode uint32) (err error) {
+	if err = m.store.CloseModuleWithExitCode(m.module.Name, exitCode); err != nil {
+		return err
+	} else if sys := m.sys; sys != nil { // ex nil if from ModuleBuilder
+		return sys.Close()
 	}
 	return
 }
@@ -110,13 +113,15 @@ func (f *FunctionInstance) ResultTypes() []publicwasm.ValueType {
 }
 
 // Call implements wasm.Function Call
-func (f *FunctionInstance) Call(ctx publicwasm.Module, params ...uint64) ([]uint64, error) {
+func (f *FunctionInstance) Call(m publicwasm.Module, params ...uint64) (ret []uint64, err error) {
 	mod := f.Module
-	if modCtx, ok := ctx.(*ModuleContext); !ok { // allow nil to substitute for the defining module
-		return mod.Engine.Call(mod.Ctx, f, params...)
-	} else { // TODO: check if the importing context is correct
-		return mod.Engine.Call(modCtx, f, params...)
+	modCtx, ok := m.(*ModuleContext)
+	if ok {
+		// TODO: check if the importing context is correct
+	} else { // allow nil to substitute for the defining module
+		modCtx = mod.Ctx
 	}
+	return mod.Engine.Call(modCtx, f, params...)
 }
 
 // ExportedGlobal implements wasm.Module ExportedGlobal
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index 0a2705ed..8d01ed83 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -332,14 +332,16 @@ func (s *Store) Instantiate(ctx context.Context, module *Module, name string, sy
 	return m.Ctx, nil
 }
 
-// CloseModule deallocates resources if a module with the given name exists.
-func (s *Store) CloseModule(moduleName string) (err error) {
-	m := s.module(moduleName)
-	if m != nil {
-		err = m.Engine.Close()
+// CloseModuleWithExitCode deallocates resources if a module with the given name exists.
+func (s *Store) CloseModuleWithExitCode(moduleName string, exitCode uint32) error {
+	if m := s.module(moduleName); m == nil {
+		return nil
+	} else if ok, err := m.Engine.CloseWithExitCode(exitCode); err != nil {
+		return err
+	} else if ok {
 		s.deleteModule(moduleName)
 	}
-	return
+	return nil
 }
 
 // deleteModule makes the moduleName available for instantiation again.
diff --git a/sys/sys.go b/sys/sys.go
new file mode 100644
index 00000000..25ed00a2
--- /dev/null
+++ b/sys/sys.go
@@ -0,0 +1,44 @@
+// Package sys includes constants and types used by both public and internal APIs.
+package sys
+
+import (
+	"fmt"
+)
+
+// ExitError is returned to a caller of wasm.Function still running when wasm.Module CloseWithExitCode was invoked.
+// ExitCode zero value means success, while any other value is an error.
+//
+// Here's an example of how to get the exit code:
+//	main := module.ExportedFunction("main")
+//	if err := main(nil); err != nil {
+//		if exitErr, ok := err.(*wazero.ExitError); ok {
+//			// If your main function expects to exit, this could be ok if Code == 0
+//		}
+//	--snip--
+//
+// Note: While possible the reason of this was "proc_exit" from wasi.ModuleSnapshotPreview1, it could be from other host
+// functions, for example an AssemblyScript's abort handler, or any arbitrary caller of CloseWithExitCode.
+// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#proc_exit
+// See https://www.assemblyscript.org/concepts.html#special-imports
+type ExitError struct {
+	moduleName string
+	exitCode   uint32
+}
+
+func NewExitError(moduleName string, exitCode uint32) *ExitError {
+	return &ExitError{moduleName: moduleName, exitCode: exitCode}
+}
+
+// ModuleName is the wasm.Module that was closed.
+func (e *ExitError) ModuleName() string {
+	return e.moduleName
+}
+
+// ExitCode returns zero on success, and an arbitrary value otherwise.
+func (e *ExitError) ExitCode() uint32 {
+	return e.exitCode
+}
+
+func (e *ExitError) Error() string {
+	return fmt.Sprintf("module %q closed with exit_code(%d)", e.moduleName, e.exitCode)
+}
diff --git a/wasi/wasi.go b/wasi/wasi.go
index 1fbf0aae..9a7b376f 100644
--- a/wasi/wasi.go
+++ b/wasi/wasi.go
@@ -266,24 +266,3 @@ var errnoToString = [...]string{
 	ErrnoXdev:           "EXDEV",
 	ErrnoNotcapable:     "ENOTCAPABLE",
 }
-
-// ExitCode is an arbitrary uint32 number returned by proc_exit.
-// An exit code of 0 indicates successful termination. The meanings of other values are not defined by WASI.
-//
-// In wazero, if ProcExit is called, the calling function returns immediately, returning the given exit code as the error.
-// You can get the exit code by casting the error as follows.
-//
-//   wasmFunction := m.ExportedFunction(/* omitted */)  // Some function which may call proc_exit
-//   err := wasmFunction()
-//   var exitCode wasi.ExitCode
-//   if errors.As(err, &exitCode) {
-//   	// The function is terminated by proc_exit.
-//   }
-//
-// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#exitcode
-// See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#proc_exit
-type ExitCode uint32
-
-func (err ExitCode) Error() string {
-	return fmt.Sprintf("terminated by proc_exit(%d)", uint32(err))
-}
diff --git a/wasm/wasm.go b/wasm/wasm.go
index fc1ca7fc..5645f915 100644
--- a/wasm/wasm.go
+++ b/wasm/wasm.go
@@ -4,7 +4,6 @@ package wasm
 import (
 	"context"
 	"fmt"
-	"io"
 	"math"
 )
 
@@ -66,10 +65,20 @@ type Module interface {
 	// Name is the name this module was instantiated with. Exported functions can be imported with this name.
 	Name() string
 
-	// Closer (Close) releases resources allocated for this Module. Using this while having outstanding function calls
-	// is safe. After calling this function, re-instantiating a module for the same name is allowed.
-	io.Closer
-	// ^^ io.Closer not due to I/O, but to allow future static analysis to catch leaks (unclosed Closers).
+	// Close is a convenience that invokes CloseWithExitCode with zero.
+	Close() error
+	// ^^ not io.Closer as the rationale (static analysis of leaks) is invalid when there are multiple close methods.
+
+	// CloseWithExitCode releases resources allocated for this Module. Use a non-zero exitCode parameter to indicate a
+	// failure to ExportedFunction callers.
+	//
+	// The error returned here, if present, is about resource de-allocation (such as I/O errors). Only the last error is
+	// returned, so a non-nil return means at least one error happened. Regardless of error, this module instance will
+	// be removed, making its name available again.
+	//
+	// Calling this inside a host function is safe, and may cause ExportedFunction callers to receive a sys.ExitError
+	// with the exitCode.
+	CloseWithExitCode(exitCode uint32) error
 
 	// Context returns any propagated context from the Runtime or a prior function call.
 	//
@@ -116,19 +125,23 @@ type Function interface {
 	// encoded according to ResultTypes. An error is returned for any failure looking up or invoking the function
 	// including signature mismatch.
 	//
-	// If the `ctx` is nil, it defaults to the module the function was defined in.
+	// If `m` is nil, it defaults to the module the function was defined in.
 	//
 	// To override context propagation, use Module.WithContext
-	//	fn = mod.ExportedFunction("fib")
-	//	results, err := fn(mod.WithContext(ctx), 5)
+	//	fn = m.ExportedFunction("fib")
+	//	results, err := fn(m.WithContext(ctx), 5)
 	//	--snip--
 	//
 	// To ensure context propagation in a host function body, pass the `ctx` parameter:
-	//	hostFunction := func(ctx wasm.Module, offset, byteCount uint32) uint32 {
-	//		fn = ctx.ExportedFunction("__read")
-	//		results, err := fn(ctx, offset, byteCount)
+	//	hostFunction := func(m wasm.Module, offset, byteCount uint32) uint32 {
+	//		fn = m.ExportedFunction("__read")
+	//		results, err := fn(m, offset, byteCount)
 	//	--snip--
-	Call(ctx Module, params ...uint64) ([]uint64, error)
+	//
+	// Note: If Module.Close or Module.CloseWithExitCode were invoked during this call, the error returned may be a
+	// sys.ExitError. Interpreting this is specific to the module. For example, some "main" functions always call a
+	// function that exits.
+	Call(m Module, params ...uint64) ([]uint64, error)
 }
 
 // Global is a WebAssembly 1.0 (20191205) global exported from an instantiated module (wazero.Runtime InstantiateModule).


Test patch:
diff --git a/internal/testing/enginetest/enginetest.go b/internal/testing/enginetest/enginetest.go
index 0eccee23..57dce169 100644
--- a/internal/testing/enginetest/enginetest.go
+++ b/internal/testing/enginetest/enginetest.go
@@ -38,7 +38,7 @@ func RunTestEngine_NewModuleEngine(t *testing.T, et EngineTester) {
 	t.Run("sets module name", func(t *testing.T) {
 		me, err := e.NewModuleEngine(t.Name(), nil, nil, nil, nil)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, me)
+		defer closeModuleEngineWithExitCode(t, me, 0)
 		require.Equal(t, t.Name(), me.Name())
 	})
 }
@@ -62,7 +62,7 @@ func RunTestModuleEngine_Call(t *testing.T, et EngineTester) {
 	// Compile the module
 	me, err := e.NewModuleEngine(module.Name, nil, module.Functions, nil, nil)
 	require.NoError(t, err)
-	defer closeModuleEngine(t, me)
+	defer closeModuleEngineWithExitCode(t, me, 0)
 
 	// Create a call context which links the module to the module-engine compiled from it.
 	ctx := newModuleContext(module, me)
@@ -83,12 +83,6 @@ func RunTestModuleEngine_Call(t *testing.T, et EngineTester) {
 	})
 }
 
-// closeModuleEngine allows unit tests to check `Close` didn't err.
-func closeModuleEngine(t *testing.T, me wasm.ModuleEngine) {
-	err := me.Close()
-	require.NoError(t, err)
-}
-
 func RunTestEngine_NewModuleEngine_InitTable(t *testing.T, et EngineTester) {
 	e := et.NewEngine()
 
@@ -101,7 +95,7 @@ func RunTestEngine_NewModuleEngine_InitTable(t *testing.T, et EngineTester) {
 		// Instantiate the module, which has nothing but an empty table.
 		me, err := e.NewModuleEngine(t.Name(), importedFunctions, moduleFunctions, table, tableInit)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, me)
+		defer closeModuleEngineWithExitCode(t, me, 0)
 
 		// Since there are no elements to initialize, we expect the table to be nil.
 		require.Equal(t, table.Table, make([]interface{}, 2))
@@ -121,7 +115,7 @@ func RunTestEngine_NewModuleEngine_InitTable(t *testing.T, et EngineTester) {
 		// Instantiate the module whose table points to its own functions.
 		me, err := e.NewModuleEngine(t.Name(), importedFunctions, moduleFunctions, table, tableInit)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, me)
+		defer closeModuleEngineWithExitCode(t, me, 0)
 
 		// The functions mapped to the table are defined in the same moduleEngine
 		require.Equal(t, table.Table, et.InitTable(me, table.Min, tableInit))
@@ -141,12 +135,12 @@ func RunTestEngine_NewModuleEngine_InitTable(t *testing.T, et EngineTester) {
 		// Imported functions are compiled before the importing module is instantiated.
 		imported, err := e.NewModuleEngine(t.Name(), nil, importedFunctions, nil, nil)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, imported)
+		defer closeModuleEngineWithExitCode(t, imported, 0)
 
 		// Instantiate the importing module, which is whose table is initialized.
 		importing, err := e.NewModuleEngine(t.Name(), importedFunctions, moduleFunctions, table, tableInit)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, importing)
+		defer closeModuleEngineWithExitCode(t, importing, 0)
 
 		// A moduleEngine's compiled function slice includes its imports, so the offsets is absolute.
 		require.Equal(t, table.Table, et.InitTable(importing, table.Min, tableInit))
@@ -171,12 +165,12 @@ func RunTestEngine_NewModuleEngine_InitTable(t *testing.T, et EngineTester) {
 		// Imported functions are compiled before the importing module is instantiated.
 		imported, err := e.NewModuleEngine(t.Name(), nil, importedFunctions, nil, nil)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, imported)
+		defer closeModuleEngineWithExitCode(t, imported, 0)
 
 		// Instantiate the importing module, which is whose table is initialized.
 		importing, err := e.NewModuleEngine(t.Name(), importedFunctions, moduleFunctions, table, tableInit)
 		require.NoError(t, err)
-		defer closeModuleEngine(t, importing)
+		defer closeModuleEngineWithExitCode(t, importing, 0)
 
 		// A moduleEngine's compiled function slice includes its imports, so the offsets are absolute.
 		require.Equal(t, table.Table, et.InitTable(importing, table.Min, tableInit))
@@ -249,3 +243,10 @@ func newModuleContext(module *wasm.ModuleInstance, engine wasm.ModuleEngine) *wa
 	// callEngineModuleContextModuleInstanceAddressOffset
 	return wasm.NewModuleContext(context.Background(), nil, module, nil)
 }
+
+// closeModuleEngineWithExitCode allows unit tests to check `CloseWithExitCode` didn't err.
+func closeModuleEngineWithExitCode(t *testing.T, me wasm.ModuleEngine, exitCode uint32) bool {
+	ok, err := me.CloseWithExitCode(exitCode)
+	require.NoError(t, err)
+	return ok
+}
diff --git a/internal/wasi/wasi_test.go b/internal/wasi/wasi_test.go
index 01ff6265..f147cee3 100644
--- a/internal/wasi/wasi_test.go
+++ b/internal/wasi/wasi_test.go
@@ -20,6 +20,7 @@ import (
 	wasm "github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/internal/wasm/interpreter"
 	"github.com/tetratelabs/wazero/internal/wasm/text"
+	"github.com/tetratelabs/wazero/sys"
 	"github.com/tetratelabs/wazero/wasi"
 	publicwasm "github.com/tetratelabs/wazero/wasm"
 )
@@ -28,7 +29,7 @@ const moduleName = "test"
 
 func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
+	sysCtx, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
 	argv := uint32(7)    // arbitrary offset
@@ -42,7 +43,7 @@ func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 		'?', // stopped after encoding
 	}
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ArgsGet", func(t *testing.T) {
@@ -73,10 +74,10 @@ func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
+	sysCtx, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -124,7 +125,7 @@ func TestSnapshotPreview1_ArgsGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
+	sysCtx, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
 	resultArgc := uint32(1)        // arbitrary offset
@@ -137,7 +138,7 @@ func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 		'?', // stopped after encoding
 	}
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ArgsSizesGet", func(t *testing.T) {
@@ -168,10 +169,10 @@ func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsSizesGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
+	sysCtx, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -216,7 +217,7 @@ func TestSnapshotPreview1_ArgsSizesGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
+	sysCtx, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	resultEnviron := uint32(11)   // arbitrary offset
@@ -231,7 +232,7 @@ func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 		'?', // stopped after encoding
 	}
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.EnvironGet", func(t *testing.T) {
@@ -262,10 +263,10 @@ func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, []string{"a=bc", "b=cd"}, nil)
+	sysCtx, err := newSysContext(nil, []string{"a=bc", "b=cd"}, nil)
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -312,7 +313,7 @@ func TestSnapshotPreview1_EnvironGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
+	sysCtx, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	resultEnvironc := uint32(1)       // arbitrary offset
@@ -325,7 +326,7 @@ func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 		'?', // stopped after encoding
 	}
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.EnvironSizesGet", func(t *testing.T) {
@@ -356,10 +357,10 @@ func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironSizesGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
+	sysCtx, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -405,10 +406,10 @@ func TestSnapshotPreview1_EnvironSizesGet_Errors(t *testing.T) {
 // TestSnapshotPreview1_ClockResGet only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_ClockResGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionClockResGet, ImportClockResGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionClockResGet, ImportClockResGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ClockResGet", func(t *testing.T) {
@@ -433,10 +434,10 @@ func TestSnapshotPreview1_ClockTimeGet(t *testing.T) {
 	}
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	a.timeNowUnixNano = func() uint64 { return epochNanos }
@@ -471,10 +472,10 @@ func TestSnapshotPreview1_ClockTimeGet_Errors(t *testing.T) {
 	epochNanos := uint64(1640995200000000000) // midnight UTC 2022-01-01
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	a.timeNowUnixNano = func() uint64 { return epochNanos }
@@ -512,10 +513,10 @@ func TestSnapshotPreview1_ClockTimeGet_Errors(t *testing.T) {
 // TestSnapshotPreview1_FdAdvise only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdAdvise(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdAdvise, ImportFdAdvise, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdAdvise, ImportFdAdvise, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdAdvise", func(t *testing.T) {
@@ -534,10 +535,10 @@ func TestSnapshotPreview1_FdAdvise(t *testing.T) {
 // TestSnapshotPreview1_FdAllocate only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdAllocate(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdAllocate, ImportFdAllocate, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdAllocate, ImportFdAllocate, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdAllocate", func(t *testing.T) {
@@ -568,23 +569,23 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 		entry2, errno := openFileEntry(testFs, path2)
 		require.Zero(t, errno, wasi.ErrnoName(errno))
 
-		sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 			fdToClose: entry1,
 			fdToKeep:  entry2,
 		})
 		require.NoError(t, err)
 
-		a, mod, fn := instantiateModule(t, ctx, FunctionFdClose, ImportFdClose, moduleName, sys)
+		a, mod, fn := instantiateModule(t, ctx, FunctionFdClose, ImportFdClose, moduleName, sysCtx)
 		return mod, fn, a
 	}
 
 	verify := func(mod publicwasm.Module) {
 		// Verify fdToClose is closed and removed from the opened FDs.
-		_, ok := sysContext(mod).OpenedFile(fdToClose)
+		_, ok := sysCtx(mod).OpenedFile(fdToClose)
 		require.False(t, ok)
 
 		// Verify fdToKeep is not closed
-		_, ok = sysContext(mod).OpenedFile(fdToKeep)
+		_, ok = sysCtx(mod).OpenedFile(fdToKeep)
 		require.True(t, ok)
 	}
 
@@ -620,10 +621,10 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 // TestSnapshotPreview1_FdDatasync only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdDatasync(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdDatasync, ImportFdDatasync, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdDatasync, ImportFdDatasync, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdDatasync", func(t *testing.T) {
@@ -644,10 +645,10 @@ func TestSnapshotPreview1_FdDatasync(t *testing.T) {
 // TestSnapshotPreview1_FdFdstatSetFlags only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdFdstatSetFlags(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetFlags, ImportFdFdstatSetFlags, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetFlags, ImportFdFdstatSetFlags, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFdstatSetFlags", func(t *testing.T) {
@@ -666,10 +667,10 @@ func TestSnapshotPreview1_FdFdstatSetFlags(t *testing.T) {
 // TestSnapshotPreview1_FdFdstatSetRights only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdFdstatSetRights(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetRights, ImportFdFdstatSetRights, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetRights, ImportFdFdstatSetRights, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFdstatSetRights", func(t *testing.T) {
@@ -688,10 +689,10 @@ func TestSnapshotPreview1_FdFdstatSetRights(t *testing.T) {
 // TestSnapshotPreview1_FdFilestatGet only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdFilestatGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatGet, ImportFdFilestatGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatGet, ImportFdFilestatGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatGet", func(t *testing.T) {
@@ -710,10 +711,10 @@ func TestSnapshotPreview1_FdFilestatGet(t *testing.T) {
 // TestSnapshotPreview1_FdFilestatSetSize only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdFilestatSetSize(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetSize, ImportFdFilestatSetSize, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetSize, ImportFdFilestatSetSize, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatSetSize", func(t *testing.T) {
@@ -732,10 +733,10 @@ func TestSnapshotPreview1_FdFilestatSetSize(t *testing.T) {
 // TestSnapshotPreview1_FdFilestatSetTimes only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdFilestatSetTimes(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetTimes, ImportFdFilestatSetTimes, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetTimes, ImportFdFilestatSetTimes, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatSetTimes", func(t *testing.T) {
@@ -754,10 +755,10 @@ func TestSnapshotPreview1_FdFilestatSetTimes(t *testing.T) {
 // TestSnapshotPreview1_FdPread only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdPread(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdPread, ImportFdPread, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdPread, ImportFdPread, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdPread", func(t *testing.T) {
@@ -778,10 +779,10 @@ func TestSnapshotPreview1_FdPrestatGet(t *testing.T) {
 
 	ctx := context.Background()
 	pathName := "/tmp"
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: pathName}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: pathName}})
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	resultPrestat := uint32(1) // arbitrary offset
@@ -824,10 +825,10 @@ func TestSnapshotPreview1_FdPrestatGet_Errors(t *testing.T) {
 	validAddress := uint32(0) // Arbitrary valid address as arguments to fd_prestat_get. We chose 0 here.
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -867,10 +868,10 @@ func TestSnapshotPreview1_FdPrestatDirName(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sysCtx)
 	defer mod.Close()
 
 	path := uint32(1)    // arbitrary offset
@@ -910,10 +911,10 @@ func TestSnapshotPreview1_FdPrestatDirName_Errors(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -971,10 +972,10 @@ func TestSnapshotPreview1_FdPrestatDirName_Errors(t *testing.T) {
 // TestSnapshotPreview1_FdPwrite only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdPwrite(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdPwrite, ImportFdPwrite, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdPwrite, ImportFdPwrite, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdPwrite", func(t *testing.T) {
@@ -1038,12 +1039,12 @@ func TestSnapshotPreview1_FdRead(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			// Create a fresh file to read the contents from
 			file, testFS := createFile(t, "test_path", []byte("wazero"))
-			sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+			sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 				fd: {Path: "test_path", FS: testFS, File: file},
 			})
 			require.NoError(t, err)
 
-			a, mod, fn := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sys)
+			a, mod, fn := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sysCtx)
 			defer mod.Close()
 
 			maskMemory(t, mod, len(expectedMemory))
@@ -1066,12 +1067,12 @@ func TestSnapshotPreview1_FdRead_Errors(t *testing.T) {
 	file, testFS := createFile(t, "test_path", []byte{}) // file with empty contents
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		validFD: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sysCtx)
 	defer mod.Close()
 
 	tests := []struct {
@@ -1157,10 +1158,10 @@ func TestSnapshotPreview1_FdRead_Errors(t *testing.T) {
 // TestSnapshotPreview1_FdReaddir only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdReaddir(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdReaddir, ImportFdReaddir, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdReaddir, ImportFdReaddir, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdReaddir", func(t *testing.T) {
@@ -1179,10 +1180,10 @@ func TestSnapshotPreview1_FdReaddir(t *testing.T) {
 // TestSnapshotPreview1_FdRenumber only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdRenumber(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdRenumber, ImportFdRenumber, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdRenumber, ImportFdRenumber, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdRenumber", func(t *testing.T) {
@@ -1204,12 +1205,12 @@ func TestSnapshotPreview1_FdSeek(t *testing.T) {
 	file, testFS := createFile(t, "test_path", []byte("wazero")) // arbitrary non-empty contents
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		fd: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sysCtx)
 	defer mod.Close()
 
 	// TestSnapshotPreview1_FdSeek uses a matrix because setting up test files is complicated and has to be clean each time.
@@ -1281,7 +1282,7 @@ func TestSnapshotPreview1_FdSeek(t *testing.T) {
 					maskMemory(t, mod, len(tc.expectedMemory))
 
 					// Since we initialized this file, we know it is a seeker (because it is a MapFile)
-					f, ok := sys.OpenedFile(fd)
+					f, ok := sysCtx.OpenedFile(fd)
 					require.True(t, ok)
 					seeker := f.File.(io.Seeker)
 
@@ -1310,12 +1311,12 @@ func TestSnapshotPreview1_FdSeek_Errors(t *testing.T) {
 	validFD := uint32(3)                                         // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	file, testFS := createFile(t, "test_path", []byte("wazero")) // arbitrary valid file with non-empty contents
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		validFD: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -1359,10 +1360,10 @@ func TestSnapshotPreview1_FdSeek_Errors(t *testing.T) {
 // TestSnapshotPreview1_FdSync only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdSync(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdSync, ImportFdSync, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdSync, ImportFdSync, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdSync", func(t *testing.T) {
@@ -1381,10 +1382,10 @@ func TestSnapshotPreview1_FdSync(t *testing.T) {
 // TestSnapshotPreview1_FdTell only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdTell(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdTell, ImportFdTell, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionFdTell, ImportFdTell, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdTell", func(t *testing.T) {
@@ -1451,12 +1452,12 @@ func TestSnapshotPreview1_FdWrite(t *testing.T) {
 			// Create a fresh file to write the contents to
 			pathName := "test_path"
 			file, testFS := createWriteableFile(t, tmpDir, pathName, []byte{})
-			sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+			sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 				fd: {Path: pathName, FS: testFS, File: file},
 			})
 			require.NoError(t, err)
 
-			a, mod, fn := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sys)
+			a, mod, fn := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sysCtx)
 			defer mod.Close()
 
 			maskMemory(t, mod, len(expectedMemory))
@@ -1487,12 +1488,12 @@ func TestSnapshotPreview1_FdWrite_Errors(t *testing.T) {
 	file, testFS := createWriteableFile(t, tmpDir, pathName, []byte{})
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		validFD: {Path: pathName, FS: testFS, File: file},
 	})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sysCtx)
 	defer mod.Close()
 
 	// Setup valid test memory
@@ -1561,10 +1562,10 @@ func TestSnapshotPreview1_FdWrite_Errors(t *testing.T) {
 // TestSnapshotPreview1_PathCreateDirectory only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathCreateDirectory(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathCreateDirectory, ImportPathCreateDirectory, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathCreateDirectory, ImportPathCreateDirectory, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathCreateDirectory", func(t *testing.T) {
@@ -1583,10 +1584,10 @@ func TestSnapshotPreview1_PathCreateDirectory(t *testing.T) {
 // TestSnapshotPreview1_PathFilestatGet only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathFilestatGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatGet, ImportPathFilestatGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatGet, ImportPathFilestatGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathFilestatGet", func(t *testing.T) {
@@ -1605,10 +1606,10 @@ func TestSnapshotPreview1_PathFilestatGet(t *testing.T) {
 // TestSnapshotPreview1_PathFilestatSetTimes only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathFilestatSetTimes(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatSetTimes, ImportPathFilestatSetTimes, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatSetTimes, ImportPathFilestatSetTimes, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathFilestatSetTimes", func(t *testing.T) {
@@ -1627,10 +1628,10 @@ func TestSnapshotPreview1_PathFilestatSetTimes(t *testing.T) {
 // TestSnapshotPreview1_PathLink only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathLink(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathLink, ImportPathLink, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathLink, ImportPathLink, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathLink", func(t *testing.T) {
@@ -1674,11 +1675,11 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 
 	setup := func() (*wasiAPI, *wasm.ModuleContext, publicwasm.Function) {
 		testFS := fstest.MapFS{pathName: &fstest.MapFile{Mode: os.ModeDir}}
-		sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 			workdirFD: {Path: ".", FS: testFS},
 		})
 		require.NoError(t, err)
-		a, mod, fn := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sys)
+		a, mod, fn := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sysCtx)
 		maskMemory(t, mod, len(expectedMemory))
 		ok := mod.Memory().Write(0, initialMemory)
 		require.True(t, ok)
@@ -1693,7 +1694,7 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 		require.Equal(t, expectedMemory, actual)
 
 		// verify the file was actually opened
-		f, ok := sysContext(mod).OpenedFile(uint32(expectedFD))
+		f, ok := sysCtx(mod).OpenedFile(uint32(expectedFD))
 		require.True(t, ok)
 		require.Equal(t, pathName, f.Path)
 	}
@@ -1719,12 +1720,12 @@ func TestSnapshotPreview1_PathOpen_Errors(t *testing.T) {
 	testFS := fstest.MapFS{pathName: &fstest.MapFile{Mode: os.ModeDir}}
 
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		validFD: {Path: ".", FS: testFS},
 	})
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sysCtx)
 	defer mod.Close()
 
 	validPath := uint32(0)    // arbitrary offset
@@ -1784,10 +1785,10 @@ func TestSnapshotPreview1_PathOpen_Errors(t *testing.T) {
 // TestSnapshotPreview1_PathReadlink only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathReadlink(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathReadlink, ImportPathReadlink, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathReadlink, ImportPathReadlink, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathLink", func(t *testing.T) {
@@ -1806,10 +1807,10 @@ func TestSnapshotPreview1_PathReadlink(t *testing.T) {
 // TestSnapshotPreview1_PathRemoveDirectory only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathRemoveDirectory(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathRemoveDirectory, ImportPathRemoveDirectory, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathRemoveDirectory, ImportPathRemoveDirectory, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathRemoveDirectory", func(t *testing.T) {
@@ -1828,10 +1829,10 @@ func TestSnapshotPreview1_PathRemoveDirectory(t *testing.T) {
 // TestSnapshotPreview1_PathRename only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathRename(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathRename, ImportPathRename, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathRename, ImportPathRename, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathRename", func(t *testing.T) {
@@ -1850,10 +1851,10 @@ func TestSnapshotPreview1_PathRename(t *testing.T) {
 // TestSnapshotPreview1_PathSymlink only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathSymlink(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathSymlink, ImportPathSymlink, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathSymlink, ImportPathSymlink, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathSymlink", func(t *testing.T) {
@@ -1872,10 +1873,10 @@ func TestSnapshotPreview1_PathSymlink(t *testing.T) {
 // TestSnapshotPreview1_PathUnlinkFile only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathUnlinkFile(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathUnlinkFile, ImportPathUnlinkFile, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPathUnlinkFile, ImportPathUnlinkFile, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathUnlinkFile", func(t *testing.T) {
@@ -1894,10 +1895,10 @@ func TestSnapshotPreview1_PathUnlinkFile(t *testing.T) {
 // TestSnapshotPreview1_PollOneoff only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PollOneoff(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionPollOneoff, ImportPollOneoff, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionPollOneoff, ImportPollOneoff, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PollOneoff", func(t *testing.T) {
@@ -1915,7 +1916,7 @@ func TestSnapshotPreview1_PollOneoff(t *testing.T) {
 
 func TestSnapshotPreview1_ProcExit(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -1933,20 +1934,18 @@ func TestSnapshotPreview1_ProcExit(t *testing.T) {
 		},
 	}
 
-	// Note: Unlike most tests, this uses fn, not the 'a' result parameter. This is because currently, this function
-	// body panics, and we expect Call to unwrap the panic.
-	_, mod, fn := instantiateModule(t, ctx, FunctionProcExit, ImportProcExit, moduleName, sys)
-	defer mod.Close()
-
 	for _, tt := range tests {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
+			// Note: Unlike most tests, this uses fn, not the 'a' result parameter. This is because currently, this function
+			// body panics, and we expect Call to unwrap the panic.
+			_, mod, fn := instantiateModule(t, ctx, FunctionProcExit, ImportProcExit, moduleName, sysCtx)
+			defer mod.Close()
+
 			// When ProcExit is called, store.CallFunction returns immediately, returning the exit code as the error.
 			_, err = fn.Call(mod, uint64(tc.exitCode))
-			var code wasi.ExitCode
-			require.ErrorAs(t, err, &code)
-			require.Equal(t, code, wasi.ExitCode(tc.exitCode))
+			require.Equal(t, tc.exitCode, err.(*sys.ExitError).ExitCode())
 		})
 	}
 }
@@ -1954,10 +1953,10 @@ func TestSnapshotPreview1_ProcExit(t *testing.T) {
 // TestSnapshotPreview1_ProcRaise only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_ProcRaise(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionProcRaise, ImportProcRaise, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionProcRaise, ImportProcRaise, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ProcRaise", func(t *testing.T) {
@@ -1976,10 +1975,10 @@ func TestSnapshotPreview1_ProcRaise(t *testing.T) {
 // TestSnapshotPreview1_SchedYield only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_SchedYield(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionSchedYield, ImportSchedYield, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionSchedYield, ImportSchedYield, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SchedYield", func(t *testing.T) {
@@ -2006,10 +2005,10 @@ func TestSnapshotPreview1_RandomGet(t *testing.T) {
 	offset := uint32(1) // offset,
 	seed := int64(42)   // and seed value
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	a.randSource = func(p []byte) error {
@@ -2048,12 +2047,12 @@ func TestSnapshotPreview1_RandomGet(t *testing.T) {
 
 func TestSnapshotPreview1_RandomGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	validAddress := uint32(0) // arbitrary valid address
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
@@ -2088,10 +2087,10 @@ func TestSnapshotPreview1_RandomGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_RandomGet_SourceError(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sysCtx)
 	defer mod.Close()
 
 	a.randSource = func(p []byte) error {
@@ -2105,10 +2104,10 @@ func TestSnapshotPreview1_RandomGet_SourceError(t *testing.T) {
 // TestSnapshotPreview1_SockRecv only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_SockRecv(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionSockRecv, ImportSockRecv, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionSockRecv, ImportSockRecv, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockRecv", func(t *testing.T) {
@@ -2127,10 +2126,10 @@ func TestSnapshotPreview1_SockRecv(t *testing.T) {
 // TestSnapshotPreview1_SockSend only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_SockSend(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionSockSend, ImportSockSend, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionSockSend, ImportSockSend, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockSend", func(t *testing.T) {
@@ -2149,10 +2148,10 @@ func TestSnapshotPreview1_SockSend(t *testing.T) {
 // TestSnapshotPreview1_SockShutdown only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_SockShutdown(t *testing.T) {
 	ctx := context.Background()
-	sys, err := newSysContext(nil, nil, nil)
+	sysCtx, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionSockShutdown, ImportSockShutdown, moduleName, sys)
+	a, mod, fn := instantiateModule(t, ctx, FunctionSockShutdown, ImportSockShutdown, moduleName, sysCtx)
 	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockShutdown", func(t *testing.T) {
@@ -2177,7 +2176,7 @@ func maskMemory(t *testing.T, mod publicwasm.Module, size int) {
 	}
 }
 
-func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImport, moduleName string, sys *wasm.SysContext) (*wasiAPI, *wasm.ModuleContext, publicwasm.Function) {
+func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImport, moduleName string, sysCtx *wasm.SysContext) (*wasiAPI, *wasm.ModuleContext, publicwasm.Function) {
 	enabledFeatures := wasm.Features20191205
 	store := wasm.NewStore(interpreter.NewEngine(), enabledFeatures)
 
@@ -2202,7 +2201,7 @@ func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImpo
 )`, wasiFunction, wasiImport)), enabledFeatures)
 	require.NoError(t, err)
 
-	mod, err := store.Instantiate(ctx, m, moduleName, sys)
+	mod, err := store.Instantiate(ctx, m, moduleName, sysCtx)
 	require.NoError(t, err)
 
 	fn := mod.ExportedFunction(wasiFunction)
@@ -2210,7 +2209,7 @@ func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImpo
 	return a, mod, fn
 }
 
-func newSysContext(args, environ []string, openedFiles map[uint32]*wasm.FileEntry) (sys *wasm.SysContext, err error) {
+func newSysContext(args, environ []string, openedFiles map[uint32]*wasm.FileEntry) (sysCtx *wasm.SysContext, err error) {
 	return wasm.NewSysContext(math.MaxUint32, args, environ, new(bytes.Buffer), nil, nil, openedFiles)
 }
 
diff --git a/internal/wasm/interpreter/interpreter_test.go b/internal/wasm/interpreter/interpreter_test.go
index f92d0c27..a0fd7ad1 100644
--- a/internal/wasm/interpreter/interpreter_test.go
+++ b/internal/wasm/interpreter/interpreter_test.go
@@ -76,7 +76,7 @@ func TestModuleEngine_Call(t *testing.T) {
 	enginetest.RunTestModuleEngine_Call(t, et)
 }
 
-func TestTestModuleEngine_Call_HostFn(t *testing.T) {
+func TestModuleEngine_Call_HostFn(t *testing.T) {
 	enginetest.RunTestModuleEngine_Call_HostFn(t, et)
 }
 
@@ -285,7 +285,8 @@ func TestClose(t *testing.T) {
 				require.Contains(t, e.compiledFunctions, f)
 			}
 
-			err = me.Close()
+			closed, err := me.CloseWithExitCode(0)
+			require.True(t, closed)
 			require.NoError(t, err)
 
 			require.Len(t, e.compiledFunctions, len(tc.importedFunctions))
diff --git a/internal/wasm/jit/engine_test.go b/internal/wasm/jit/engine_test.go
index 72e2cbb5..c5c6c4ae 100644
--- a/internal/wasm/jit/engine_test.go
+++ b/internal/wasm/jit/engine_test.go
@@ -131,7 +131,7 @@ func TestModuleEngine_Call(t *testing.T) {
 	enginetest.RunTestModuleEngine_Call(t, et)
 }
 
-func TestTestModuleEngine_Call_HostFn(t *testing.T) {
+func TestModuleEngine_Call_HostFn(t *testing.T) {
 	requireSupportedOSArch(t)
 	enginetest.RunTestModuleEngine_Call_HostFn(t, et)
 }
@@ -219,7 +219,7 @@ func TestNewModuleEngine_CompiledFunctions(t *testing.T) {
 	}
 	modE, err := e.NewModuleEngine(t.Name(), nil, importedFunctions, nil, nil)
 	require.NoError(t, err)
-	defer modE.Close()
+	defer modE.CloseWithExitCode(0) //nolint
 	imported := modE.(*moduleEngine)
 
 	importingFinalizer := fakeFinalizer{}
@@ -233,7 +233,7 @@ func TestNewModuleEngine_CompiledFunctions(t *testing.T) {
 
 	modE, err = e.NewModuleEngine(t.Name(), importedFunctions, moduleFunctions, nil, nil)
 	require.NoError(t, err)
-	defer modE.Close()
+	defer modE.CloseWithExitCode(0) //nolint
 	importing := modE.(*moduleEngine)
 
 	// Ensure the importing module didn't try to finalize the imported functions.
@@ -354,7 +354,8 @@ func TestModuleEngine_Close(t *testing.T) {
 				require.Contains(t, e.compiledFunctions, f)
 			}
 
-			err = importing.Close()
+			closed, err := importing.CloseWithExitCode(0)
+			require.True(t, closed)
 			require.NoError(t, err)
 
 			// Closing should flip the status bit, so that it cannot be closed again.
@@ -372,7 +373,8 @@ func TestModuleEngine_Close(t *testing.T) {
 			}
 
 			if len(tc.importedFunctions) > 0 {
-				err = imported.Close()
+				closed, err = imported.CloseWithExitCode(0)
+				require.True(t, closed)
 				require.NoError(t, err)
 			}
 
diff --git a/internal/wasm/module_context_test.go b/internal/wasm/module_context_test.go
index bcc0d16c..e77386b9 100644
--- a/internal/wasm/module_context_test.go
+++ b/internal/wasm/module_context_test.go
@@ -141,13 +141,13 @@ func TestModuleContext_String(t *testing.T) {
 func TestModuleContext_Close(t *testing.T) {
 	s := newStore()
 
-	t.Run("calls store.CloseModule(module.name)", func(t *testing.T) {
+	t.Run("calls store.CloseWithExitCode(module.name)", func(t *testing.T) {
 		moduleName := t.Name()
 		m, err := s.Instantiate(context.Background(), &Module{}, moduleName, nil)
 		require.NoError(t, err)
 
-		// We use side effects to determine if Close in fact called store.CloseModule (without repeating store_test.go).
-		// One side effect of store.CloseModule is that the moduleName can no longer be looked up. Verify our base case.
+		// We use side effects to determine if Close in fact called store.CloseWithExitCode (without repeating store_test.go).
+		// One side effect of store.CloseWithExitCode is that the moduleName can no longer be looked up. Verify our base case.
 		require.Equal(t, s.Module(moduleName), m)
 
 		// Closing should not err.
diff --git a/internal/wasm/store_test.go b/internal/wasm/store_test.go
index 11ad0971..c8313cde 100644
--- a/internal/wasm/store_test.go
+++ b/internal/wasm/store_test.go
@@ -160,14 +160,14 @@ func TestStore_CloseModule(t *testing.T) {
 			require.True(t, ok)
 
 			// Close the importing module
-			require.NoError(t, s.CloseModule(importingModuleName))
+			require.NoError(t, s.CloseModuleWithExitCode(importingModuleName, 0))
 			require.NotContains(t, s.modules, importingModuleName)
 
 			// Can re-close the importing module
-			require.NoError(t, s.CloseModule(importingModuleName))
+			require.NoError(t, s.CloseModuleWithExitCode(importingModuleName, 0))
 
 			// Now we close the imported module.
-			require.NoError(t, s.CloseModule(importedModuleName))
+			require.NoError(t, s.CloseModuleWithExitCode(importedModuleName, 0))
 			require.Nil(t, s.modules[importedModuleName])
 			require.NotContains(t, s.modules, importedModuleName)
 		})
@@ -208,16 +208,16 @@ func TestStore_hammer(t *testing.T) {
 		N = 100
 	}
 	hammer.NewHammer(t, P, N).Run(func(name string) {
-		_, instantiateErr := s.Instantiate(context.Background(), importingModule, name, DefaultSysContext())
+		mod, instantiateErr := s.Instantiate(context.Background(), importingModule, name, DefaultSysContext())
 		require.NoError(t, instantiateErr)
-		require.NoError(t, s.CloseModule(name))
+		require.NoError(t, s.CloseModuleWithExitCode(mod.Name(), 0))
 	}, nil)
 	if t.Failed() {
 		return // At least one test failed, so return now.
 	}
 
 	// Close the imported module.
-	require.NoError(t, s.CloseModule(imported.Name()))
+	require.NoError(t, s.CloseModuleWithExitCode(imported.Name(), 0))
 
 	// All instances are freed.
 	require.Len(t, s.modules, 0)
@@ -447,9 +447,9 @@ func (e *mockModuleEngine) Call(ctx *ModuleContext, f *FunctionInstance, _ ...ui
 	return
 }
 
-// Close implements the same method as documented on internalwasm.ModuleEngine.
-func (e *mockModuleEngine) Close() (err error) {
-	return
+// CloseWithExitCode implements the same method as documented on internalwasm.ModuleEngine.
+func (me *mockModuleEngine) CloseWithExitCode(exitCode uint32) (bool, error) {
+	return true, nil
 }
 
 func TestStore_getTypeInstance(t *testing.T) {
diff --git a/tests/engine/adhoc_test.go b/tests/engine/adhoc_test.go
index 3aaf3cdd..c622607b 100644
--- a/tests/engine/adhoc_test.go
+++ b/tests/engine/adhoc_test.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	wasm "github.com/tetratelabs/wazero/internal/wasm"
+	"github.com/tetratelabs/wazero/sys"
 	publicwasm "github.com/tetratelabs/wazero/wasm"
 )
 
@@ -41,6 +42,8 @@ var configContext = context.WithValue(context.Background(), configContextKey("wa
 
 func runAllTests(t *testing.T, tests map[string]func(t *testing.T, r wazero.Runtime), config *wazero.RuntimeConfig) {
 	for name, testf := range tests {
+		name := name   // pin
+		testf := testf // pin
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 			testf(t, wazero.NewRuntimeWithConfig(config.WithContext(configContext)))
@@ -252,37 +255,34 @@ func testHostFunctionNumericParameter(t *testing.T, r wazero.Runtime) {
 	}
 }
 
-func callImportAfterAddSource(importedModule, importingModule string) []byte {
+func callReturnImportSource(importedModule, importingModule string) []byte {
 	return []byte(fmt.Sprintf(`(module $%[1]s
-	(import "%[2]s" "return_input" (func $block (param i32) (result i32)))
-	(func $call_import_after_add (param i32) (param i32) (result i32)
-		local.get 0
-		local.get 1
-		i32.add
-		call $block
-	)
-	(export "call_import_after_add" (func $call_import_after_add))
+	;; test an imported function by re-exporting it
+	(import "%[2]s" "return_input" (func $return_input (param i32) (result i32)))
+	(export "return_input" (func $return_input))
+
+	;; test wasm, by calling an imported function
+	(func $call_return_import (param i32) (result i32) local.get 0 call $return_input)
+	(export "call_return_import" (func $call_return_import))
 )`, importingModule, importedModule))
 }
 
 func testCloseInFlight(t *testing.T, r wazero.Runtime) {
 	tests := []struct {
-		name           string
-		closeImporting bool
-		closeImported  bool
+		name, function                string
+		closeImporting, closeImported uint32
 	}{
-		{
-			name:           "importing", // Ex. WASI proc_exit or AssemblyScript abort handler.
-			closeImporting: true,
-		},
-		{
-			name:          "imported",
-			closeImported: true,
+		{ // Ex. WASI proc_exit or AssemblyScript abort handler.
+			name:           "importing",
+			function:       "call_return_import",
+			closeImporting: 1,
 		},
-		{
-			name:           "both", // Ex. A function that stops the runtime.
-			closeImporting: true,
-			closeImported:  true,
+		// TODO: A module that re-exports a function (ex "return_input") can call it after it is closed!
+		{ // Ex. A function that stops the runtime.
+			name:           "both",
+			function:       "call_return_import",
+			closeImporting: 1,
+			closeImported:  2,
 		},
 	}
 	for _, tt := range tests {
@@ -292,11 +292,11 @@ func testCloseInFlight(t *testing.T, r wazero.Runtime) {
 			var imported, importing publicwasm.Module
 			var err error
 			closeAndReturn := func(x uint32) uint32 {
-				if tc.closeImporting {
-					require.NoError(t, importing.Close())
+				if tc.closeImporting != 0 {
+					require.NoError(t, importing.CloseWithExitCode(tc.closeImporting))
 				}
-				if tc.closeImported {
-					require.NoError(t, importing.Close())
+				if tc.closeImported != 0 {
+					require.NoError(t, imported.CloseWithExitCode(tc.closeImported))
 				}
 				return x
 			}
@@ -308,14 +308,26 @@ func testCloseInFlight(t *testing.T, r wazero.Runtime) {
 			defer imported.Close()
 
 			// Import that module.
-			source := callImportAfterAddSource(imported.Name(), t.Name()+"-importing")
+			source := callReturnImportSource(imported.Name(), t.Name()+"-importing")
 			importing, err = r.InstantiateModuleFromSource(source)
 			require.NoError(t, err)
 			defer importing.Close()
 
-			// Expect no error, because there's currently no logic to fail on the return path after close.
-			_, err = importing.ExportedFunction("call_import_after_add").Call(nil, 1, 2)
-			require.NoError(t, err)
+			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)
+			} else if tc.closeImported != 0 {
+				expectedErr = sys.NewExitError(imported.Name(), tc.closeImported)
+			} else if tc.closeImporting != 0 {
+				expectedErr = sys.NewExitError(importing.Name(), tc.closeImporting)
+			} else {
+				t.Fatal("invalid test case")
+			}
+
+			// Functions that return after being closed should have an exit error.
+			_, err = importing.ExportedFunction(tc.function).Call(nil, 5)
+			require.Equal(t, expectedErr, err)
 		})
 	}
 }
diff --git a/tests/engine/hammer_test.go b/tests/engine/hammer_test.go
index 7a618aa3..812987d9 100644
--- a/tests/engine/hammer_test.go
+++ b/tests/engine/hammer_test.go
@@ -1,15 +1,14 @@
 package adhoc
 
 import (
-	"errors"
 	"sync"
-	"sync/atomic"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/internal/testing/hammer"
+	"github.com/tetratelabs/wazero/sys"
 	"github.com/tetratelabs/wazero/wasm"
 )
 
@@ -31,12 +30,12 @@ func TestEngineInterpreter_hammer(t *testing.T) {
 }
 
 func closeImportingModuleWhileInUse(t *testing.T, r wazero.Runtime) {
-	closeModuleWhileInUse(t, r, func(hostFunctionClosed *uint32, imported, importing wasm.Module) (wasm.Module, wasm.Module) {
+	closeModuleWhileInUse(t, r, func(imported, importing wasm.Module) (wasm.Module, wasm.Module) {
 		// Close the importing module, despite calls being in-flight.
 		require.NoError(t, importing.Close())
 
 		// Prove a module can be redefined even with in-flight calls.
-		source := callImportAfterAddSource(imported.Name(), importing.Name())
+		source := callReturnImportSource(imported.Name(), importing.Name())
 		importing, err := r.InstantiateModuleFromSource(source)
 		require.NoError(t, err)
 		return imported, importing
@@ -44,26 +43,19 @@ func closeImportingModuleWhileInUse(t *testing.T, r wazero.Runtime) {
 }
 
 func closeImportedModuleWhileInUse(t *testing.T, r wazero.Runtime) {
-	closeModuleWhileInUse(t, r, func(hostFunctionClosed *uint32, imported, importing wasm.Module) (wasm.Module, wasm.Module) {
-		// Close the underlying host function, which causes future calls to it to fail.
-		atomic.StoreUint32(hostFunctionClosed, 1)
-
-		// Validate new calls to the imported function fail, since it was closed.
-		_, err := imported.ExportedFunction("return_input").Call(nil, 1)
-		require.Contains(t, err.Error(), "wasm runtime error: function closed")
-
-		// Close both the importing and the imported module, despite calls being in-flight
-		require.NoError(t, imported.Close())
+	closeModuleWhileInUse(t, r, func(imported, importing wasm.Module) (wasm.Module, wasm.Module) {
+		// Close the importing and imported module, despite calls being in-flight.
 		require.NoError(t, importing.Close())
+		require.NoError(t, imported.Close())
 
 		// Redefine the imported module, with a function that no longer blocks.
-		imported, err = r.NewModuleBuilder(imported.Name()).ExportFunction("return_input", func(x uint32) uint32 {
+		imported, err := r.NewModuleBuilder(imported.Name()).ExportFunction("return_input", func(x uint32) uint32 {
 			return x
 		}).Instantiate()
 		require.NoError(t, err)
 
 		// Redefine the importing module, which should link to the redefined host module.
-		source := callImportAfterAddSource(imported.Name(), importing.Name())
+		source := callReturnImportSource(imported.Name(), importing.Name())
 		importing, err = r.InstantiateModuleFromSource(source)
 		require.NoError(t, err)
 
@@ -71,23 +63,16 @@ func closeImportedModuleWhileInUse(t *testing.T, r wazero.Runtime) {
 	})
 }
 
-func closeModuleWhileInUse(t *testing.T, r wazero.Runtime, closeFn func(hostFunctionClosed *uint32, imported, importing wasm.Module) (wasm.Module, wasm.Module)) {
-	args := []uint64{1, 123}
-	exp := args[0] + args[1]
-
+func closeModuleWhileInUse(t *testing.T, r wazero.Runtime, closeFn func(imported, importing wasm.Module) (wasm.Module, wasm.Module)) {
 	P := 8               // max count of goroutines
 	if testing.Short() { // Adjust down if `-test.short`
 		P = 4
 	}
 
-	var hostFunctionClosed uint32
 	// To know return path works on a closed module, we need to block calls.
 	var calls sync.WaitGroup
 	calls.Add(P)
 	blockAndReturn := func(x uint32) uint32 {
-		if atomic.LoadUint32(&hostFunctionClosed) == 1 { // Not require.False as we don't want to fail the test.
-			panic(errors.New("function closed"))
-		}
 		calls.Wait()
 		return x
 	}
@@ -99,16 +84,18 @@ func closeModuleWhileInUse(t *testing.T, r wazero.Runtime, closeFn func(hostFunc
 	defer imported.Close()
 
 	// Import that module.
-	source := callImportAfterAddSource(imported.Name(), t.Name()+"-importing")
+	source := callReturnImportSource(imported.Name(), t.Name()+"-importing")
 	importing, err := r.InstantiateModuleFromSource(source)
 	require.NoError(t, err)
 	defer importing.Close()
 
 	// As this is a blocking function call, only run 1 per goroutine.
+	i := importing // pin the module used inside goroutines
 	hammer.NewHammer(t, P, 1).Run(func(name string) {
-		requireFunctionCall(t, importing.ExportedFunction("call_import_after_add"), args, exp)
+		// 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_import"))
 	}, func() { // When all functions are in-flight, re-assign the modules.
-		imported, importing = closeFn(&hostFunctionClosed, imported, importing)
+		imported, importing = closeFn(imported, importing)
 		// Unblock all the calls
 		calls.Add(-P)
 	})
@@ -120,12 +107,16 @@ func closeModuleWhileInUse(t *testing.T, r wazero.Runtime, closeFn func(hostFunc
 	}
 
 	// If unloading worked properly, a new function call should route to the newly instantiated module.
-	requireFunctionCall(t, importing.ExportedFunction("call_import_after_add"), args, exp)
+	requireFunctionCall(t, importing.ExportedFunction("call_return_import"))
 }
 
-func requireFunctionCall(t *testing.T, fn wasm.Function, args []uint64, exp uint64) {
-	res, err := fn.Call(nil, args...)
-	// We don't expect an error because there's currently no functionality to detect or fail on a closed module.
+func requireFunctionCall(t *testing.T, fn wasm.Function) {
+	res, err := fn.Call(nil, 3)
 	require.NoError(t, err)
-	require.Equal(t, exp, res[0])
+	require.Equal(t, uint64(3), res[0])
+}
+
+func requireFunctionCallExits(t *testing.T, moduleName string, fn wasm.Function) {
+	_, err := fn.Call(nil, 3)
+	require.Equal(t, sys.NewExitError(moduleName, 0), err)
 }