Repository: tetratelabs/wazero
Pull request #396 (2022-03-19 03:34:39)

wasi: renames WASIConfig to SysConfig and makes stdio defaults safer
This introduces `SysConfig` to replace `WASIConfig` and formalize documentation around system calls.

The only incompatible change planned after this is to switch from wasi.FS to fs.FS in #396


Implementation Notes:

Defaulting to os.Stdin os.Stdout and os.Stderr doesn't make sense for
the same reasons as why we don't propagate ENV or ARGV: it violates
sand-boxing. Moreover, these are worse as they prevent concurrency and
can also lead to console overload if accidentally not overridden.

This also changes default stdin to read EOF as that is safer than reading
from os.DevNull, which can run the host out of file descriptors.

Finally, this removes "WithPreopens" for "WithFS" and "WithWorkDirFS",
to focus on the intended result. Similar Docker, if the WorkDir isn't set, it
defaults to the same as root.

codefromthecrypt: getting closer to this, just have to re-align the planet a bit :D
codefromthecrypt: I owe some cleanup and tests, but this should be ready tomorrow morning asia time.

Patch:
diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml
index 534ef95f..fa9cec40 100644
--- a/.github/workflows/commit.yaml
+++ b/.github/workflows/commit.yaml
@@ -99,7 +99,7 @@ jobs:
           key: test-${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}-${{ matrix.arch }}
 
       - name: Build test binaries
-        run: go list ./... | xargs -Ipkg go test pkg -c
+        run: go list -f '{{.Dir}}' ./... | xargs -Ipkg go test pkg -c -o pkg.test
         env:
           GOARCH: ${{ matrix.arch }}
           CGO_ENABLED: 0
diff --git a/RATIONALE.md b/RATIONALE.md
index 07a167fe..3e1e679a 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -32,7 +32,7 @@ wazero shares constants and interfaces with internal code by a sharing pattern d
 * shared interfaces and constants go in a package under root.
   * Ex. package `wasi` -> `/wasi/*.go`
 * user code that refer to that package go into the flat root package `wazero`.
-  * Ex. `WASIConfig` -> `/wasi.go`
+  * Ex. `StartWASICommand` -> `/wasi.go`
 * implementation code begin in a corresponding package under `/internal`.
   * Ex  package `internalwasi` -> `/internal/wasi/*.go`
 
@@ -87,6 +87,62 @@ runtime vs interpreting Wasm directly (the `naivevm` interpreter).
 Note: `microwasm` was never specified formally, and only exists in a historical codebase of wasmtime:
 https://github.com/bytecodealliance/wasmtime/blob/v0.29.0/crates/lightbeam/src/microwasm.rs
 
+## Why is `SysConfig` decoupled from WASI?
+
+WebAssembly System Interfaces (WASI) is a formalization of a practice that can be done anyway: Define a host function to
+access a system interface, such as writing to STDOUT. WASI stalled at snapshot-01 and as of early 2022, is being
+rewritten entirely.
+
+This instability implies a need to transition between WASI specs, which places wazero in a position that requires
+decoupling. For example, if code uses two different functions to call `fd_write`, the underlying configuration must be
+centralized and decoupled. Otherwise, calls using the same file descriptor number will end up writing to different
+places.
+
+In short, wazero defined system configuration in `SysConfig`, not a WASI type. This allows end-users to switch from
+one spec to another with minimal impact. This has other helpful benefits, as centralized resources are simpler to close
+coherently (ex via `Module.Close`).
+
+### Background on `SysConfig` design
+WebAssembly 1.0 (20191205) specifies some aspects to control isolation between modules ([sandboxing](https://en.wikipedia.org/wiki/Sandbox_(computer_security))).
+For example, `wasm.Memory` has size constraints and each instance of it is isolated from each other. While `wasm.Memory`
+can be shared, by exporting it, it is not exported by default. In fact a WebAssembly Module (Wasm) has no memory by
+default.
+
+While memory is defined in WebAssembly 1.0 (20191205), many aspects are not. Let's use an example of `exec.Cmd` as for
+example, a WebAssembly System Interfaces (WASI) command is implemented as a module with a `_start` function, and in many
+ways acts similar to a process with a `main` function.
+
+To capture "hello world" written to the console (stdout a.k.a. file descriptor 1) in `exec.Cmd`, you would set the
+`Stdout` field accordingly, perhaps to a buffer. In WebAssembly 1.0 (20191205), the only way to perform something like
+this is via a host function (ex `ModuleBuilder.ExportFunction`) and internally copy memory corresponding to that string
+to a buffer.
+
+WASI implements system interfaces with host functions. Concretely, to write to console, a WASI command `Module` imports
+"fd_write" from "wasi_snapshot_preview1" and calls it with the `fd` parameter set to 1 (STDOUT).
+
+The [snapshot-01](https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md) version of WASI has no
+means to declare configuration, although its function definitions imply configuration for example if fd 1 should exist,
+and if so where should it write. Moreover, snapshot-01 was last updated in late 2020 and the specification is being
+completely rewritten as of early 2022. This means WASI as defined by "snapshot-01" will not clarify aspects like which
+file descriptors are required. While it is possible a subsequent version may, it is too early to tell as no version of
+WASI has reached a stage near W3C recommendation. Even if it did, module authors are not required to only use WASI to
+write to console, as they can define their own host functions, such as they did before WASI existed.
+
+wazero aims to serve Go developers as a primary function, and help them transition between WASI specifications. In
+order to do this, we have to allow top-level configuration. To ensure isolation by default, `SysConfig` has WithXXX
+that override defaults to no-op or empty. One `SysConfig` instance is used regardless of how many times the same WASI
+functions are imported. The nil defaults allow safe concurrency in these situations, as well lower the cost when they
+are never used. Finally, a one-to-one mapping with `Module` allows the module to close the `SysConfig` instead of
+confusing users with another API to close.
+
+Naming, defaults and validation rules of aspects like `STDIN` and `Environ` are intentionally similar to other Go
+libraries such as `exec.Cmd` or `syscall.SetEnv`, and differences called out where helpful. For example, there's no goal
+to emulate any operating system primitive specific to Windows (such as a 'c:\' drive). Moreover, certain defaults
+working with real system calls are neither relevant nor safe to inherit: For example, `exec.Cmd` defaults to read STDIN
+from a real file descriptor ("/dev/null"). Defaulting to this, vs reading `io.EOF`, would be unsafe as it can exhaust
+file descriptors if resources aren't managed properly. In other words, blind copying of defaults isn't wise as it can
+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.
 
 ## Implementation limitations
 
diff --git a/builder.go b/builder.go
index 726b049e..0cc42a1b 100644
--- a/builder.go
+++ b/builder.go
@@ -71,6 +71,8 @@ type ModuleBuilder interface {
 	Build() (*Module, error)
 
 	// Instantiate is a convenience that calls Build, then Runtime.InstantiateModule
+	//
+	// Note: Fields in the builder are copied during instantiation: Later changes do not affect the instantiated result.
 	Instantiate() (wasm.Module, error)
 }
 
diff --git a/config.go b/config.go
index 8bca03c2..f2dacfb9 100644
--- a/config.go
+++ b/config.go
@@ -2,10 +2,15 @@ package wazero
 
 import (
 	"context"
+	"errors"
+	"fmt"
+	"io"
+	"math"
 
 	internalwasm "github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/internal/wasm/interpreter"
 	"github.com/tetratelabs/wazero/internal/wasm/jit"
+	"github.com/tetratelabs/wazero/wasi"
 )
 
 // NewRuntimeConfigJIT compiles WebAssembly modules into runtime.GOARCH-specific assembly for optimal performance.
@@ -77,7 +82,204 @@ type Module struct {
 	module *internalwasm.Module
 }
 
-// WithName returns a new instance which overrides the name.
-func (m *Module) WithName(moduleName string) *Module {
-	return &Module{name: moduleName, module: m.module}
+// WithName configures the module name. Defaults to what was decoded from the module source.
+//
+// If the source was in WebAssembly 1.0 (20191205) Binary Format, this defaults to what was decoded from the custom name
+// section. Otherwise, if it was decoded from Text Format, this defaults to the module ID stripped of leading '$'.
+//
+// For example, if the Module was decoded from the text format `(module $math)`, the default name is "math".
+//
+// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#name-section%E2%91%A0
+// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#custom-section%E2%91%A0
+// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#modules%E2%91%A0%E2%91%A2
+func (m *Module) WithName(name string) *Module {
+	m.name = name
+	return m
+}
+
+// SysConfig configures resources needed by functions that have low-level interactions with the host operating system.
+// Using this, resources such as STDIN can be isolated (ex via StartWASICommandWithConfig), so that the same module can
+// be safely instantiated multiple times.
+//
+// Note: While wazero supports Windows as a platform, host functions using SysConfig follow a UNIX dialect.
+// See RATIONALE.md for design background and relationship to WebAssembly System Interfaces (WASI).
+type SysConfig struct {
+	stdin  io.Reader
+	stdout io.Writer
+	stderr io.Writer
+	args   []string
+	// environ is pair-indexed to retain order similar to os.Environ.
+	environ []string
+	// environKeys allow overwriting of existing values.
+	environKeys map[string]int
+
+	// preopenFD has the next FD number to use
+	preopenFD uint32
+	// preopens are keyed on file descriptor and only include the Path and FS fields.
+	preopens map[uint32]*internalwasm.FileEntry
+	// preopenPaths allow overwriting of existing paths.
+	preopenPaths map[string]uint32
+}
+
+func NewSysConfig() *SysConfig {
+	return &SysConfig{
+		environKeys:  map[string]int{},
+		preopenFD:    uint32(3), // after stdin/stdout/stderr
+		preopens:     map[uint32]*internalwasm.FileEntry{},
+		preopenPaths: map[string]uint32{},
+	}
+}
+
+// WithStdin configures where standard input (file descriptor 0) is read. Defaults to return io.EOF.
+//
+// This reader is most commonly used by the functions like "fd_read" in wasi.ModuleSnapshotPreview1 although it could be
+// used by functions imported from other modules.
+//
+// Note: The caller is responsible to close any io.Reader they supply: It is not closed on wasm.Module Close.
+// Note: This does not default to os.Stdin as that both violates sandboxing and prevents concurrent modules.
+// See https://linux.die.net/man/3/stdin
+func (c *SysConfig) WithStdin(stdin io.Reader) *SysConfig {
+	c.stdin = stdin
+	return c
+}
+
+// WithStdout configures where standard output (file descriptor 1) is written. Defaults to io.Discard.
+//
+// This writer is most commonly used by the functions like "fd_write" in wasi.ModuleSnapshotPreview1 although it could
+// be used by functions imported from other modules.
+//
+// Note: The caller is responsible to close any io.Writer they supply: It is not closed on wasm.Module Close.
+// Note: This does not default to os.Stdout as that both violates sandboxing and prevents concurrent modules.
+// See https://linux.die.net/man/3/stdout
+func (c *SysConfig) WithStdout(stdout io.Writer) *SysConfig {
+	c.stdout = stdout
+	return c
+}
+
+// WithStderr configures where standard error (file descriptor 2) is written. Defaults to io.Discard.
+//
+// This writer is most commonly used by the functions like "fd_write" in wasi.ModuleSnapshotPreview1 although it could
+// be used by functions imported from other modules.
+//
+// Note: The caller is responsible to close any io.Writer they supply: It is not closed on wasm.Module Close.
+// Note: This does not default to os.Stderr as that both violates sandboxing and prevents concurrent modules.
+// See https://linux.die.net/man/3/stderr
+func (c *SysConfig) WithStderr(stderr io.Writer) *SysConfig {
+	c.stderr = stderr
+	return c
+}
+
+// WithArgs assigns command-line arguments visible to an imported function that reads an arg vector (argv). Defaults to
+// none.
+//
+// These values are commonly read by the functions like "args_get" in wasi.ModuleSnapshotPreview1 although they could be
+// read by functions imported from other modules.
+//
+// Similar to os.Args and exec.Cmd Env, many implementations would expect a program name to be argv[0]. However, neither
+// WebAssembly nor WebAssembly System Interfaces (WASI) define this. Regardless, you may choose to set the first
+// argument to the same value set via WithName.
+//
+// Note: This does not default to os.Args as that violates sandboxing.
+// Note: Runtime.InstantiateModule errs if any value is empty.
+// See https://linux.die.net/man/3/argv
+// See https://en.wikipedia.org/wiki/Null-terminated_string
+func (c *SysConfig) WithArgs(args ...string) *SysConfig {
+	c.args = args
+	return c
+}
+
+// WithEnv sets an environment variable visible to a Module that imports functions. Defaults to none.
+//
+// Validation is the same as os.Setenv on Linux and replaces any existing value. Unlike exec.Cmd Env, this does not
+// default to the current process environment as that would violate sandboxing. This also does not preserve order.
+//
+// Environment variables are commonly read by the functions like "environ_get" in wasi.ModuleSnapshotPreview1 although
+// they could be read by functions imported from other modules.
+//
+// While similar to process configuration, there are no assumptions that can be made about anything OS-specific. For
+// example, neither WebAssembly nor WebAssembly System Interfaces (WASI) define concerns processes have, such as
+// case-sensitivity on environment keys. For portability, define entries with case-insensitively unique keys.
+//
+// Note: Runtime.InstantiateModule errs if the key is empty or contains a NULL(0) or equals("") character.
+// See https://linux.die.net/man/3/environ
+// See https://en.wikipedia.org/wiki/Null-terminated_string
+func (c *SysConfig) WithEnv(key, value string) *SysConfig {
+	// Check to see if this key already exists and update it.
+	if i, ok := c.environKeys[key]; ok {
+		c.environ[i+1] = value // environ is pair-indexed, so the value is 1 after the key.
+	} else {
+		c.environKeys[key] = len(c.environ)
+		c.environ = append(c.environ, key, value)
+	}
+	return c
+}
+
+// WithFS assigns the file system to use for any paths beginning at "/". Defaults to not found.
+//
+// Note: This sets WithWorkDirFS to the same file-system unless already set.
+func (c *SysConfig) WithFS(fs wasi.FS) *SysConfig {
+	c.setFS("/", fs)
+	return c
+}
+
+// WithWorkDirFS indicates the file system to use for any paths beginning at ".". Defaults to the same as WithFS.
+func (c *SysConfig) WithWorkDirFS(fs wasi.FS) *SysConfig {
+	c.setFS(".", fs)
+	return c
+}
+
+// withFS is hidden especially until #394 as existing use cases should be possible by composing file systems.
+// TODO: in #394 add examples on WithFS to accomplish this.
+func (c *SysConfig) setFS(path string, fs wasi.FS) {
+	// Check to see if this key already exists and update it.
+	entry := &internalwasm.FileEntry{Path: path, FS: fs}
+	if fd, ok := c.preopenPaths[path]; ok {
+		c.preopens[fd] = entry
+	} else {
+		c.preopens[c.preopenFD] = entry
+		c.preopenPaths[path] = c.preopenFD
+		c.preopenFD++
+	}
+}
+
+// toSysContext creates a baseline internalwasm.SysContext configured by SysConfig.
+func (c *SysConfig) toSysContext() (sys *internalwasm.SysContext, err error) {
+	var environ []string // Intentionally doesn't pre-allocate to reduce logic to default to nil.
+	// Same validation as syscall.Setenv for Linux
+	for i := 0; i < len(c.environ); i += 2 {
+		key, value := c.environ[i], c.environ[i+1]
+		if len(key) == 0 {
+			err = errors.New("environ invalid: empty key")
+			return
+		}
+		for j := 0; j < len(key); j++ {
+			if key[j] == '=' { // NUL enforced in NewSysContext
+				err = errors.New("environ invalid: key contains '=' character")
+				return
+			}
+		}
+		environ = append(environ, key+"="+value)
+	}
+
+	// Ensure no-one set a nil FD. We do this here instead of at the call site to allow chaining as nil is unexpected.
+	rootFD := uint32(0) // zero is invalid
+	setWorkDirFS := false
+	preopens := c.preopens
+	for fd, fs := range preopens {
+		if fs.FS == nil {
+			err = fmt.Errorf("FS for %s is nil", fs.Path)
+			return
+		} else if fs.Path == "/" {
+			rootFD = fd
+		} else if fs.Path == "." {
+			setWorkDirFS = true
+		}
+	}
+
+	// Default the working directory to the root FS if it exists.
+	if rootFD != 0 && !setWorkDirFS {
+		preopens[c.preopenFD] = &internalwasm.FileEntry{Path: ".", FS: preopens[rootFD].FS}
+	}
+
+	return internalwasm.NewSysContext(math.MaxUint32, c.args, environ, c.stdin, c.stdout, c.stderr, preopens)
 }
diff --git a/internal/cstring/cstring.go b/internal/cstring/cstring.go
deleted file mode 100644
index 6792e94e..00000000
--- a/internal/cstring/cstring.go
+++ /dev/null
@@ -1,45 +0,0 @@
-// Package cstring is named cstring because null-terminated strings are also known as CString and that avoids using
-// clashing package names like "strings" or a really long one like "null-terminated-strings"
-package cstring
-
-import (
-	"fmt"
-	"unicode/utf8"
-)
-
-// NullTerminatedStrings holds null-terminated strings. It ensures that
-// its length and total buffer size don't exceed the max of uint32.
-// NullTerminatedStrings are convenience struct for args_get and environ_get. (environ_get is not implemented yet)
-//
-// A Null-terminated string is a byte string with a NULL suffix ("\x00").
-// See https://en.wikipedia.org/wiki/Null-terminated_string
-type NullTerminatedStrings struct {
-	// NullTerminatedValues are null-terminated values with a NULL suffix.
-	NullTerminatedValues [][]byte
-	TotalBufSize         uint32
-}
-
-var EmptyNullTerminatedStrings = &NullTerminatedStrings{NullTerminatedValues: [][]byte{}}
-
-// NewNullTerminatedStrings creates a NullTerminatedStrings from the given string slice. It returns an error
-// if the length or the total buffer size of the result NullTerminatedStrings exceeds the maxBufSize
-func NewNullTerminatedStrings(maxBufSize uint32, valName string, vals ...string) (*NullTerminatedStrings, error) {
-	if len(vals) == 0 {
-		return EmptyNullTerminatedStrings, nil
-	}
-	var strings [][]byte // don't pre-allocate as this function is size bound
-	totalBufSize := uint32(0)
-	for i, arg := range vals {
-		if !utf8.ValidString(arg) {
-			return nil, fmt.Errorf("%s[%d] is not a valid UTF-8 string", valName, i)
-		}
-		argLen := uint64(len(arg)) + 1 // + 1 for "\x00"; uint64 in case this one arg is huge
-		nextSize := uint64(totalBufSize) + argLen
-		if nextSize > uint64(maxBufSize) {
-			return nil, fmt.Errorf("%s[%d] will exceed max buffer size %d", valName, i, maxBufSize)
-		}
-		totalBufSize = uint32(nextSize)
-		strings = append(strings, append([]byte(arg), 0))
-	}
-	return &NullTerminatedStrings{NullTerminatedValues: strings, TotalBufSize: totalBufSize}, nil
-}
diff --git a/internal/wasi/wasi.go b/internal/wasi/wasi.go
index 77f1db1c..b21f00be 100644
--- a/internal/wasi/wasi.go
+++ b/internal/wasi/wasi.go
@@ -2,7 +2,6 @@ package internalwasi
 
 import (
 	crand "crypto/rand"
-	"encoding/binary"
 	"errors"
 	"fmt"
 	"io"
@@ -1019,30 +1018,19 @@ 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 := systemContext(ctx)
-
-	for _, arg := range sys.Args.NullTerminatedValues {
-		if !ctx.Memory().WriteUint32Le(argv, argvBuf) {
-			return wasi.ErrnoFault
-		}
-		argv += 4 // size of uint32
-		if !ctx.Memory().Write(argvBuf, arg) {
-			return wasi.ErrnoFault
-		}
-		argvBuf += uint32(len(arg))
-	}
-
-	return wasi.ErrnoSuccess
+	sys := sysContext(ctx)
+	return writeOffsetsAndNullTerminatedValues(ctx.Memory(), sys.Args(), argv, argvBuf)
 }
 
 // ArgsSizesGet implements SnapshotPreview1.ArgsSizesGet
 func (a *wasiAPI) ArgsSizesGet(ctx wasm.Module, resultArgc, resultArgvBufSize uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
+	mem := ctx.Memory()
 
-	if !ctx.Memory().WriteUint32Le(resultArgc, uint32(len(sys.Args.NullTerminatedValues))) {
+	if !mem.WriteUint32Le(resultArgc, uint32(len(sys.Args()))) {
 		return wasi.ErrnoFault
 	}
-	if !ctx.Memory().WriteUint32Le(resultArgvBufSize, sys.Args.TotalBufSize) {
+	if !mem.WriteUint32Le(resultArgvBufSize, sys.ArgsSize()) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
@@ -1050,31 +1038,19 @@ 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 := systemContext(ctx)
-
-	// w.environ holds the environment variables in the form of "key=val\x00", so just copies it to the linear memory.
-	for _, env := range sys.Environ.NullTerminatedValues {
-		if !ctx.Memory().WriteUint32Le(environ, environBuf) {
-			return wasi.ErrnoFault
-		}
-		environ += 4 // size of uint32
-		if !ctx.Memory().Write(environBuf, env) {
-			return wasi.ErrnoFault
-		}
-		environBuf += uint32(len(env))
-	}
-
-	return wasi.ErrnoSuccess
+	sys := sysContext(ctx)
+	return writeOffsetsAndNullTerminatedValues(ctx.Memory(), sys.Environ(), environ, environBuf)
 }
 
 // EnvironSizesGet implements SnapshotPreview1.EnvironSizesGet
 func (a *wasiAPI) EnvironSizesGet(ctx wasm.Module, resultEnvironc uint32, resultEnvironBufSize uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
+	mem := ctx.Memory()
 
-	if !ctx.Memory().WriteUint32Le(resultEnvironc, uint32(len(sys.Environ.NullTerminatedValues))) {
+	if !mem.WriteUint32Le(resultEnvironc, uint32(len(sys.Environ()))) {
 		return wasi.ErrnoFault
 	}
-	if !ctx.Memory().WriteUint32Le(resultEnvironBufSize, sys.Environ.TotalBufSize) {
+	if !mem.WriteUint32Le(resultEnvironBufSize, sys.EnvironSize()) {
 		return wasi.ErrnoFault
 	}
 
@@ -1107,19 +1083,14 @@ func (a *wasiAPI) FdAllocate(ctx wasm.Module, fd uint32, offset, len uint64) was
 
 // FdClose implements SnapshotPreview1.FdClose
 func (a *wasiAPI) FdClose(ctx wasm.Module, fd uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	f, ok := sys.OpenedFiles[fd]
-	if !ok {
+	if ok, err := sys.CloseFile(fd); err != nil {
+		return wasi.ErrnoIo
+	} else if !ok {
 		return wasi.ErrnoBadf
 	}
 
-	if f.File != nil {
-		f.File.Close()
-	}
-
-	delete(sys.OpenedFiles, fd)
-
 	return wasi.ErrnoSuccess
 }
 
@@ -1131,9 +1102,9 @@ func (a *wasiAPI) FdDatasync(ctx wasm.Module, fd uint32) wasi.Errno {
 // FdFdstatGet implements SnapshotPreview1.FdFdstatGet
 // TODO: Currently FdFdstatget implements nothing except returning fake fs_right_inheriting
 func (a *wasiAPI) FdFdstatGet(ctx wasm.Module, fd uint32, resultStat uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	if _, ok := sys.OpenedFiles[fd]; !ok {
+	if _, ok := sys.OpenedFile(fd); !ok {
 		return wasi.ErrnoBadf
 	}
 	if !ctx.Memory().WriteUint64Le(resultStat+16, rightFDRead|rightFDWrite) {
@@ -1144,9 +1115,9 @@ 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 := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	entry, ok := sys.OpenedFiles[fd]
+	entry, ok := sys.OpenedFile(fd)
 	if !ok || entry.Path == "" {
 		return wasi.ErrnoBadf
 	}
@@ -1195,9 +1166,9 @@ func (a *wasiAPI) FdPread(ctx wasm.Module, fd, iovs, iovsCount uint32, offset ui
 
 // FdPrestatDirName implements SnapshotPreview1.FdPrestatDirName
 func (a *wasiAPI) FdPrestatDirName(ctx wasm.Module, fd uint32, pathPtr uint32, pathLen uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	f, ok := sys.OpenedFiles[fd]
+	f, ok := sys.OpenedFile(fd)
 	if !ok {
 		return wasi.ErrnoBadf
 	}
@@ -1221,19 +1192,14 @@ func (a *wasiAPI) FdPwrite(ctx wasm.Module, fd, iovs, iovsCount uint32, offset u
 
 // FdRead implements SnapshotPreview1.FdRead
 func (a *wasiAPI) FdRead(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
 	var reader io.Reader
 
-	switch fd {
-	case 0:
-		if sys.Stdin != nil {
-			reader = sys.Stdin
-		} else {
-			return wasi.ErrnoBadf
-		}
-	default:
-		f, ok := sys.OpenedFiles[fd]
+	if fd == 0 {
+		reader = sys.Stdin()
+	} else {
+		f, ok := sys.OpenedFile(fd)
 		if !ok || f.File == nil {
 			return wasi.ErrnoBadf
 		}
@@ -1281,9 +1247,9 @@ func (a *wasiAPI) FdRenumber(ctx wasm.Module, fd, to uint32) wasi.Errno {
 
 // FdSeek implements SnapshotPreview1.FdSeek
 func (a *wasiAPI) FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	f, ok := sys.OpenedFiles[fd]
+	f, ok := sys.OpenedFile(fd)
 	if !ok || f.File == nil {
 		return wasi.ErrnoBadf
 	}
@@ -1319,17 +1285,17 @@ func (a *wasiAPI) FdTell(ctx wasm.Module, fd, resultOffset uint32) wasi.Errno {
 
 // FdWrite implements SnapshotPreview1.FdWrite
 func (a *wasiAPI) FdWrite(ctx wasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
 	var writer io.Writer
 
 	switch fd {
 	case 1:
-		writer = sys.Stdout
+		writer = sys.Stdout()
 	case 2:
-		writer = sys.Stderr
+		writer = sys.Stderr()
 	default:
-		f, ok := sys.OpenedFiles[fd]
+		f, ok := sys.OpenedFile(fd)
 		if !ok || f.File == nil {
 			return wasi.ErrnoBadf
 		}
@@ -1420,9 +1386,9 @@ func posixOpenFlags(oFlags uint32, fsRights uint64) (pFlags int) {
 // PathOpen implements SnapshotPreview1.PathOpen
 func (a *wasiAPI) PathOpen(ctx wasm.Module, fd, dirflags, pathPtr, pathLen, oflags uint32, fsRightsBase,
 	fsRightsInheriting uint64, fdflags, resultOpenedFd uint32) (errno wasi.Errno) {
-	sys := systemContext(ctx)
+	sys := sysContext(ctx)
 
-	dir, ok := sys.OpenedFiles[fd]
+	dir, ok := sys.OpenedFile(fd)
 	if !ok || dir.FS == nil {
 		return wasi.ErrnoBadf
 	}
@@ -1447,14 +1413,9 @@ func (a *wasiAPI) PathOpen(ctx wasm.Module, fd, dirflags, pathPtr, pathLen, ofla
 	// when ofagDir is set, the opened file must be a directory.
 	// TODO if oflags&oflagDir != 0 return wasi.ErrnoNotdir if stat != dir
 
-	newFD, err := a.randUnusedFD(sys)
-	if err != nil {
+	if newFD, ok := sys.OpenFile(&internalwasm.FileEntry{Path: pathName, File: f, FS: dir.FS}); !ok {
 		return wasi.ErrnoIo
-	}
-
-	sys.OpenedFiles[newFD] = &internalwasm.FileEntry{Path: pathName, File: f, FS: dir.FS}
-
-	if !ctx.Memory().WriteUint32Le(resultOpenedFd, newFD) {
+	} else if !ctx.Memory().WriteUint32Le(resultOpenedFd, newFD) {
 		return wasi.ErrnoFault
 	}
 	return wasi.ErrnoSuccess
@@ -1551,29 +1512,11 @@ func NewAPI() *wasiAPI {
 	}
 }
 
-func systemContext(ctx wasm.Module) *internalwasm.SystemContext {
+func sysContext(ctx wasm.Module) *internalwasm.SysContext {
 	if internal, ok := ctx.(*internalwasm.ModuleContext); !ok {
 		panic(fmt.Errorf("unsupported wasm.Module implementation: %v", ctx))
 	} else {
-		return internal.System
-	}
-}
-
-func (a *wasiAPI) randUnusedFD(sys *internalwasm.SystemContext) (uint32, error) {
-	// TODO: consider not using random source here, as there's collision avoidance below anyway, and using the random
-	// source is both expensive (including allocation and decoding) and can deplete it.
-	rand := make([]byte, 4)
-	err := a.randSource(rand)
-	if err != nil {
-		return 0, err
-	}
-	// fd is actually a signed int32, and must be a positive number.
-	fd := binary.LittleEndian.Uint32(rand) % (1 << 31)
-	for { // avoid collisions on an already used file-descriptor. Note: this is not goroutine safe.
-		if _, ok := sys.OpenedFiles[fd]; !ok {
-			return fd, nil
-		}
-		fd = (fd + 1) % (1 << 31)
+		return internal.Sys()
 	}
 }
 
@@ -1608,3 +1551,25 @@ func requireExport(module *internalwasm.Module, moduleName string, exportName st
 	}
 	return exp, nil
 }
+
+func writeOffsetsAndNullTerminatedValues(mem wasm.Memory, values []string, offsets, bytes uint32) wasi.Errno {
+	for _, value := range values {
+		// Write current offset and advance it.
+		if !mem.WriteUint32Le(offsets, bytes) {
+			return wasi.ErrnoFault
+		}
+		offsets += 4 // size of uint32
+
+		// Write the next value to memory with a NUL terminator
+		if !mem.Write(bytes, []byte(value)) {
+			return wasi.ErrnoFault
+		}
+		bytes += uint32(len(value))
+		if !mem.WriteByte(bytes, 0) {
+			return wasi.ErrnoFault
+		}
+		bytes++
+	}
+
+	return wasi.ErrnoSuccess
+}
diff --git a/internal/wasm/jit/asm/amd64/assembler.go b/internal/wasm/jit/asm/amd64/assembler.go
index d711cb9e..a7be6f49 100644
--- a/internal/wasm/jit/asm/amd64/assembler.go
+++ b/internal/wasm/jit/asm/amd64/assembler.go
@@ -10,35 +10,38 @@ func NewAssembler() (Assembler, error) {
 
 type Assembler interface {
 	asm.AssemblerBase
-	// CompileConstModeRegisterToRegister adds an instruction where source and destination
+	// CompileRegisterToRegisterWithMode adds an instruction where source and destination
 	// are `from` and `to` registers and the instruction's "mode" is specified by `mode`.
-	// For example, ROUND** instructions can be modified "mode" constant.
-	// See https://www.felixcloutier.com/x86/roundss for ROUNDSS as an example.
-	CompileConstModeRegisterToRegister(inst asm.Instruction, from, to asm.Register, mode int64)
+	CompileRegisterToRegisterWithMode(instruction asm.Instruction, from, to asm.Register, mode Mode)
 	// CompileMemoryWithIndexToRegister adds an instruction where source operand is the memory address
 	// specified as `srcBaseReg + srcOffsetConst + srcIndex*srcScale` and destination is the register `dstReg`.
 	// Note: sourceScale must be one of 1, 2, 4, 8.
-	CompileMemoryWithIndexToRegister(inst asm.Instruction, srcBaseReg asm.Register, srcOffsetConst int64, srcIndex asm.Register, srcScale int16, dstReg asm.Register)
+	CompileMemoryWithIndexToRegister(instruction asm.Instruction, srcBaseReg asm.Register, srcOffsetConst int64, srcIndex asm.Register, srcScale int16, dstReg asm.Register)
 	// CompileRegisterToMemoryWithIndex adds an instruction where source operand is the register `srcReg`,
 	// and the destination is the memory address specified as `dstBaseReg + dstOffsetConst + dstIndex*dstScale`
 	// Note: dstScale must be one of 1, 2, 4, 8.
-	CompileRegisterToMemoryWithIndex(inst asm.Instruction, srcReg asm.Register, dstBaseReg asm.Register, dstOffsetConst int64, dstIndex asm.Register, dstScale int16)
+	CompileRegisterToMemoryWithIndex(instruction asm.Instruction, srcReg asm.Register, dstBaseReg asm.Register, dstOffsetConst int64, dstIndex asm.Register, dstScale int16)
 	// CompileRegisterToConst adds an instruction where source operand is the register `srcRegister`,
 	// and the destination is the const `value`.
-	CompileRegisterToConst(inst asm.Instruction, srcRegister asm.Register, value int64) asm.Node
+	CompileRegisterToConst(instruction asm.Instruction, srcRegister asm.Register, value int64) asm.Node
 	// CompileRegisterToNone adds an instruction where source operand is the register `register`,
 	// and there's no destination operand.
-	CompileRegisterToNone(inst asm.Instruction, register asm.Register)
+	CompileRegisterToNone(instruction asm.Instruction, register asm.Register)
 	// CompileRegisterToNone adds an instruction where destination operand is the register `register`,
 	// and there's no source operand.
-	CompileNoneToRegister(inst asm.Instruction, register asm.Register)
+	CompileNoneToRegister(instruction asm.Instruction, register asm.Register)
 	// CompileRegisterToNone adds an instruction where destination operand is the memory address specified
 	// as `baseReg+offset`. and there's no source operand.
-	CompileNoneToMemory(inst asm.Instruction, baseReg asm.Register, offset int64)
+	CompileNoneToMemory(instruction asm.Instruction, baseReg asm.Register, offset int64)
 	// CompileConstToMemory adds an instruction where source operand is the constant `value` and
 	// the destination is the memory address sppecified as `dstbaseReg+dstOffset`.
-	CompileConstToMemory(inst asm.Instruction, value int64, dstbaseReg asm.Register, dstOffset int64) asm.Node
+	CompileConstToMemory(instruction asm.Instruction, value int64, dstbaseReg asm.Register, dstOffset int64) asm.Node
 	// CompileMemoryToConst adds an instruction where source operand is the memory address, and
 	// the destination is the constant `value`.
-	CompileMemoryToConst(inst asm.Instruction, srcBaseReg asm.Register, srcOffset int64, value int64) asm.Node
+	CompileMemoryToConst(instruction asm.Instruction, srcBaseReg asm.Register, srcOffset int64, value int64) asm.Node
 }
+
+// Mode represents a mode for specific instruction.
+// For example, ROUND** instructions' behavior can be modified "mode" constant.
+// See https://www.felixcloutier.com/x86/roundss for ROUNDSS as an example.
+type Mode = byte
diff --git a/internal/wasm/jit/asm/amd64/consts.go b/internal/wasm/jit/asm/amd64/consts.go
index 04ff3589..d4fced36 100644
--- a/internal/wasm/jit/asm/amd64/consts.go
+++ b/internal/wasm/jit/asm/amd64/consts.go
@@ -2,7 +2,9 @@ package amd64
 
 import "github.com/tetratelabs/wazero/internal/wasm/jit/asm"
 
-// Arm64-specific register states.
+// AMD64-specific conditional register states.
+// https://www.lri.fr/~filliatr/ens/compil/x86-64.pdf
+// https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-instruction-set-reference-manual-325383.pdf
 const (
 	ConditionalRegisterStateE  = asm.ConditionalRegisterStateUnset + 1 + iota // ZF equal to zero
 	ConditionalRegisterStateNE                                                //˜ZF not equal to zero
@@ -18,9 +20,13 @@ const (
 	ConditionalRegisterStateBE                                                // CF | ZF below or equal (unsigned <=)
 )
 
-// Arm64-specific instructions.
+// AMD64-specific instructions.
+// https://www.felixcloutier.com/x86/index.html
+//
+// Note: here we do not define all of amd64 instructions, and we only define the ones used by wazero's JIT compiler.
+// Note: naming convension is exactly the same as Go assembler: https://go.dev/doc/asm
 const (
-	ADDL = iota
+	ADDL asm.Instruction = iota
 	ADDQ
 	ADDSD
 	ADDSS
@@ -112,7 +118,6 @@ const (
 	ROUNDSS
 	SARL
 	SARQ
-	SET
 	SETCC
 	SETCS
 	SETEQ
@@ -153,7 +158,277 @@ const (
 	UD2
 )
 
+func instructionName(instruction asm.Instruction) string {
+	switch instruction {
+	case ADDL:
+		return "ADDL"
+	case ADDQ:
+		return "ADDQ"
+	case ADDSD:
+		return "ADDSD"
+	case ADDSS:
+		return "ADDSS"
+	case ANDL:
+		return "ANDL"
+	case ANDPD:
+		return "ANDPD"
+	case ANDPS:
+		return "ANDPS"
+	case ANDQ:
+		return "ANDQ"
+	case BSRL:
+		return "BSRL"
+	case BSRQ:
+		return "BSRQ"
+	case CDQ:
+		return "CDQ"
+	case CMOVQCS:
+		return "CMOVQCS"
+	case CMPL:
+		return "CMPL"
+	case CMPQ:
+		return "CMPQ"
+	case COMISD:
+		return "COMISD"
+	case COMISS:
+		return "COMISS"
+	case CQO:
+		return "CQO"
+	case CVTSD2SS:
+		return "CVTSD2SS"
+	case CVTSL2SD:
+		return "CVTSL2SD"
+	case CVTSL2SS:
+		return "CVTSL2SS"
+	case CVTSQ2SD:
+		return "CVTSQ2SD"
+	case CVTSQ2SS:
+		return "CVTSQ2SS"
+	case CVTSS2SD:
+		return "CVTSS2SD"
+	case CVTTSD2SL:
+		return "CVTTSD2SL"
+	case CVTTSD2SQ:
+		return "CVTTSD2SQ"
+	case CVTTSS2SL:
+		return "CVTTSS2SL"
+	case CVTTSS2SQ:
+		return "CVTTSS2SQ"
+	case DECQ:
+		return "DECQ"
+	case DIVL:
+		return "DIVL"
+	case DIVQ:
+		return "DIVQ"
+	case DIVSD:
+		return "DIVSD"
+	case DIVSS:
+		return "DIVSS"
+	case IDIVL:
+		return "IDIVL"
+	case IDIVQ:
+		return "IDIVQ"
+	case INCQ:
+		return "INCQ"
+	case JCC:
+		return "JCC"
+	case JCS:
+		return "JCS"
+	case JEQ:
+		return "JEQ"
+	case JGE:
+		return "JGE"
+	case JGT:
+		return "JGT"
+	case JHI:
+		return "JHI"
+	case JLE:
+		return "JLE"
+	case JLS:
+		return "JLS"
+	case JLT:
+		return "JLT"
+	case JMI:
+		return "JMI"
+	case JNE:
+		return "JNE"
+	case JPC:
+		return "JPC"
+	case JPL:
+		return "JPL"
+	case JPS:
+		return "JPS"
+	case LEAQ:
+		return "LEAQ"
+	case LZCNTL:
+		return "LZCNTL"
+	case LZCNTQ:
+		return "LZCNTQ"
+	case MAXSD:
+		return "MAXSD"
+	case MAXSS:
+		return "MAXSS"
+	case MINSD:
+		return "MINSD"
+	case MINSS:
+		return "MINSS"
+	case MOVB:
+		return "MOVB"
+	case MOVBLSX:
+		return "MOVBLSX"
+	case MOVBLZX:
+		return "MOVBLZX"
+	case MOVBQSX:
+		return "MOVBQSX"
+	case MOVBQZX:
+		return "MOVBQZX"
+	case MOVL:
+		return "MOVL"
+	case MOVLQSX:
+		return "MOVLQSX"
+	case MOVLQZX:
+		return "MOVLQZX"
+	case MOVQ:
+		return "MOVQ"
+	case MOVW:
+		return "MOVW"
+	case MOVWLSX:
+		return "MOVWLSX"
+	case MOVWLZX:
+		return "MOVWLZX"
+	case MOVWQSX:
+		return "MOVWQSX"
+	case MOVWQZX:
+		return "MOVWQZX"
+	case MULL:
+		return "MULL"
+	case MULQ:
+		return "MULQ"
+	case MULSD:
+		return "MULSD"
+	case MULSS:
+		return "MULSS"
+	case ORL:
+		return "ORL"
+	case ORPD:
+		return "ORPD"
+	case ORPS:
+		return "ORPS"
+	case ORQ:
+		return "ORQ"
+	case POPCNTL:
+		return "POPCNTL"
+	case POPCNTQ:
+		return "POPCNTQ"
+	case PSLLL:
+		return "PSLLL"
+	case PSLLQ:
+		return "PSLLQ"
+	case PSRLL:
+		return "PSRLL"
+	case PSRLQ:
+		return "PSRLQ"
+	case ROLL:
+		return "ROLL"
+	case ROLQ:
+		return "ROLQ"
+	case RORL:
+		return "RORL"
+	case RORQ:
+		return "RORQ"
+	case ROUNDSD:
+		return "ROUNDSD"
+	case ROUNDSS:
+		return "ROUNDSS"
+	case SARL:
+		return "SARL"
+	case SARQ:
+		return "SARQ"
+	case SETCC:
+		return "SETCC"
+	case SETCS:
+		return "SETCS"
+	case SETEQ:
+		return "SETEQ"
+	case SETGE:
+		return "SETGE"
+	case SETGT:
+		return "SETGT"
+	case SETHI:
+		return "SETHI"
+	case SETLE:
+		return "SETLE"
+	case SETLS:
+		return "SETLS"
+	case SETLT:
+		return "SETLT"
+	case SETMI:
+		return "SETMI"
+	case SETNE:
+		return "SETNE"
+	case SETPC:
+		return "SETPC"
+	case SETPL:
+		return "SETPL"
+	case SETPS:
+		return "SETPS"
+	case SHLL:
+		return "SHLL"
+	case SHLQ:
+		return "SHLQ"
+	case SHRL:
+		return "SHRL"
+	case SHRQ:
+		return "SHRQ"
+	case SQRTSD:
+		return "SQRTSD"
+	case SQRTSS:
+		return "SQRTSS"
+	case SUBL:
+		return "SUBL"
+	case SUBQ:
+		return "SUBQ"
+	case SUBSD:
+		return "SUBSD"
+	case SUBSS:
+		return "SUBSS"
+	case TESTL:
+		return "TESTL"
+	case TESTQ:
+		return "TESTQ"
+	case TZCNTL:
+		return "TZCNTL"
+	case TZCNTQ:
+		return "TZCNTQ"
+	case UCOMISD:
+		return "UCOMISD"
+	case UCOMISS:
+		return "UCOMISS"
+	case XORL:
+		return "XORL"
+	case XORPD:
+		return "XORPD"
+	case XORPS:
+		return "XORPS"
+	case XORQ:
+		return "XORQ"
+	case RET:
+		return "RET"
+	case JMP:
+		return "JMP"
+	case NOP:
+		return "NOP"
+	case UD2:
+		return "UD2"
+	}
+	return "Unknown"
+}
+
 // Arm64-specific registers.
+// https://www.lri.fr/~filliatr/ens/compil/x86-64.pdf
+// https://cs.brown.edu/courses/cs033/docs/guides/x64_cheatsheet.pdf
+//
+// Note: naming convension is exactly the same as Go assembler: https://go.dev/doc/asm
 const (
 	REG_AX asm.Register = asm.NilRegister + 1 + iota
 	REG_CX
@@ -204,3 +479,105 @@ const (
 	REG_X30
 	REG_X31
 )
+
+func registerName(reg asm.Register) string {
+	switch reg {
+	case REG_AX:
+		return "AX"
+	case REG_CX:
+		return "CX"
+	case REG_DX:
+		return "DX"
+	case REG_BX:
+		return "BX"
+	case REG_SP:
+		return "SP"
+	case REG_BP:
+		return "BP"
+	case REG_SI:
+		return "SI"
+	case REG_DI:
+		return "DI"
+	case REG_R8:
+		return "R8"
+	case REG_R9:
+		return "R9"
+	case REG_R10:
+		return "R10"
+	case REG_R11:
+		return "R11"
+	case REG_R12:
+		return "R12"
+	case REG_R13:
+		return "R13"
+	case REG_R14:
+		return "R14"
+	case REG_R15:
+		return "R15"
+	case REG_X0:
+		return "X0"
+	case REG_X1:
+		return "X1"
+	case REG_X2:
+		return "X2"
+	case REG_X3:
+		return "X3"
+	case REG_X4:
+		return "X4"
+	case REG_X5:
+		return "X5"
+	case REG_X6:
+		return "X6"
+	case REG_X7:
+		return "X7"
+	case REG_X8:
+		return "X8"
+	case REG_X9:
+		return "X9"
+	case REG_X10:
+		return "X10"
+	case REG_X11:
+		return "X11"
+	case REG_X12:
+		return "X12"
+	case REG_X13:
+		return "X13"
+	case REG_X14:
+		return "X14"
+	case REG_X15:
+		return "X15"
+	case REG_X16:
+		return "X16"
+	case REG_X17:
+		return "X17"
+	case REG_X18:
+		return "X18"
+	case REG_X19:
+		return "X19"
+	case REG_X20:
+		return "X20"
+	case REG_X21:
+		return "X21"
+	case REG_X22:
+		return "X22"
+	case REG_X23:
+		return "X23"
+	case REG_X24:
+		return "X24"
+	case REG_X25:
+		return "X25"
+	case REG_X26:
+		return "X26"
+	case REG_X27:
+		return "X27"
+	case REG_X28:
+		return "X28"
+	case REG_X29:
+		return "X29"
+	case REG_X30:
+		return "X30"
+	case REG_X31:
+		return "X31"
+	}
+	return "Unknown"
+}
diff --git a/internal/wasm/jit/asm/amd64/golang_asm.go b/internal/wasm/jit/asm/amd64/golang_asm.go
index c4de3f48..678f2184 100644
--- a/internal/wasm/jit/asm/amd64/golang_asm.go
+++ b/internal/wasm/jit/asm/amd64/golang_asm.go
@@ -41,7 +41,7 @@ func (a *assemblerGoAsmImpl) CompileRegisterToRegister(inst asm.Instruction, fro
 
 // CompileMemoryWithIndexToRegister implements Assembler.CompileMemoryWithIndexToRegister.
 func (a *assemblerGoAsmImpl) CompileMemoryWithIndexToRegister(inst asm.Instruction,
-	sourceBaseReg asm.Register, sourceOffsetConst int64, sourceIndexReg asm.Register, sourceScale int16, destinationReg asm.Register) {
+	sourceBaseReg asm.Register, sourceOffsetConst asm.ConstantValue, sourceIndexReg asm.Register, sourceScale int16, destinationReg asm.Register) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.To.Type = obj.TYPE_REG
@@ -55,7 +55,7 @@ func (a *assemblerGoAsmImpl) CompileMemoryWithIndexToRegister(inst asm.Instructi
 }
 
 // CompileRegisterToMemoryWithIndex implements Assembler.CompileRegisterToMemoryWithIndex.
-func (a *assemblerGoAsmImpl) CompileRegisterToMemoryWithIndex(inst asm.Instruction, srcReg asm.Register, dstBaseReg asm.Register, dstOffsetConst int64, dstIndexReg asm.Register, dstScale int16) {
+func (a *assemblerGoAsmImpl) CompileRegisterToMemoryWithIndex(inst asm.Instruction, srcReg asm.Register, dstBaseReg asm.Register, dstOffsetConst asm.ConstantValue, dstIndexReg asm.Register, dstScale int16) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.From.Type = obj.TYPE_REG
@@ -69,7 +69,7 @@ func (a *assemblerGoAsmImpl) CompileRegisterToMemoryWithIndex(inst asm.Instructi
 }
 
 // CompileRegisterToMemory implements Assembler.CompileRegisterToMemory.
-func (a *assemblerGoAsmImpl) CompileRegisterToMemory(inst asm.Instruction, sourceRegister asm.Register, destinationBaseRegister asm.Register, destinationOffsetConst int64) {
+func (a *assemblerGoAsmImpl) CompileRegisterToMemory(inst asm.Instruction, sourceRegister asm.Register, destinationBaseRegister asm.Register, destinationOffsetConst asm.ConstantValue) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.To.Type = obj.TYPE_MEM
@@ -81,7 +81,7 @@ func (a *assemblerGoAsmImpl) CompileRegisterToMemory(inst asm.Instruction, sourc
 }
 
 // CompileConstToRegister implements Assembler.CompileConstToRegister.
-func (a *assemblerGoAsmImpl) CompileConstToRegister(inst asm.Instruction, constValue int64, destinationRegister asm.Register) asm.Node {
+func (a *assemblerGoAsmImpl) CompileConstToRegister(inst asm.Instruction, constValue asm.ConstantValue, destinationRegister asm.Register) asm.Node {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.From.Type = obj.TYPE_CONST
@@ -93,7 +93,7 @@ func (a *assemblerGoAsmImpl) CompileConstToRegister(inst asm.Instruction, constV
 }
 
 // CompileRegisterToConst implements Assembler.CompileRegisterToConst.
-func (a *assemblerGoAsmImpl) CompileRegisterToConst(inst asm.Instruction, srcRegister asm.Register, constValue int64) asm.Node {
+func (a *assemblerGoAsmImpl) CompileRegisterToConst(inst asm.Instruction, srcRegister asm.Register, constValue asm.ConstantValue) asm.Node {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.To.Type = obj.TYPE_CONST
@@ -125,7 +125,7 @@ func (a *assemblerGoAsmImpl) CompileNoneToRegister(inst asm.Instruction, registe
 }
 
 // CompileNoneToMemory implements Assembler.CompileNoneToMemory.
-func (a *assemblerGoAsmImpl) CompileNoneToMemory(inst asm.Instruction, baseReg asm.Register, offset int64) {
+func (a *assemblerGoAsmImpl) CompileNoneToMemory(inst asm.Instruction, baseReg asm.Register, offset asm.ConstantValue) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.To.Type = obj.TYPE_MEM
@@ -136,7 +136,7 @@ func (a *assemblerGoAsmImpl) CompileNoneToMemory(inst asm.Instruction, baseReg a
 }
 
 // CompileConstToMemory implements Assembler.CompileConstToMemory.
-func (a *assemblerGoAsmImpl) CompileConstToMemory(inst asm.Instruction, constValue int64, baseReg asm.Register, offset int64) asm.Node {
+func (a *assemblerGoAsmImpl) CompileConstToMemory(inst asm.Instruction, constValue asm.ConstantValue, baseReg asm.Register, offset asm.ConstantValue) asm.Node {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.From.Type = obj.TYPE_CONST
@@ -149,7 +149,7 @@ func (a *assemblerGoAsmImpl) CompileConstToMemory(inst asm.Instruction, constVal
 }
 
 // CompileMemoryToRegister implements AssemblerBase.CompileMemoryToRegister.
-func (a *assemblerGoAsmImpl) CompileMemoryToRegister(inst asm.Instruction, sourceBaseReg asm.Register, sourceOffsetConst int64, destinationReg asm.Register) {
+func (a *assemblerGoAsmImpl) CompileMemoryToRegister(inst asm.Instruction, sourceBaseReg asm.Register, sourceOffsetConst asm.ConstantValue, destinationReg asm.Register) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.From.Type = obj.TYPE_MEM
@@ -161,7 +161,7 @@ func (a *assemblerGoAsmImpl) CompileMemoryToRegister(inst asm.Instruction, sourc
 }
 
 // CompileMemoryToConst implements Assembler.CompileMemoryToConst.
-func (a *assemblerGoAsmImpl) CompileMemoryToConst(inst asm.Instruction, baseReg asm.Register, offset int64, constValue int64) asm.Node {
+func (a *assemblerGoAsmImpl) CompileMemoryToConst(inst asm.Instruction, baseReg asm.Register, offset asm.ConstantValue, constValue asm.ConstantValue) asm.Node {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.To.Type = obj.TYPE_CONST
@@ -173,11 +173,6 @@ func (a *assemblerGoAsmImpl) CompileMemoryToConst(inst asm.Instruction, baseReg
 	return asm.NewGolangAsmNode(p)
 }
 
-// CompileUnconditionalJump implements Assembler.CompileUnconditionalJump.
-func (a *assemblerGoAsmImpl) CompileUnconditionalJump() asm.Node {
-	return a.CompileJump(JMP)
-}
-
 // CompileJump implements Assembler.CompileJump.
 func (a *assemblerGoAsmImpl) CompileJump(jmpInstruction asm.Instruction) asm.Node {
 	p := a.NewProg()
@@ -197,7 +192,7 @@ func (a *assemblerGoAsmImpl) CompileJumpToRegister(jmpInstruction asm.Instructio
 }
 
 // CompileJumpToMemory implements Assembler.CompileJumpToMemory.
-func (a *assemblerGoAsmImpl) CompileJumpToMemory(jmpInstruction asm.Instruction, baseReg asm.Register, offset int64) {
+func (a *assemblerGoAsmImpl) CompileJumpToMemory(jmpInstruction asm.Instruction, baseReg asm.Register, offset asm.ConstantValue) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[jmpInstruction]
 	p.To.Type = obj.TYPE_MEM
@@ -206,12 +201,12 @@ func (a *assemblerGoAsmImpl) CompileJumpToMemory(jmpInstruction asm.Instruction,
 	a.AddInstruction(p)
 }
 
-// CompileConstModeRegisterToRegister implements Assembler.CompileConstModeRegisterToRegister.
-func (a *assemblerGoAsmImpl) CompileConstModeRegisterToRegister(inst asm.Instruction, from, to asm.Register, mode int64) {
+// CompileRegisterToRegisterWithMode implements Assembler.CompileRegisterToRegisterWithMode.
+func (a *assemblerGoAsmImpl) CompileRegisterToRegisterWithMode(inst asm.Instruction, from, to asm.Register, mode Mode) {
 	p := a.NewProg()
 	p.As = castAsGolangAsmInstruction[inst]
 	p.From.Type = obj.TYPE_CONST
-	p.From.Offset = mode
+	p.From.Offset = int64(mode)
 	p.To.Type = obj.TYPE_REG
 	p.To.Reg = castAsGolangAsmRegister[to]
 	p.RestArgs = append(p.RestArgs,
diff --git a/internal/wasm/jit/asm/amd64/impl.go b/internal/wasm/jit/asm/amd64/impl.go
new file mode 100644
index 00000000..3d742809
--- /dev/null
+++ b/internal/wasm/jit/asm/amd64/impl.go
@@ -0,0 +1,411 @@
+package amd64
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+
+	"github.com/tetratelabs/wazero/internal/wasm/jit/asm"
+)
+
+// nodeImpl implements asm.Node for amd64.
+type nodeImpl struct {
+	instruction asm.Instruction
+
+	offsetInBinary asm.NodeOffsetInBinary
+	// jumpTarget holds the target node in the linked for the jump-kind instruction.
+	jumpTarget *nodeImpl
+	// prev holds the previous node to this node in the assembled linked list.
+	prev *nodeImpl
+	// next holds the next node from this node in the assembled linked list.
+	next *nodeImpl
+
+	types                    operandTypes
+	srcReg, dstReg           asm.Register
+	srcConst, dstConst       asm.ConstantValue
+	srcMemIndex, dstMemIndex asm.Register
+	srcMemScale, dstMemScale byte
+
+	mode byte
+}
+
+// AssignJumpTarget implements asm.Node.AssignJumpTarget.
+func (n *nodeImpl) AssignJumpTarget(target asm.Node) {
+	n.jumpTarget = target.(*nodeImpl)
+}
+
+// AssignSourceConstant implements asm.Node.AssignSourceConstant.
+func (n *nodeImpl) AssignDestinationConstant(value asm.ConstantValue) {
+	n.dstConst = value
+}
+
+// AssignSourceConstant implements asm.Node.AssignSourceConstant.
+func (n *nodeImpl) AssignSourceConstant(value asm.ConstantValue) {
+	n.srcConst = value
+}
+
+// OffsetInBinary implements asm.Node.OffsetInBinary.
+func (n *nodeImpl) OffsetInBinary() asm.NodeOffsetInBinary {
+	return n.offsetInBinary
+}
+
+// String implements fmt.Stringer.
+//
+// This is for debugging purpose, and the format is almost same as the AT&T assembly syntax,
+// meaning that this should look like "INSTRUCTION ${from}, ${to}" where each operand
+// might be embraced by '[]' to represent the memory location.
+func (n *nodeImpl) String() (ret string) {
+	instName := instructionName(n.instruction)
+	switch n.types {
+	case operandTypesNoneToNone:
+		ret = instName
+	case operandTypesNoneToRegister:
+		ret = fmt.Sprintf("%s %s", instName, registerName(n.dstReg))
+	case operandTypesNoneToMemory:
+		if n.dstMemIndex != asm.NilRegister {
+			ret = fmt.Sprintf("%s [%s + 0x%x + %s*0x%x]", instName,
+				registerName(n.dstReg), n.dstConst, registerName(n.dstMemIndex), n.dstMemScale)
+		} else {
+			ret = fmt.Sprintf("%s [%s + 0x%x]", instName, registerName(n.dstReg), n.dstConst)
+		}
+	case operandTypesNoneToBranch:
+		ret = fmt.Sprintf("%s {%v}", instName, n.jumpTarget)
+	case operandTypesRegisterToNone:
+		ret = fmt.Sprintf("%s %s", instName, registerName(n.srcReg))
+	case operandTypesRegisterToRegister:
+		ret = fmt.Sprintf("%s %s, %s", instName, registerName(n.srcReg), registerName(n.dstReg))
+	case operandTypesRegisterToMemory:
+		if n.dstMemIndex != asm.NilRegister {
+			ret = fmt.Sprintf("%s %s, [%s + 0x%x + %s*0x%x]", instName, registerName(n.srcReg),
+				registerName(n.dstReg), n.dstConst, registerName(n.dstMemIndex), n.dstMemScale)
+		} else {
+			ret = fmt.Sprintf("%s %s, [%s + 0x%x]", instName, registerName(n.srcReg), registerName(n.dstReg), n.dstConst)
+		}
+	case operandTypesRegisterToConst:
+		ret = fmt.Sprintf("%s %s, 0x%x", instName, registerName(n.srcReg), n.dstConst)
+	case operandTypesMemoryToRegister:
+		if n.srcMemIndex != asm.NilRegister {
+			ret = fmt.Sprintf("%s [%s + %d + %s*0x%x], %s", instName,
+				registerName(n.srcReg), n.srcConst, registerName(n.srcMemIndex), n.srcMemScale, registerName(n.dstReg))
+		} else {
+			ret = fmt.Sprintf("%s [%s + 0x%x], %s", instName, registerName(n.srcReg), n.srcConst, registerName(n.dstReg))
+		}
+	case operandTypesMemoryToConst:
+		if n.srcMemIndex != asm.NilRegister {
+			ret = fmt.Sprintf("%s [%s + %d + %s*0x%x], 0x%x", instName,
+				registerName(n.srcReg), n.srcConst, registerName(n.srcMemIndex), n.srcMemScale, n.dstConst)
+		} else {
+			ret = fmt.Sprintf("%s [%s + 0x%x], 0x%x", instName, registerName(n.srcReg), n.srcConst, n.dstConst)
+		}
+	case operandTypesConstToMemory:
+		if n.dstMemIndex != asm.NilRegister {
+			ret = fmt.Sprintf("%s 0x%x, [%s + 0x%x + %s*0x%x]", instName, n.srcConst,
+				registerName(n.dstReg), n.dstConst, registerName(n.dstMemIndex), n.dstMemScale)
+		} else {
+			ret = fmt.Sprintf("%s 0x%x, [%s + 0x%x]", instName, n.srcConst, registerName(n.dstReg), n.dstConst)
+		}
+	case operandTypesConstToRegister:
+		ret = fmt.Sprintf("%s 0x%x, %s", instName, n.srcConst, registerName(n.dstReg))
+	}
+	return
+}
+
+// operandType represents where an operand is placed for an instruction.
+// Note: this is almost the same as obj.AddrType in GO assembler.
+type operandType byte
+
+const (
+	operandTypeNone operandType = iota
+	operandTypeRegister
+	operandTypeMemory
+	operandTypeConst
+	operandTypeBranch
+)
+
+func (o operandType) String() (ret string) {
+	switch o {
+	case operandTypeNone:
+		ret = "none"
+	case operandTypeRegister:
+		ret = "register"
+	case operandTypeMemory:
+		ret = "memory"
+	case operandTypeConst:
+		ret = "const"
+	case operandTypeBranch:
+		ret = "branch"
+	}
+	return
+}
+
+// operandTypes represents the only combinations of two operandTypes used by wazero
+type operandTypes struct{ src, dst operandType }
+
+var (
+	operandTypesNoneToNone         = operandTypes{operandTypeNone, operandTypeNone}
+	operandTypesNoneToRegister     = operandTypes{operandTypeNone, operandTypeRegister}
+	operandTypesNoneToMemory       = operandTypes{operandTypeNone, operandTypeMemory}
+	operandTypesNoneToBranch       = operandTypes{operandTypeNone, operandTypeBranch}
+	operandTypesRegisterToNone     = operandTypes{operandTypeRegister, operandTypeNone}
+	operandTypesRegisterToRegister = operandTypes{operandTypeRegister, operandTypeRegister}
+	operandTypesRegisterToMemory   = operandTypes{operandTypeRegister, operandTypeMemory}
+	operandTypesRegisterToConst    = operandTypes{operandTypeRegister, operandTypeConst}
+	operandTypesMemoryToRegister   = operandTypes{operandTypeMemory, operandTypeRegister}
+	operandTypesMemoryToConst      = operandTypes{operandTypeMemory, operandTypeConst}
+	operandTypesConstToRegister    = operandTypes{operandTypeConst, operandTypeRegister}
+	operandTypesConstToMemory      = operandTypes{operandTypeConst, operandTypeMemory}
+)
+
+// String implements fmt.Stringer
+func (o operandTypes) String() string {
+	return fmt.Sprintf("from:%s,to:%s", o.src, o.dst)
+}
+
+// assemblerImpl implements Assembler.
+type assemblerImpl struct {
+	asm.BaseAssemblerImpl
+
+	root, current *nodeImpl
+}
+
+var _ Assembler = &assemblerImpl{}
+
+// newNode creates a new Node and appends it into the linked list.
+func (a *assemblerImpl) newNode(instruction asm.Instruction, types operandTypes) *nodeImpl {
+	n := &nodeImpl{
+		instruction: instruction,
+		prev:        nil,
+		next:        nil,
+		types:       types,
+	}
+	a.addNode(n)
+	return n
+}
+
+// addNode appends the new node into the linked list.
+func (a *assemblerImpl) addNode(node *nodeImpl) {
+	if a.root == nil {
+		a.root = node
+		a.current = node
+	} else {
+		parent := a.current
+		parent.next = node
+		a.current = node
+	}
+}
+
+// encodeNode encodes the given node into writer.
+func (a *assemblerImpl) encodeNode(w io.Writer, n *nodeImpl) (err error) {
+	switch n.types {
+	case operandTypesNoneToNone:
+		err = a.encodeNoneToNone(w, n)
+	case operandTypesNoneToRegister:
+		err = a.encodeNoneToRegister(w, n)
+	case operandTypesNoneToMemory:
+		err = a.encodeNoneToMemory(w, n)
+	case operandTypesNoneToBranch:
+		err = a.encodeNoneToBranch(w, n)
+	case operandTypesRegisterToNone:
+		err = a.encodeRegisterToNone(w, n)
+	case operandTypesRegisterToRegister:
+		err = a.encodeRegisterToRegister(w, n)
+	case operandTypesRegisterToMemory:
+		err = a.encodeRegisterToMemory(w, n)
+	case operandTypesRegisterToConst:
+		err = a.encodeRegisterToConst(w, n)
+	case operandTypesMemoryToRegister:
+		err = a.encodeMemoryToRegister(w, n)
+	default:
+		err = fmt.Errorf("encoder undefined for [%s] operand type", n.types)
+	}
+	return
+}
+
+// Assemble implements asm.AssemblerBase
+func (a *assemblerImpl) Assemble() ([]byte, error) {
+	w := bytes.NewBuffer(nil)
+	// TODO: NOP padding for jumps before encodes: https://github.com/golang/go/issues/35881
+
+	for n := a.root; n != nil; n = n.next {
+		if err := a.encodeNode(w, n); err != nil {
+			return nil, err
+		}
+	}
+
+	code := w.Bytes()
+	for _, cb := range a.OnGenerateCallbacks {
+		if err := cb(code); err != nil {
+			return nil, err
+		}
+	}
+	return code, nil
+}
+
+// CompileReadInstructionAddress implements asm.AssemblerBase.CompileReadInstructionAddress
+func (a *assemblerImpl) CompileStandAlone(instruction asm.Instruction) asm.Node {
+	return a.newNode(instruction, operandTypesNoneToNone)
+}
+
+// CompileConstToRegister implements asm.AssemblerBase.CompileConstToRegister
+func (a *assemblerImpl) CompileConstToRegister(instruction asm.Instruction, value asm.ConstantValue, destinationReg asm.Register) (inst asm.Node) {
+	n := a.newNode(instruction, operandTypesConstToRegister)
+	n.srcConst = value
+	n.dstReg = destinationReg
+	return n
+}
+
+// CompileRegisterToRegister implements asm.AssemblerBase.CompileRegisterToRegister
+func (a *assemblerImpl) CompileRegisterToRegister(instruction asm.Instruction, from, to asm.Register) {
+	n := a.newNode(instruction, operandTypesRegisterToRegister)
+	n.srcReg = from
+	n.dstReg = to
+}
+
+// CompileMemoryToRegister implements asm.AssemblerBase.CompileMemoryToRegister
+func (a *assemblerImpl) CompileMemoryToRegister(instruction asm.Instruction, sourceBaseReg asm.Register, sourceOffsetConst asm.ConstantValue, destinationReg asm.Register) {
+	n := a.newNode(instruction, operandTypesMemoryToRegister)
+	n.srcReg = sourceBaseReg
+	n.srcConst = sourceOffsetConst
+	n.dstReg = destinationReg
+}
+
+// CompileRegisterToMemory implements asm.AssemblerBase.CompileRegisterToMemory
+func (a *assemblerImpl) CompileRegisterToMemory(instruction asm.Instruction, sourceRegister asm.Register, destinationBaseRegister asm.Register, destinationOffsetConst asm.ConstantValue) {
+	n := a.newNode(instruction, operandTypesRegisterToMemory)
+	n.srcReg = sourceRegister
+	n.dstReg = destinationBaseRegister
+	n.dstConst = destinationOffsetConst
+}
+
+// CompileJump implements asm.AssemblerBase.CompileJump
+func (a *assemblerImpl) CompileJump(jmpInstruction asm.Instruction) asm.Node {
+	return a.newNode(jmpInstruction, operandTypesNoneToBranch)
+}
+
+// CompileJumpToMemory implements asm.AssemblerBase.CompileJumpToMemory
+func (a *assemblerImpl) CompileJumpToMemory(jmpInstruction asm.Instruction, baseReg asm.Register, offset asm.ConstantValue) {
+	n := a.newNode(jmpInstruction, operandTypesNoneToMemory)
+	n.dstReg = baseReg
+	n.dstConst = offset
+}
+
+// CompileJumpToRegister implements asm.AssemblerBase.CompileJumpToRegister
+func (a *assemblerImpl) CompileJumpToRegister(jmpInstruction asm.Instruction, reg asm.Register) {
+	n := a.newNode(jmpInstruction, operandTypesNoneToRegister)
+	n.dstReg = reg
+}
+
+// CompileReadInstructionAddress implements asm.AssemblerBase.CompileReadInstructionAddress
+func (a *assemblerImpl) CompileReadInstructionAddress(destinationRegister asm.Register, beforeAcquisitionTargetInstruction asm.Instruction) {
+	// TODO
+}
+
+// CompileRegisterToRegisterWithMode implements assembler.CompileRegisterToRegisterWithMode
+func (a *assemblerImpl) CompileRegisterToRegisterWithMode(instruction asm.Instruction, from, to asm.Register, mode Mode) {
+	n := a.newNode(instruction, operandTypesRegisterToRegister)
+	n.srcReg = from
+	n.dstReg = to
+	n.mode = mode
+}
+
+// CompileMemoryWithIndexToRegister implements assembler.CompileMemoryWithIndexToRegister
+func (a *assemblerImpl) CompileMemoryWithIndexToRegister(instruction asm.Instruction, srcBaseReg asm.Register, srcOffsetConst asm.ConstantValue, srcIndex asm.Register, srcScale int16, dstReg asm.Register) {
+	n := a.newNode(instruction, operandTypesMemoryToRegister)
+	n.srcReg = srcBaseReg
+	n.srcConst = srcOffsetConst
+	n.srcMemIndex = srcIndex
+	n.srcMemScale = byte(srcScale)
+	n.dstReg = dstReg
+}
+
+// CompileRegisterToMemoryWithIndex implements assembler.CompileRegisterToMemoryWithIndex
+func (a *assemblerImpl) CompileRegisterToMemoryWithIndex(instruction asm.Instruction, srcReg asm.Register, dstBaseReg asm.Register, dstOffsetConst asm.ConstantValue, dstIndex asm.Register, dstScale int16) {
+	n := a.newNode(instruction, operandTypesRegisterToMemory)
+	n.srcReg = srcReg
+	n.dstReg = dstBaseReg
+	n.dstConst = dstOffsetConst
+	n.dstMemIndex = dstIndex
+	n.dstMemScale = byte(dstScale)
+}
+
+// CompileRegisterToConst implements assembler.CompileRegisterToConst
+func (a *assemblerImpl) CompileRegisterToConst(instruction asm.Instruction, srcRegister asm.Register, value asm.ConstantValue) asm.Node {
+	n := a.newNode(instruction, operandTypesRegisterToConst)
+	n.srcReg = srcRegister
+	n.dstConst = value
+	return n
+}
+
+// CompileRegisterToNone implements assembler.CompileRegisterToNone
+func (a *assemblerImpl) CompileRegisterToNone(instruction asm.Instruction, register asm.Register) {
+	n := a.newNode(instruction, operandTypesRegisterToNone)
+	n.srcReg = register
+}
+
+// CompileNoneToRegister implements assembler.CompileNoneToRegister
+func (a *assemblerImpl) CompileNoneToRegister(instruction asm.Instruction, register asm.Register) {
+	n := a.newNode(instruction, operandTypesNoneToRegister)
+	n.dstReg = register
+}
+
+// CompileNoneToMemory implements assembler.CompileNoneToMemory
+func (a *assemblerImpl) CompileNoneToMemory(instruction asm.Instruction, baseReg asm.Register, offset asm.ConstantValue) {
+	n := a.newNode(instruction, operandTypesNoneToMemory)
+	n.dstReg = baseReg
+	n.dstConst = offset
+}
+
+// CompileConstToMemory implements assembler.CompileConstToMemory
+func (a *assemblerImpl) CompileConstToMemory(instruction asm.Instruction, value asm.ConstantValue, dstbaseReg asm.Register, dstOffset asm.ConstantValue) asm.Node {
+	n := a.newNode(instruction, operandTypesConstToMemory)
+	n.srcConst = value
+	n.dstReg = dstbaseReg
+	n.dstConst = dstOffset
+	return n
+}
+
+// CompileMemoryToConst implements assembler.CompileMemoryToConst
+func (a *assemblerImpl) CompileMemoryToConst(instruction asm.Instruction, srcBaseReg asm.Register, srcOffset asm.ConstantValue, value asm.ConstantValue) asm.Node {
+	n := a.newNode(instruction, operandTypesMemoryToConst)
+	n.srcReg = srcBaseReg
+	n.srcConst = srcOffset
+	n.dstConst = value
+	return n
+}
+
+func (a *assemblerImpl) encodeNoneToNone(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeNoneToRegister(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeNoneToMemory(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeNoneToBranch(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeRegisterToNone(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeRegisterToRegister(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeRegisterToMemory(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeRegisterToConst(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
+
+func (a *assemblerImpl) encodeMemoryToRegister(w io.Writer, n *nodeImpl) error {
+	return errors.New("TODO")
+}
diff --git a/internal/wasm/jit/asm/arm64/assembler.go b/internal/wasm/jit/asm/arm64/assembler.go
index 6e8922b8..27cb9615 100644
--- a/internal/wasm/jit/asm/arm64/assembler.go
+++ b/internal/wasm/jit/asm/arm64/assembler.go
@@ -28,10 +28,10 @@ type Assembler interface {
 	CompileTwoRegistersToNone(instruction asm.Instruction, src1, src2 asm.Register)
 	// CompileTwoRegistersToRegister adds an instruction where source operands consist of one register `src` and
 	// constant `srcConst`, and destination operand is unspecified.
-	CompileRegisterAndConstSourceToNone(instruction asm.Instruction, src asm.Register, srcConst int64)
+	CompileRegisterAndConstSourceToNone(instruction asm.Instruction, src asm.Register, srcConst asm.ConstantValue)
 	// CompileLeftShiftedRegisterToRegister adds an instruction where source operand is the "left shifted register"
 	// represented as `srcReg << shiftNum` and the destaintion is the register `dstReg`.
-	CompileLeftShiftedRegisterToRegister(shiftedSourceReg asm.Register, shiftNum int64, srcReg, dstReg asm.Register)
+	CompileLeftShiftedRegisterToRegister(shiftedSourceReg asm.Register, shiftNum asm.ConstantValue, srcReg, dstReg asm.Register)
 	// CompileSIMDByteToSIMDByte adds an instruction where source and destination operand is the SIMD register
 	// specified as `srcReg.B8` and `dstReg.B8` where `.B8` part of register is called "arrangement".
 	// See https://stackoverflow.com/questions/57294672/what-is-arrangement-specifier-16b-8b-in-arm-assembly-language-instructions
diff --git a/internal/wasm/jit/asm/arm64/consts.go b/internal/wasm/jit/asm/arm64/consts.go
index 9604ebe9..f2d98a33 100644
--- a/internal/wasm/jit/asm/arm64/consts.go
+++ b/internal/wasm/jit/asm/arm64/consts.go
@@ -5,6 +5,8 @@ import (
 )
 
 // Arm64-specific register states.
+// https://community.arm.com/arm-community-blogs/b/architectures-and-processors-blog/posts/condition-codes-1-condition-flags-and-codes
+// Note: naming convension is exactly the same as Go assembler: https://go.dev/doc/asm
 const (
 	COND_EQ asm.ConditionalRegisterState = asm.ConditionalRegisterStateUnset + 1 + iota
 	COND_NE
@@ -25,6 +27,8 @@ const (
 )
 
 // Arm64-specific registers.
+// https://developer.arm.com/documentation/dui0801/a/Overview-of-AArch64-state/Predeclared-core-register-names-in-AArch64-state
+// Note: naming convension is exactly the same as Go assembler: https://go.dev/doc/asm
 const (
 	// Integer registers.
 	REG_R0 asm.Register = asm.NilRegister + 1 + iota
@@ -99,6 +103,8 @@ const (
 )
 
 // Arm64-specific instructions.
+//
+// Note: naming convension is exactly the same as Go assembler: https://go.dev/doc/asm
 const (
 	NOP asm.Instruction = iota
 	RET
diff --git a/internal/wasm/jit/asm/arm64/golang_asm.go b/internal/wasm/jit/asm/arm64/golang_asm.go
index 2aeefd60..625f3fc2 100644
--- a/internal/wasm/jit/asm/arm64/golang_asm.go
+++ b/internal/wasm/jit/asm/arm64/golang_asm.go
@@ -22,7 +22,7 @@ func newGolangAsmAssembler(temporaryRegister asm.Register) (*assemblerGoAsmImpl,
 }
 
 // CompileConstToRegisterInstruction implements Assembler.CompileConstToRegisterInstruction.
-func (a *assemblerGoAsmImpl) CompileConstToRegister(instruction asm.Instruction, constValue int64, destinationReg asm.Register) asm.Node {
+func (a *assemblerGoAsmImpl) CompileConstToRegister(instruction asm.Instruction, constValue asm.ConstantValue, destinationReg asm.Register) asm.Node {
 	inst := a.NewProg()
 	inst.As = castAsGolangAsmInstruction[instruction]
 	if constValue == 0 {
@@ -43,7 +43,7 @@ func (a *assemblerGoAsmImpl) CompileConstToRegister(instruction asm.Instruction,
 }
 
 // CompileMemoryToRegister implements AssemblerBase.CompileMemoryToRegister.
-func (a *assemblerGoAsmImpl) CompileMemoryToRegister(instruction asm.Instruction, sourceBaseReg asm.Register, sourceOffsetConst int64, destinationReg asm.Register) {
+func (a *assemblerGoAsmImpl) CompileMemoryToRegister(instruction asm.Instruction, sourceBaseReg asm.Register, sourceOffsetConst asm.ConstantValue, destinationReg asm.Register) {
 	if sourceOffsetConst > math.MaxInt16 {
 		// The assembler can take care of offsets larger than 2^15-1 by emitting additional instructions to load such large offset,
 		// but it uses "its" temporary register which we cannot track. Therefore, we avoid directly emitting memory load with large offsets,
@@ -76,7 +76,7 @@ func (a *assemblerGoAsmImpl) CompileMemoryWithRegisterOffsetToRegister(instructi
 }
 
 // CompileRegisterToMemory implements Assembler.CompileRegisterToMemory.
-func (a *assemblerGoAsmImpl) CompileRegisterToMemory(instruction asm.Instruction, sourceReg asm.Register, destinationBaseReg asm.Register, destinationOffsetConst int64) {
+func (a *assemblerGoAsmImpl) CompileRegisterToMemory(instruction asm.Instruction, sourceReg asm.Register, destinationBaseReg asm.Register, destinationOffsetConst asm.ConstantValue) {
 	if destinationOffsetConst > math.MaxInt16 {
 		// The assembler can take care of offsets larger than 2^15-1 by emitting additional instructions to load such large offset,
 		// but we cannot track its temporary register. Therefore, we avoid directly emitting memory load with large offsets:
@@ -158,7 +158,7 @@ func (a *assemblerGoAsmImpl) CompileTwoRegistersToNone(instruction asm.Instructi
 }
 
 // CompileRegisterAndConstSourceToNone implements Assembler.CompileRegisterAndConstSourceToNone.
-func (a *assemblerGoAsmImpl) CompileRegisterAndConstSourceToNone(instruction asm.Instruction, src asm.Register, srcConst int64) {
+func (a *assemblerGoAsmImpl) CompileRegisterAndConstSourceToNone(instruction asm.Instruction, src asm.Register, srcConst asm.ConstantValue) {
 	inst := a.NewProg()
 	inst.As = castAsGolangAsmInstruction[instruction]
 	// TYPE_NONE indicates that this instruction doesn't have a destination.
@@ -180,7 +180,7 @@ func (a *assemblerGoAsmImpl) CompileJump(jmpInstruction asm.Instruction) asm.Nod
 }
 
 // CompileJumpToMemory implements AssemblerBase.CompileJumpToMemory.
-func (a *assemblerGoAsmImpl) CompileJumpToMemory(jmpInstruction asm.Instruction, baseReg asm.Register, offset int64) {
+func (a *assemblerGoAsmImpl) CompileJumpToMemory(jmpInstruction asm.Instruction, baseReg asm.Register, offset asm.ConstantValue) {
 	br := a.NewProg()
 	br.As = castAsGolangAsmInstruction[jmpInstruction]
 	br.To.Type = obj.TYPE_MEM
@@ -207,7 +207,7 @@ func (a *assemblerGoAsmImpl) CompileStandAlone(instruction asm.Instruction) asm.
 }
 
 // CompileLeftShiftedRegisterToRegister implements Assembler.CompileLeftShiftedRegisterToRegister.
-func (a *assemblerGoAsmImpl) CompileLeftShiftedRegisterToRegister(shiftedSourceReg asm.Register, shiftNum int64, srcReg, destinationReg asm.Register) {
+func (a *assemblerGoAsmImpl) CompileLeftShiftedRegisterToRegister(shiftedSourceReg asm.Register, shiftNum asm.ConstantValue, srcReg, destinationReg asm.Register) {
 	inst := a.NewProg()
 	inst.As = arm64.AADD
 	inst.To.Type = obj.TYPE_REG
diff --git a/internal/wasm/jit/asm/assembler.go b/internal/wasm/jit/asm/assembler.go
index e754ebf4..b178cf0f 100644
--- a/internal/wasm/jit/asm/assembler.go
+++ b/internal/wasm/jit/asm/assembler.go
@@ -1,5 +1,10 @@
 package asm
 
+import (
+	"fmt"
+	"math"
+)
+
 // Register represents architecture-specific registers.
 type Register byte
 
@@ -20,19 +25,26 @@ const ConditionalRegisterStateUnset ConditionalRegisterState = 0
 
 // Node represents a node in the linked list of assembled operations.
 type Node interface {
+	fmt.Stringer
 	// AssignJumpTarget assigns the given target node as the destination of
 	// jump instruction for this Node.
 	AssignJumpTarget(target Node)
 	// AssignDestinationConstant assigns the given constnat as the destination
 	// of the instruction for this node.
-	AssignDestinationConstant(value int64)
+	AssignDestinationConstant(value ConstantValue)
 	// AssignSourceConstant assigns the given constnat as the source
 	// of the instruction for this node.
-	AssignSourceConstant(value int64)
-	// offsetInBinary returns the offset of this node in the assembled binary.
-	offsetInBinary() int64
+	AssignSourceConstant(value ConstantValue)
+	// OffsetInBinary returns the offset of this node in the assembled binary.
+	OffsetInBinary() NodeOffsetInBinary
 }
 
+// NodeOffsetInBinary represents an offset of this node in the final binary.
+type NodeOffsetInBinary = uint64
+
+// ConstantValue represents a constant value used in an instruction.
+type ConstantValue = int64
+
 // AssemblerBase is the common interface for assemblers among multiple architectures.
 //
 // Note: some of them can be implemented in a arch-independent way, but not all can be
@@ -53,20 +65,20 @@ type AssemblerBase interface {
 	// CompileStandAlone adds an instruction to take no arguments.
 	CompileStandAlone(instruction Instruction) Node
 	// CompileConstToRegister adds an instruction where source operand is `value` as constant and destination is `destinationReg` register.
-	CompileConstToRegister(instruction Instruction, value int64, destinationReg Register) (inst Node)
+	CompileConstToRegister(instruction Instruction, value ConstantValue, destinationReg Register) Node
 	// CompileConstToRegister adds an instruction where source and destination operands are registers.
 	CompileRegisterToRegister(instruction Instruction, from, to Register)
 	// CompileMemoryToRegister adds an instruction where source operands is the memory address specified by `sourceBaseReg+sourceOffsetConst`
 	// and the destination is `destinationReg` register.
-	CompileMemoryToRegister(instruction Instruction, sourceBaseReg Register, sourceOffsetConst int64, destinationReg Register)
+	CompileMemoryToRegister(instruction Instruction, sourceBaseReg Register, sourceOffsetConst ConstantValue, destinationReg Register)
 	// CompileRegisterToMemory adds an instruction where source operand is `sourceRegister` register and the destination is the
 	// memory address specified by `destinationBaseRegister+destinationOffsetConst`.
-	CompileRegisterToMemory(inst Instruction, sourceRegister Register, destinationBaseRegister Register, destinationOffsetConst int64)
+	CompileRegisterToMemory(instruction Instruction, sourceRegister Register, destinationBaseRegister Register, destinationOffsetConst ConstantValue)
 	// CompileJump adds jump-type instruction and returns the corresponding Node in the assembled linked list.
 	CompileJump(jmpInstruction Instruction) Node
 	// CompileJumpToMemory adds jump-type instruction whose destination is stored in the memory address specified by `baseReg+offset`,
 	// and returns the corresponding Node in the assembled linked list.
-	CompileJumpToMemory(jmpInstruction Instruction, baseReg Register, offset int64)
+	CompileJumpToMemory(jmpInstruction Instruction, baseReg Register, offset ConstantValue)
 	// CompileJumpToMemory adds jump-type instruction whose destination is the memory address specified by `reg` register.
 	CompileJumpToRegister(jmpInstruction Instruction, reg Register)
 	// CompileReadInstructionAddress adds an ADR instruction to set the absolute address of "target instruction"
@@ -78,3 +90,11 @@ type AssemblerBase interface {
 	// address of MOV instruction into the destination register.
 	CompileReadInstructionAddress(destinationRegister Register, beforeAcquisitionTargetInstruction Instruction)
 }
+
+// binaryOffsetMaximum represents the limit on the size of jump table in bytes.
+// When users try loading an extremely large webassembly binary which contains a br_table
+// statement with approximately 4294967296 (2^32) targets. Realistically speaking, that kind of binary
+// could result in more than ten giga bytes of native JITed code where we have to care about
+// huge stacks whose height might exceed 32-bit range, and such huge stack doesn't work with the
+// current implementation.
+const jumpTableMaximumOffset = math.MaxUint32
diff --git a/internal/wasm/jit/asm/golang_asm.go b/internal/wasm/jit/asm/golang_asm.go
index 447a6bfc..feb9f948 100644
--- a/internal/wasm/jit/asm/golang_asm.go
+++ b/internal/wasm/jit/asm/golang_asm.go
@@ -3,7 +3,6 @@ package asm
 import (
 	"encoding/binary"
 	"fmt"
-	"math"
 	"runtime"
 
 	goasm "github.com/twitchyliquid64/golang-asm"
@@ -19,9 +18,14 @@ func NewGolangAsmNode(p *obj.Prog) Node {
 	return &golangAsmNode{prog: p}
 }
 
-// offsetInBinary implements Node.offsetInBinary.
-func (n *golangAsmNode) offsetInBinary() int64 {
-	return n.prog.Pc
+// String implements fmt.Stringer.
+func (n *golangAsmNode) String() string {
+	return n.prog.String()
+}
+
+// OffsetInBinary implements Node.OffsetInBinary.
+func (n *golangAsmNode) OffsetInBinary() NodeOffsetInBinary {
+	return NodeOffsetInBinary(n.prog.Pc)
 }
 
 // AssignJumpTarget implements Node.AssignJumpTarget.
@@ -31,12 +35,12 @@ func (n *golangAsmNode) AssignJumpTarget(target Node) {
 }
 
 // AssignDestinationConstant implements Node.AssignDestinationConstant.
-func (n *golangAsmNode) AssignDestinationConstant(value int64) {
+func (n *golangAsmNode) AssignDestinationConstant(value ConstantValue) {
 	n.prog.To.Offset = value
 }
 
 // AssignSourceConstant implements Node.AssignSourceConstant.
-func (n *golangAsmNode) AssignSourceConstant(value int64) {
+func (n *golangAsmNode) AssignSourceConstant(value ConstantValue) {
 	n.prog.From.Offset = value
 }
 
@@ -82,20 +86,14 @@ func (a *GolangAsmBaseAssembler) AddOnGenerateCallBack(cb func([]byte) error) {
 // BuildJumpTable implements AssemblerBase.BuildJumpTable
 func (a *GolangAsmBaseAssembler) BuildJumpTable(table []byte, labelInitialInstructions []Node) {
 	a.AddOnGenerateCallBack(func(code []byte) error {
-		// Build the offset table for each target including default one.
-		base := labelInitialInstructions[0].offsetInBinary() // This corresponds to the L0's address in the example.
+		// Build the offset table for each target.
+		base := labelInitialInstructions[0].OffsetInBinary()
 		for i, nop := range labelInitialInstructions {
-			if uint64(nop.offsetInBinary())-uint64(base) >= math.MaxUint32 {
-				// TODO: this happens when users try loading an extremely large webassembly binary
-				// which contains a br_table statement with approximately 4294967296 (2^32) targets.
-				// We would like to support that binary, but realistically speaking, that kind of binary
-				// could result in more than ten giga bytes of native JITed code where we have to care about
-				// huge stacks whose height might exceed 32-bit range, and such huge stack doesn't work with the
-				// current implementation.
+			if uint64(nop.OffsetInBinary())-uint64(base) >= jumpTableMaximumOffset {
 				return fmt.Errorf("too large br_table")
 			}
 			// We store the offset from the beginning of the L0's initial instruction.
-			binary.LittleEndian.PutUint32(table[i*4:(i+1)*4], uint32(nop.offsetInBinary())-uint32(base))
+			binary.LittleEndian.PutUint32(table[i*4:(i+1)*4], uint32(nop.OffsetInBinary())-uint32(base))
 		}
 		return nil
 	})
diff --git a/internal/wasm/jit/asm/impl.go b/internal/wasm/jit/asm/impl.go
new file mode 100644
index 00000000..c9353efa
--- /dev/null
+++ b/internal/wasm/jit/asm/impl.go
@@ -0,0 +1,44 @@
+package asm
+
+import (
+	"encoding/binary"
+	"fmt"
+)
+
+// BaseAssemblerImpl includes code common to all architectures.
+//
+// Note: When possible, add code here instead of in architecture-specific files to reduce drift:
+// As this is internal, exporting symbols only to reduce duplication is ok.
+type BaseAssemblerImpl struct {
+	// setBranchTargetOnNextInstructions holds branch kind instructions (BR, conditional BR, etc)
+	// where we want to set the next coming instruction as the destination of these BR instructions.
+	setBranchTargetOnNextNodes []Node
+	// onGenerateCallbacks holds the callbacks which are called after generating native code.
+	OnGenerateCallbacks []func(code []byte) error
+}
+
+// SetJumpTargetOnNext implements AssemblerBase.SetJumpTargetOnNext
+func (a *BaseAssemblerImpl) SetJumpTargetOnNext(nodes ...Node) {
+	a.setBranchTargetOnNextNodes = append(a.setBranchTargetOnNextNodes, nodes...)
+}
+
+// AddOnGenerateCallBack implements AssemblerBase.AddOnGenerateCallBack
+func (a *BaseAssemblerImpl) AddOnGenerateCallBack(cb func([]byte) error) {
+	a.OnGenerateCallbacks = append(a.OnGenerateCallbacks, cb)
+}
+
+// BuildJumpTable implements AssemblerBase.BuildJumpTable
+func (a *BaseAssemblerImpl) BuildJumpTable(table []byte, labelInitialInstructions []Node) {
+	a.AddOnGenerateCallBack(func(code []byte) error {
+		// Build the offset table for each target.
+		base := labelInitialInstructions[0].OffsetInBinary()
+		for i, nop := range labelInitialInstructions {
+			if uint64(nop.OffsetInBinary())-uint64(base) >= jumpTableMaximumOffset {
+				return fmt.Errorf("too large br_table")
+			}
+			// We store the offset from the beginning of the L0's initial instruction.
+			binary.LittleEndian.PutUint32(table[i*4:(i+1)*4], uint32(nop.OffsetInBinary())-uint32(base))
+		}
+		return nil
+	})
+}
diff --git a/internal/wasm/jit/jit_amd64_impl.go b/internal/wasm/jit/jit_amd64_impl.go
index d31e3fb0..b58e620b 100644
--- a/internal/wasm/jit/jit_amd64_impl.go
+++ b/internal/wasm/jit/jit_amd64_impl.go
@@ -1801,9 +1801,9 @@ func (c *amd64Compiler) compileRoundInstruction(is32Bit bool, mode int64) error
 	}
 
 	if is32Bit {
-		c.assembler.CompileConstModeRegisterToRegister(amd64.ROUNDSS, target.register, target.register, mode)
+		c.assembler.CompileRegisterToRegisterWithMode(amd64.ROUNDSS, target.register, target.register, byte(mode))
 	} else {
-		c.assembler.CompileConstModeRegisterToRegister(amd64.ROUNDSD, target.register, target.register, mode)
+		c.assembler.CompileRegisterToRegisterWithMode(amd64.ROUNDSD, target.register, target.register, byte(mode))
 	}
 	return nil
 }
diff --git a/internal/wasm/module_context.go b/internal/wasm/module_context.go
index a7969542..4a22edfe 100644
--- a/internal/wasm/module_context.go
+++ b/internal/wasm/module_context.go
@@ -10,8 +10,8 @@ import (
 // compile time check to ensure ModuleContext implements wasm.Module
 var _ publicwasm.Module = &ModuleContext{}
 
-func NewModuleContext(ctx context.Context, store *Store, instance *ModuleInstance) *ModuleContext {
-	return &ModuleContext{ctx: ctx, memory: instance.Memory, module: instance, store: store}
+func NewModuleContext(ctx context.Context, store *Store, instance *ModuleInstance, sys *SysContext) *ModuleContext {
+	return &ModuleContext{ctx: ctx, memory: instance.Memory, module: instance, store: store, sys: sys}
 }
 
 // ModuleContext implements wasm.Module
@@ -23,17 +23,16 @@ type ModuleContext struct {
 	memory publicwasm.Memory
 	store  *Store
 
-	// System is not exposed publicly. This is currently only used by internalwasi.
+	// Sys is not exposed publicly. This is currently only used by internalwasi.
 	// Note: This is a part of ModuleContext so that scope is correct and Close is coherent.
-	// TODO: In #394 unexport this for System()
-	System *SystemContext
+	sys *SysContext
 }
 
 // WithMemory allows overriding memory without re-allocation when the result would be the same.
 func (m *ModuleContext) WithMemory(memory *MemoryInstance) *ModuleContext {
 	// only re-allocate if it will change the effective memory
 	if m.memory == nil || (memory != nil && memory.Max != nil && *memory.Max > 0 && memory != m.memory) {
-		return &ModuleContext{module: m.module, memory: memory, ctx: m.ctx, System: m.System}
+		return &ModuleContext{module: m.module, memory: memory, ctx: m.ctx, sys: m.sys}
 	}
 	return m
 }
@@ -48,11 +47,16 @@ func (m *ModuleContext) Context() context.Context {
 	return m.ctx
 }
 
+// Sys is exposed only for WASI.
+func (m *ModuleContext) Sys() *SysContext {
+	return m.sys
+}
+
 // WithContext implements wasm.Module WithContext
 func (m *ModuleContext) WithContext(ctx context.Context) publicwasm.Module {
 	// only re-allocate if it will change the effective context
 	if ctx != nil && ctx != m.ctx {
-		return &ModuleContext{module: m.module, memory: m.memory, ctx: ctx, System: m.System}
+		return &ModuleContext{module: m.module, memory: m.memory, ctx: ctx, sys: m.sys}
 	}
 	return m
 }
@@ -61,7 +65,9 @@ func (m *ModuleContext) WithContext(ctx context.Context) publicwasm.Module {
 // Note: When there are multiple errors, the error returned is the last one.
 func (m *ModuleContext) Close() (err error) {
 	err = m.store.CloseModule(m.module.Name)
-	if err2 := m.System.Close(); err2 != nil {
+	if sys := m.sys; sys == nil { // ex from ModuleBuilder
+		return
+	} else if err2 := m.sys.Close(); err2 != nil {
 		err = err2
 	}
 	return
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index bd6228ca..b91a0176 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -256,16 +256,13 @@ func NewStore(engine Engine, enabledFeatures Features) *Store {
 // Instantiate uses name instead of the Module.NameSection ModuleName as it allows instantiating the same module under
 // different names safely and concurrently.
 //
-// * ctx: the default context used for function calls
+// * ctx: the default context used for function calls.
+// * name: the name of the module.
+// * sys: the system context, which will be closed (SysContext.Close) on ModuleContext.Close.
 //
 // Note: Module.Validate must be called prior to instantiation.
-func (s *Store) Instantiate(ctx context.Context, module *Module, name string) (*ModuleContext, error) {
-	sys, err := NewSystemContext() // TODO: from config in #394
-	if err != nil {                // fail early
-		return nil, err
-	}
-
-	if err = s.requireModuleName(name); err != nil {
+func (s *Store) Instantiate(ctx context.Context, module *Module, name string, sys *SysContext) (*ModuleContext, error) {
+	if err := s.requireModuleName(name); err != nil {
 		return nil, err
 	}
 
@@ -318,8 +315,7 @@ func (s *Store) Instantiate(ctx context.Context, module *Module, name string) (*
 	m.applyData(module.DataSection)
 
 	// Build the default context for calls to this module.
-	m.Ctx = NewModuleContext(ctx, s, m)
-	m.Ctx.System = sys
+	m.Ctx = NewModuleContext(ctx, s, m, sys)
 
 	// Execute the start function.
 	if module.StartSection != nil {
diff --git a/internal/wasm/sys.go b/internal/wasm/sys.go
index d16aee0f..edd59966 100644
--- a/internal/wasm/sys.go
+++ b/internal/wasm/sys.go
@@ -1,13 +1,13 @@
 package internalwasm
 
 import (
+	"errors"
 	"fmt"
 	"io"
 	"math"
 	"os"
-	"strings"
+	"sync/atomic"
 
-	"github.com/tetratelabs/wazero/internal/cstring"
 	"github.com/tetratelabs/wazero/wasi"
 )
 
@@ -20,102 +20,220 @@ type FileEntry struct {
 	File wasi.File
 }
 
-// SystemContext holds module-scoped system resources currently only used by internalwasi.
-//
-// TODO: Most fields are mutable so that WASI config can overwrite fields when starting a command module. This can be
-// fixed in #394 by replacing wazero.WASIConfig with wazero.SystemConfig, defaulted by wazero.RuntimeConfig and
-// overridable with InstantiateModule.
-type SystemContext struct {
-	// WithArgs hold a possibly empty (cstring.EmptyNullTerminatedStrings) list of arguments similar to os.Args.
-	//
-	// TODO: document this better in #396
-	Args *cstring.NullTerminatedStrings
-
-	// WithArgs hold a possibly empty (cstring.EmptyNullTerminatedStrings) list of arguments key/value pairs, similar to
-	// os.Environ.
-	//
-	// TODO: document this better in #396
-	Environ *cstring.NullTerminatedStrings
-
-	// WithStdin defaults to os.Stdin.
-	//
-	// TODO: change default to read os.DevNull in #396
-	Stdin io.Reader
-
-	// WithStdout defaults to os.Stdout.
-	//
-	// TODO: change default to io.Discard in #396
-	Stdout io.Writer
-
-	// WithStderr defaults to os.Stderr.
-	//
-	// TODO: change default to io.Discard in #396
-	Stderr io.Writer
-
-	// OpenedFiles are a map of file descriptor numbers (starting at 3) to open files (or directories).
+// SysContext holds module-scoped system resources currently only used by internalwasi.
+type SysContext struct {
+	args, environ         []string
+	argsSize, environSize uint32
+	stdin                 io.Reader
+	stdout, stderr        io.Writer
+
+	// openedFiles is a map of file descriptor numbers (>=3) to open files (or directories) and defaults to empty.
 	// TODO: This is unguarded, so not goroutine-safe!
-	OpenedFiles map[uint32]*FileEntry
+	openedFiles map[uint32]*FileEntry
+
+	// lastFD is not meant to be read directly. Rather by nextFD.
+	lastFD uint32
+}
+
+// nextFD gets the next file descriptor number in a goroutine safe way (monotonically) or zero if we ran out.
+// TODO: opendFiles is still not goroutine safe!
+// TODO: This can return zero if we ran out of file descriptors. A future change can optimize by re-using an FD pool.
+func (c *SysContext) nextFD() uint32 {
+	if c.lastFD == math.MaxUint32 {
+		return 0
+	}
+	return atomic.AddUint32(&c.lastFD, 1)
+}
+
+// Args is like os.Args and defaults to nil.
+//
+// Note: The count will never be more than math.MaxUint32.
+// See wazero.SysConfig WithArgs
+func (c *SysContext) Args() []string {
+	return c.args
 }
 
-func NewSystemContext() (*SystemContext, error) {
-	return &SystemContext{
-		Args:        cstring.EmptyNullTerminatedStrings,
-		Environ:     cstring.EmptyNullTerminatedStrings,
-		Stdin:       os.Stdin, // TODO: stop this default #396
-		Stdout:      os.Stdout,
-		Stderr:      os.Stderr,
-		OpenedFiles: map[uint32]*FileEntry{},
-	}, nil // TODO: once we open stdin from os.DevNull, this may raise an error (albeit unlikely).
+// ArgsSize is the size to encode Args as Null-terminated strings.
+//
+// Note: To get the size without null-terminators, subtract the length of Args from this value.
+// See wazero.SysConfig WithArgs
+// See https://en.wikipedia.org/wiki/Null-terminated_string
+func (c *SysContext) ArgsSize() uint32 {
+	return c.argsSize
 }
 
-// WithStdin the same as wazero.WASIConfig WithStdin
-func (c *SystemContext) WithStdin(reader io.Reader) {
-	c.Stdin = reader
+// Environ are "key=value" entries like os.Environ and default to nil.
+//
+// Note: The count will never be more than math.MaxUint32.
+// See wazero.SysConfig WithEnviron
+func (c *SysContext) Environ() []string {
+	return c.environ
+}
+
+// EnvironSize is the size to encode Environ as Null-terminated strings.
+//
+// Note: To get the size without null-terminators, subtract the length of Environ from this value.
+// See wazero.SysConfig WithEnviron
+// See https://en.wikipedia.org/wiki/Null-terminated_string
+func (c *SysContext) EnvironSize() uint32 {
+	return c.environSize
 }
 
-// WithStdout the same as wazero.WASIConfig WithStdout
-func (c *SystemContext) WithStdout(writer io.Writer) {
-	c.Stdout = writer
+// Stdin is like exec.Cmd Stdin and defaults to a reader of os.DevNull.
+// See wazero.SysConfig WithStdin
+func (c *SysContext) Stdin() io.Reader {
+	return c.stdin
 }
 
-// WithStderr the same as wazero.WASIConfig WithStderr
-func (c *SystemContext) WithStderr(writer io.Writer) {
-	c.Stderr = writer
+// Stdout is like exec.Cmd Stdout and defaults to io.Discard.
+// See wazero.SysConfig WithStdout
+func (c *SysContext) Stdout() io.Writer {
+	return c.stdout
 }
 
-// WithArgs is the same as wazero.WASIConfig WithArgs
-func (c *SystemContext) WithArgs(args ...string) error {
-	wasiStrings, err := cstring.NewNullTerminatedStrings(math.MaxUint32, "arg", args...) // TODO: this is crazy high even if spec allows it
-	if err != nil {
-		return err
+// Stderr is like exec.Cmd Stderr and defaults to io.Discard.
+// See wazero.SysConfig WithStderr
+func (c *SysContext) Stderr() io.Writer {
+	return c.stderr
+}
+
+// eofReader is safer than reading from os.DevNull as it can never overrun operating system file descriptors.
+type eofReader struct{}
+
+// Read implements io.Reader
+// Note: This doesn't use a pointer reference as it has no state and an empty struct doesn't allocate.
+func (eofReader) Read([]byte) (int, error) {
+	return 0, io.EOF
+}
+
+// DefaultSysContext returns SysContext with no values set.
+//
+// Note: This isn't a constant because SysContext.openedFiles is currently mutable even when empty.
+// TODO: Make it an error to open or close files when no FS was assigned.
+func DefaultSysContext() *SysContext {
+	if sys, err := NewSysContext(0, nil, nil, nil, nil, nil, nil); err != nil {
+		panic(fmt.Errorf("BUG: DefaultSysContext should never error: %w", err))
+	} else {
+		return sys
 	}
-	c.Args = wasiStrings
-	return nil
 }
 
-// WithEnviron is the same as wazero.WASIConfig WithEnviron
-func (c *SystemContext) WithEnviron(environ ...string) error {
-	for i, env := range environ {
-		if !strings.Contains(env, "=") {
-			return fmt.Errorf("environ[%d] is not joined with '='", i)
-		}
+var _ = DefaultSysContext() // Force panic on bug.
+
+// NewSysContext is a factory function which helps avoid needing to know defaults or exporting all fields.
+// Note: max is exposed for testing. max is only used for env/args validation.
+func NewSysContext(max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, openedFiles map[uint32]*FileEntry) (sys *SysContext, err error) {
+	sys = &SysContext{args: args, environ: environ}
+
+	if sys.argsSize, err = nullTerminatedByteCount(max, args); err != nil {
+		return nil, fmt.Errorf("args invalid: %w", err)
+	}
+
+	if sys.environSize, err = nullTerminatedByteCount(max, environ); err != nil {
+		return nil, fmt.Errorf("environ invalid: %w", err)
 	}
-	wasiStrings, err := cstring.NewNullTerminatedStrings(math.MaxUint32, "environ", environ...) // TODO: this is crazy high even if spec allows it
-	if err != nil {
-		return err
+
+	if stdin == nil {
+		sys.stdin = eofReader{}
+	} else {
+		sys.stdin = stdin
 	}
-	c.Environ = wasiStrings
-	return nil
+
+	if stdout == nil {
+		sys.stdout = io.Discard
+	} else {
+		sys.stdout = stdout
+	}
+
+	if stderr == nil {
+		sys.stderr = io.Discard
+	} else {
+		sys.stderr = stderr
+	}
+
+	if openedFiles == nil {
+		sys.openedFiles = map[uint32]*FileEntry{}
+		sys.lastFD = 2 // STDERR
+	} else {
+		sys.openedFiles = openedFiles
+		sys.lastFD = 2 // STDERR
+		for fd := range openedFiles {
+			if fd > sys.lastFD {
+				sys.lastFD = fd
+			}
+		}
+	}
+	return
 }
 
-// WithPreopen adds one element in  wazero.WASIConfig WithPreopens
-func (c *SystemContext) WithPreopen(dir string, fileSys wasi.FS) {
-	c.OpenedFiles[uint32(len(c.OpenedFiles))+3] = &FileEntry{Path: dir, FS: fileSys}
+// nullTerminatedByteCount ensures the count or Nul-terminated length of the elements doesn't exceed max, and that no
+// element includes the nul character.
+func nullTerminatedByteCount(max uint32, elements []string) (uint32, error) {
+	count := uint32(len(elements))
+	if count > max {
+		return 0, errors.New("exceeds maximum count")
+	}
+
+	// The buffer size is the total size including null terminators. The null terminator count == value count, sum
+	// count with each value length. This works because in Go, the length of a string is the same as its byte count.
+	bufSize, maxSize := uint64(count), uint64(max) // uint64 to allow summing without overflow
+	for _, e := range elements {
+		// As this is null-terminated, We have to validate there are no null characters in the string.
+		for _, c := range e {
+			if c == 0 {
+				return 0, errors.New("contains NUL character")
+			}
+		}
+
+		nextSize := bufSize + uint64(len(e))
+		if nextSize > maxSize {
+			return 0, errors.New("exceeds maximum size")
+		}
+		bufSize = nextSize
+
+	}
+	return uint32(bufSize), nil
 }
 
 // Close implements io.Closer
-func (c *SystemContext) Close() (err error) {
-	// Note: WithStdin, WithStdout and WithStderr are not closed as we didn't open them.
-	// TODO: In #394, close open files
+func (c *SysContext) Close() (err error) {
+	// stdin, stdout and stderr are only closed if we opened them. The only case we open is when stdin -> /dev/null
+	if f, ok := c.stdin.(*os.File); ok && f.Name() == os.DevNull {
+		_ = f.Close() // ignore error closing reader of /dev/null
+	}
+	// TODO: close openedFiles in #394
 	return
 }
+
+// CloseFile returns true if a file was opened and closed without error, or false if not.
+func (c *SysContext) CloseFile(fd uint32) (bool, error) {
+	f, ok := c.openedFiles[fd]
+	if !ok {
+		return false, nil
+	}
+	delete(c.openedFiles, fd)
+
+	if f.File == nil { // TODO: currently, this means it is a pre-opened filesystem, but this may change later.
+		return true, nil
+	}
+	if err := f.File.Close(); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// OpenedFile returns a file and true if it was opened or nil and false, if not.
+func (c *SysContext) OpenedFile(fd uint32) (*FileEntry, bool) {
+	f, ok := c.openedFiles[fd]
+	return f, ok
+}
+
+// OpenFile returns the file descriptor of the new file or false if we ran out of file descriptors
+func (c *SysContext) OpenFile(f *FileEntry) (uint32, bool) {
+	newFD := c.nextFD()
+	if newFD == 0 {
+		return 0, false
+	}
+	c.openedFiles[newFD] = f
+	return newFD, true
+}
diff --git a/wasi.go b/wasi.go
index ef29c1af..9b6fd3c3 100644
--- a/wasi.go
+++ b/wasi.go
@@ -2,7 +2,6 @@ package wazero
 
 import (
 	"fmt"
-	"io"
 
 	internalwasi "github.com/tetratelabs/wazero/internal/wasi"
 	internalwasm "github.com/tetratelabs/wazero/internal/wasm"
@@ -23,49 +22,6 @@ func WASIMemFS() wasi.FS {
 	}
 }
 
-type WASIConfig struct {
-	stdin    io.Reader
-	stdout   io.Writer
-	stderr   io.Writer
-	args     []string
-	environ  map[string]string
-	preopens map[string]wasi.FS
-}
-
-func NewWASIConfig() *WASIConfig {
-	return &WASIConfig{}
-}
-
-func (c *WASIConfig) WithStdin(stdin io.Reader) *WASIConfig {
-	c.stdin = stdin
-	return c
-}
-
-func (c *WASIConfig) WithStdout(stdout io.Writer) *WASIConfig {
-	c.stdout = stdout
-	return c
-}
-
-func (c *WASIConfig) WithStderr(stderr io.Writer) *WASIConfig {
-	c.stderr = stderr
-	return c
-}
-
-func (c *WASIConfig) WithArgs(args ...string) *WASIConfig {
-	c.args = args
-	return c
-}
-
-func (c *WASIConfig) WithEnviron(environ map[string]string) *WASIConfig {
-	c.environ = environ
-	return c
-}
-
-func (c *WASIConfig) WithPreopens(preopens map[string]wasi.FS) *WASIConfig {
-	c.preopens = preopens
-	return c
-}
-
 // WASISnapshotPreview1 are functions importable as the module name wasi.ModuleSnapshotPreview1
 func WASISnapshotPreview1() *Module {
 	_, fns := internalwasi.SnapshotPreview1Functions()
@@ -76,45 +32,6 @@ func WASISnapshotPreview1() *Module {
 	return &Module{name: wasi.ModuleSnapshotPreview1, module: m}
 }
 
-func newSystemContext(c *WASIConfig) (*internalwasm.SystemContext, error) {
-	sys, err := internalwasm.NewSystemContext()
-	if err != nil {
-		return nil, err
-	}
-
-	if c.stdin != nil {
-		sys.WithStdin(c.stdin)
-	}
-	if c.stdout != nil {
-		sys.WithStdout(c.stdout)
-	}
-	if c.stderr != nil {
-		sys.WithStderr(c.stderr)
-	}
-	if len(c.args) > 0 {
-		err = sys.WithArgs(c.args...)
-		if err != nil {
-			panic(err) // better to panic vs have bother users about unlikely size > uint32
-		}
-	}
-	if len(c.environ) > 0 {
-		environ := make([]string, 0, len(c.environ))
-		for k, v := range c.environ {
-			environ = append(environ, fmt.Sprintf("%s=%s", k, v))
-		}
-		err = sys.WithEnviron(environ...)
-		if err != nil { // this can't be due to lack of '=' as we did that above.
-			panic(err) // better to panic vs have bother users about unlikely size > uint32
-		}
-	}
-	if len(c.preopens) > 0 {
-		for k, v := range c.preopens {
-			sys.WithPreopen(k, v)
-		}
-	}
-	return sys, nil
-}
-
 // StartWASICommandFromSource instantiates a module from the WebAssembly 1.0 (20191205) text or binary source or errs if
 // invalid. Once instantiated, this starts its WASI Command function ("_start").
 //
@@ -160,31 +77,33 @@ func StartWASICommandFromSource(r Runtime, source []byte) (wasm.Module, error) {
 // See StartWASICommandWithConfig
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/design/application-abi.md#current-unstable-abi
 func StartWASICommand(r Runtime, module *Module) (wasm.Module, error) {
-	return StartWASICommandWithConfig(r, module, nil)
+	return startWASICommandWithSysContext(r, module, internalwasm.DefaultSysContext())
 }
 
 // StartWASICommandWithConfig is like StartWASICommand, except you can override configuration based on the importing
 // module. For example, you can use this to define different args depending on the importing module.
 //
-//	// Initialize base configuration:
 //	r := wazero.NewRuntime()
-//	config := wazero.NewWASIConfig().WithStdout(buf)
 //	wasi, _ := r.NewHostModule(wazero.WASISnapshotPreview1())
-//	decoded, _ := r.CompileModule(source)
+//	mod, _ := r.CompileModule(source)
+//
+//	// Initialize base configuration:
+//	sys := wazero.NewSysConfig().WithStdout(buf)
 //
-//	// Assign configuration only when ready to instantiate.
-//	module, _ := StartWASICommandWithConfig(r, decoded, config.WithArgs("rotate", "angle=90", "dir=cw"))
+//	// Assign different configuration on each instantiation
+//	module, _ := StartWASICommandWithConfig(r, mod.WithName("rotate"), sys.WithArgs("rotate", "angle=90", "dir=cw"))
 //
+// Note: Config is copied during instantiation: Later changes to config do not affect the instantiated result.
 // See StartWASICommand
-func StartWASICommandWithConfig(r Runtime, module *Module, config *WASIConfig) (mod wasm.Module, err error) {
-	// Until #394 we have to re-assign the system context manually.
-	var sys *internalwasm.SystemContext
-	if config != nil {
-		if sys, err = newSystemContext(config); err != nil {
-			return
-		}
+func StartWASICommandWithConfig(r Runtime, module *Module, config *SysConfig) (mod wasm.Module, err error) {
+	var sys *internalwasm.SysContext
+	if sys, err = config.toSysContext(); err != nil {
+		return
 	}
+	return startWASICommandWithSysContext(r, module, sys)
+}
 
+func startWASICommandWithSysContext(r Runtime, module *Module, sys *internalwasm.SysContext) (mod wasm.Module, err error) {
 	if err = internalwasi.ValidateWASICommand(module.module, module.name); err != nil {
 		return
 	}
@@ -194,15 +113,10 @@ func StartWASICommandWithConfig(r Runtime, module *Module, config *WASIConfig) (
 		return nil, fmt.Errorf("unsupported Runtime implementation: %s", r)
 	}
 
-	if mod, err = internal.store.Instantiate(internal.ctx, module.module, module.name); err != nil {
+	if mod, err = internal.store.Instantiate(internal.ctx, module.module, module.name, sys); err != nil {
 		return
 	}
 
-	// Override as necessary
-	if sys != nil {
-		mod.(*internalwasm.ModuleContext).System = sys
-	}
-
 	start := mod.ExportedFunction(internalwasi.FunctionStart)
 	if _, err = start.Call(mod.WithContext(internal.ctx)); err != nil {
 		return nil, fmt.Errorf("module[%s] function[%s] failed: %w", module.name, internalwasi.FunctionStart, err)
diff --git a/wasm.go b/wasm.go
index 4fce90db..fd415639 100644
--- a/wasm.go
+++ b/wasm.go
@@ -71,8 +71,6 @@ type Runtime interface {
 	//
 	// Note: The last value of RuntimeConfig.WithContext is used for any start function.
 	InstantiateModule(module *Module) (wasm.Module, error)
-
-	// TODO: RemoveModule
 }
 
 func NewRuntime() Runtime {
@@ -146,5 +144,5 @@ func (r *runtime) InstantiateModuleFromSource(source []byte) (wasm.Module, error
 
 // InstantiateModule implements Runtime.InstantiateModule
 func (r *runtime) InstantiateModule(module *Module) (wasm.Module, error) {
-	return r.store.Instantiate(r.ctx, module.module, module.name)
+	return r.store.Instantiate(r.ctx, module.module, module.name, internalwasm.DefaultSysContext())
 }


Test patch:
diff --git a/config_test.go b/config_test.go
index b3206363..12c6cbc4 100644
--- a/config_test.go
+++ b/config_test.go
@@ -1,6 +1,8 @@
 package wazero
 
 import (
+	"io"
+	"math"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -54,3 +56,280 @@ func TestRuntimeConfig_Features(t *testing.T) {
 		})
 	}
 }
+
+func TestSysConfig_toSysContext(t *testing.T) {
+	memFS := WASIMemFS()
+	memFS2 := WASIMemFS()
+
+	tests := []struct {
+		name     string
+		input    *SysConfig
+		expected *internalwasm.SysContext
+	}{
+		{
+			name:  "empty",
+			input: NewSysConfig(),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				nil,            // openedFiles
+			),
+		},
+		{
+			name:  "WithArgs",
+			input: NewSysConfig().WithArgs("a", "bc"),
+			expected: requireSysContext(t,
+				math.MaxUint32,      // max
+				[]string{"a", "bc"}, // args
+				nil,                 // environ
+				nil,                 // stdin
+				nil,                 // stdout
+				nil,                 // stderr
+				nil,                 // openedFiles
+			),
+		},
+		{
+			name:  "WithArgs - empty ok", // Particularly argv[0] can be empty, and we have no rules about others.
+			input: NewSysConfig().WithArgs("", "bc"),
+			expected: requireSysContext(t,
+				math.MaxUint32,     // max
+				[]string{"", "bc"}, // args
+				nil,                // environ
+				nil,                // stdin
+				nil,                // stdout
+				nil,                // stderr
+				nil,                // openedFiles
+			),
+		},
+		{
+			name:  "WithArgs - second call overwrites",
+			input: NewSysConfig().WithArgs("a", "bc").WithArgs("bc", "a"),
+			expected: requireSysContext(t,
+				math.MaxUint32,      // max
+				[]string{"bc", "a"}, // args
+				nil,                 // environ
+				nil,                 // stdin
+				nil,                 // stdout
+				nil,                 // stderr
+				nil,                 // openedFiles
+			),
+		},
+		{
+			name:  "WithEnv",
+			input: NewSysConfig().WithEnv("a", "b"),
+			expected: requireSysContext(t,
+				math.MaxUint32,  // max
+				nil,             // args
+				[]string{"a=b"}, // environ
+				nil,             // stdin
+				nil,             // stdout
+				nil,             // stderr
+				nil,             // openedFiles
+			),
+		},
+		{
+			name:  "WithEnv - empty value",
+			input: NewSysConfig().WithEnv("a", ""),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				[]string{"a="}, // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				nil,            // openedFiles
+			),
+		},
+		{
+			name:  "WithEnv twice",
+			input: NewSysConfig().WithEnv("a", "b").WithEnv("c", "de"),
+			expected: requireSysContext(t,
+				math.MaxUint32,          // max
+				nil,                     // args
+				[]string{"a=b", "c=de"}, // environ
+				nil,                     // stdin
+				nil,                     // stdout
+				nil,                     // stderr
+				nil,                     // openedFiles
+			),
+		},
+		{
+			name:  "WithEnv overwrites",
+			input: NewSysConfig().WithEnv("a", "bc").WithEnv("c", "de").WithEnv("a", "de"),
+			expected: requireSysContext(t,
+				math.MaxUint32,           // max
+				nil,                      // args
+				[]string{"a=de", "c=de"}, // environ
+				nil,                      // stdin
+				nil,                      // stdout
+				nil,                      // stderr
+				nil,                      // openedFiles
+			),
+		},
+
+		{
+			name:  "WithEnv twice",
+			input: NewSysConfig().WithEnv("a", "b").WithEnv("c", "de"),
+			expected: requireSysContext(t,
+				math.MaxUint32,          // max
+				nil,                     // args
+				[]string{"a=b", "c=de"}, // environ
+				nil,                     // stdin
+				nil,                     // stdout
+				nil,                     // stderr
+				nil,                     // openedFiles
+			),
+		},
+		{
+			name:  "WithFS",
+			input: NewSysConfig().WithFS(memFS),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				map[uint32]*internalwasm.FileEntry{ // openedFiles
+					3: {Path: "/", FS: memFS},
+					4: {Path: ".", FS: memFS},
+				},
+			),
+		},
+		{
+			name:  "WithFS - overwrites",
+			input: NewSysConfig().WithFS(memFS).WithFS(memFS2),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				map[uint32]*internalwasm.FileEntry{ // openedFiles
+					3: {Path: "/", FS: memFS2},
+					4: {Path: ".", FS: memFS2},
+				},
+			),
+		},
+		{
+			name:  "WithWorkDirFS",
+			input: NewSysConfig().WithWorkDirFS(memFS),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				map[uint32]*internalwasm.FileEntry{ // openedFiles
+					3: {Path: ".", FS: memFS},
+				},
+			),
+		},
+		{
+			name:  "WithFS and WithWorkDirFS",
+			input: NewSysConfig().WithFS(memFS).WithWorkDirFS(memFS2),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				map[uint32]*internalwasm.FileEntry{ // openedFiles
+					3: {Path: "/", FS: memFS},
+					4: {Path: ".", FS: memFS2},
+				},
+			),
+		},
+		{
+			name:  "WithWorkDirFS and WithFS",
+			input: NewSysConfig().WithWorkDirFS(memFS).WithFS(memFS2),
+			expected: requireSysContext(t,
+				math.MaxUint32, // max
+				nil,            // args
+				nil,            // environ
+				nil,            // stdin
+				nil,            // stdout
+				nil,            // stderr
+				map[uint32]*internalwasm.FileEntry{ // openedFiles
+					3: {Path: ".", FS: memFS},
+					4: {Path: "/", FS: memFS2},
+				},
+			),
+		},
+	}
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			sys, err := tc.input.toSysContext()
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, sys)
+		})
+	}
+}
+
+func TestSysConfig_toSysContext_Errors(t *testing.T) {
+	tests := []struct {
+		name        string
+		input       *SysConfig
+		expectedErr string
+	}{
+		{
+			name:        "WithArgs - arg contains NUL",
+			input:       NewSysConfig().WithArgs("", string([]byte{'a', 0})),
+			expectedErr: "args invalid: contains NUL character",
+		},
+		{
+			name:        "WithEnv - key contains NUL",
+			input:       NewSysConfig().WithEnv(string([]byte{'a', 0}), "a"),
+			expectedErr: "environ invalid: contains NUL character",
+		},
+		{
+			name:        "WithEnv - value contains NUL",
+			input:       NewSysConfig().WithEnv("a", string([]byte{'a', 0})),
+			expectedErr: "environ invalid: contains NUL character",
+		},
+		{
+			name:        "WithEnv - key contains equals",
+			input:       NewSysConfig().WithEnv("a=", "a"),
+			expectedErr: "environ invalid: key contains '=' character",
+		},
+		{
+			name:        "WithEnv - empty key",
+			input:       NewSysConfig().WithEnv("", "a"),
+			expectedErr: "environ invalid: empty key",
+		},
+		{
+			name:        "WithFS - nil",
+			input:       NewSysConfig().WithFS(nil),
+			expectedErr: "FS for / is nil",
+		},
+		{
+			name:        "WithWorkDirFS - nil",
+			input:       NewSysConfig().WithWorkDirFS(nil),
+			expectedErr: "FS for . is nil",
+		},
+	}
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := tc.input.toSysContext()
+			require.EqualError(t, err, tc.expectedErr)
+		})
+	}
+}
+
+// requireSysContext ensures internalwasm.NewSysContext doesn't return an error, which makes it usable in test matrices.
+func requireSysContext(t *testing.T, max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, openedFiles map[uint32]*internalwasm.FileEntry) *internalwasm.SysContext {
+	sys, err := internalwasm.NewSysContext(max, args, environ, stdin, stdout, stderr, openedFiles)
+	require.NoError(t, err)
+	return sys
+}
diff --git a/examples/file_system_test.go b/examples/file_system_test.go
index e1c223c9..bc30b842 100644
--- a/examples/file_system_test.go
+++ b/examples/file_system_test.go
@@ -29,18 +29,18 @@ func Test_Cat(t *testing.T) {
 
 	// First, configure where the WebAssembly Module (Wasm) console outputs to (stdout).
 	stdoutBuf := bytes.NewBuffer(nil)
-	wasiConfig := wazero.NewWASIConfig().WithStdout(stdoutBuf)
+	sysConfig := wazero.NewSysConfig().WithStdout(stdoutBuf)
 
 	// Next, configure a sandboxed filesystem to include one file.
 	file := "cat.go" // arbitrary file
 	memFS := wazero.WASIMemFS()
 	err := writeFile(memFS, file, catGo)
 	require.NoError(t, err)
-	wasiConfig.WithPreopens(map[string]wasi.FS{".": memFS})
+	sysConfig.WithWorkDirFS(memFS)
 
 	// Since this runs a main function (_start in WASI), configure the arguments.
 	// Remember, arg[0] is the program name!
-	wasiConfig.WithArgs("cat", file)
+	sysConfig.WithArgs("cat", file)
 
 	// Compile the `cat` module.
 	compiled, err := r.CompileModule(catWasm)
@@ -52,7 +52,7 @@ func Test_Cat(t *testing.T) {
 	defer wasi.Close()
 
 	// StartWASICommand runs the "_start" function which is what TinyGo compiles "main" to.
-	cat, err := wazero.StartWASICommandWithConfig(r, compiled, wasiConfig)
+	cat, err := wazero.StartWASICommandWithConfig(r, compiled, sysConfig)
 
 	require.NoError(t, err)
 	defer cat.Close()
diff --git a/examples/host_func_test.go b/examples/host_func_test.go
index 60ccedb8..fc675785 100644
--- a/examples/host_func_test.go
+++ b/examples/host_func_test.go
@@ -65,7 +65,7 @@ func Test_hostFunc(t *testing.T) {
 
 	// Configure stdout (console) to write to a buffer.
 	stdout := bytes.NewBuffer(nil)
-	config := wazero.NewWASIConfig().WithStdout(stdout)
+	config := wazero.NewSysConfig().WithStdout(stdout)
 
 	// Instantiate WASI, which implements system I/O such as console output.
 	wasi, err := r.InstantiateModule(wazero.WASISnapshotPreview1())
diff --git a/examples/stdio_test.go b/examples/stdio_test.go
index 09fd5a3b..33cc2372 100644
--- a/examples/stdio_test.go
+++ b/examples/stdio_test.go
@@ -26,7 +26,7 @@ func Test_stdio(t *testing.T) {
 	stdinBuf := bytes.NewBuffer([]byte("WASI\n"))
 	stdoutBuf := bytes.NewBuffer(nil)
 	stderrBuf := bytes.NewBuffer(nil)
-	config := wazero.NewWASIConfig().WithStdin(stdinBuf).WithStdout(stdoutBuf).WithStderr(stderrBuf)
+	config := wazero.NewSysConfig().WithStdin(stdinBuf).WithStdout(stdoutBuf).WithStderr(stderrBuf)
 
 	// Instantiate WASI, which implements system I/O such as console output.
 	wasi, err := r.InstantiateModule(wazero.WASISnapshotPreview1())
diff --git a/internal/cstring/cstring_test.go b/internal/cstring/cstring_test.go
deleted file mode 100644
index e53d6b30..00000000
--- a/internal/cstring/cstring_test.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package cstring
-
-import (
-	_ "embed"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestNewNullTerminatedStrings(t *testing.T) {
-	emptyWASIStringArray := &NullTerminatedStrings{NullTerminatedValues: [][]byte{}}
-	tests := []struct {
-		name     string
-		input    []string
-		expected *NullTerminatedStrings
-	}{
-		{
-			name:     "nil",
-			expected: emptyWASIStringArray,
-		},
-		{
-			name:     "none",
-			input:    []string{},
-			expected: emptyWASIStringArray,
-		},
-		{
-			name:  "two",
-			input: []string{"a", "bc"},
-			expected: &NullTerminatedStrings{
-				NullTerminatedValues: [][]byte{
-					{'a', 0},
-					{'b', 'c', 0},
-				},
-				TotalBufSize: 5,
-			},
-		},
-		{
-			name:  "two and empty string",
-			input: []string{"a", "", "bc"},
-			expected: &NullTerminatedStrings{
-				NullTerminatedValues: [][]byte{
-					{'a', 0},
-					{0},
-					{'b', 'c', 0},
-				},
-				TotalBufSize: 6,
-			},
-		},
-		{
-			name: "utf-8",
-			// "😨", "🤣", and "️🏃‍♀️" have 4, 4, and 13 bytes respectively
-			input: []string{"😨🤣🏃\u200d♀️", "foo", "bar"},
-			expected: &NullTerminatedStrings{
-				NullTerminatedValues: [][]byte{
-					[]byte("😨🤣🏃\u200d♀️\x00"),
-					{'f', 'o', 'o', 0},
-					{'b', 'a', 'r', 0},
-				},
-				TotalBufSize: 30,
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			s, err := NewNullTerminatedStrings(100, "", tc.input...)
-			require.NoError(t, err)
-			require.Equal(t, tc.expected, s)
-		})
-	}
-}
-
-func TestNewNullTerminatedStrings_Errors(t *testing.T) {
-	t.Run("invalid utf-8", func(t *testing.T) {
-		_, err := NewNullTerminatedStrings(100, "arg", "\xff\xfe\xfd", "foo", "bar")
-		require.EqualError(t, err, "arg[0] is not a valid UTF-8 string")
-	})
-	t.Run("arg[0] too large", func(t *testing.T) {
-		_, err := NewNullTerminatedStrings(1, "arg", "a", "bc")
-		require.EqualError(t, err, "arg[0] will exceed max buffer size 1")
-	})
-	t.Run("empty arg too large due to null terminator", func(t *testing.T) {
-		_, err := NewNullTerminatedStrings(2, "arg", "a", "", "bc")
-		require.EqualError(t, err, "arg[1] will exceed max buffer size 2")
-	})
-}
diff --git a/internal/wasi/wasi_test.go b/internal/wasi/wasi_test.go
index 61b72324..b8d021af 100644
--- a/internal/wasi/wasi_test.go
+++ b/internal/wasi/wasi_test.go
@@ -1,12 +1,13 @@
 package internalwasi
 
 import (
+	"bytes"
 	"context"
 	_ "embed"
-	"encoding/binary"
 	"errors"
 	"fmt"
 	"io"
+	"math"
 	"math/rand"
 	"testing"
 
@@ -23,10 +24,7 @@ const moduleName = "test"
 
 func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithArgs("a", "bc")
+	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
 	argv := uint32(7)    // arbitrary offset
@@ -41,6 +39,7 @@ func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 	}
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ArgsGet", func(t *testing.T) {
 		maskMemory(t, mod, len(expectedMemory))
@@ -69,12 +68,11 @@ func TestSnapshotPreview1_ArgsGet(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
-	err = sys.WithArgs("a", "bc")
-	require.NoError(t, err)
 	a, mod, _ := instantiateModule(t, ctx, FunctionArgsGet, ImportArgsGet, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 	validAddress := uint32(0) // arbitrary valid address as arguments to args_get. We chose 0 here.
@@ -121,10 +119,7 @@ func TestSnapshotPreview1_ArgsGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithArgs("a", "bc")
+	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
 	resultArgc := uint32(1)        // arbitrary offset
@@ -138,6 +133,7 @@ func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 	}
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ArgsSizesGet", func(t *testing.T) {
 		maskMemory(t, mod, len(expectedMemory))
@@ -166,13 +162,11 @@ func TestSnapshotPreview1_ArgsSizesGet(t *testing.T) {
 
 func TestSnapshotPreview1_ArgsSizesGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithArgs("a", "bc")
+	sys, err := newSysContext([]string{"a", "bc"}, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionArgsSizesGet, ImportArgsSizesGet, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 	validAddress := uint32(0) // arbitrary valid address as arguments to args_sizes_get. We chose 0 here.
@@ -217,10 +211,7 @@ func TestSnapshotPreview1_ArgsSizesGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithEnviron("a=b", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	resultEnviron := uint32(11)   // arbitrary offset
@@ -236,6 +227,7 @@ func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 	}
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.EnvironGet", func(t *testing.T) {
 		maskMemory(t, mod, len(expectedMemory))
@@ -264,13 +256,11 @@ func TestSnapshotPreview1_EnvironGet(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithEnviron("a=bc", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=bc", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironGet, ImportEnvironGet, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 	validAddress := uint32(0) // arbitrary valid address as arguments to environ_get. We chose 0 here.
@@ -317,10 +307,7 @@ func TestSnapshotPreview1_EnvironGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithEnviron("a=b", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	resultEnvironc := uint32(1)       // arbitrary offset
@@ -334,6 +321,7 @@ func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 	}
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.EnvironSizesGet", func(t *testing.T) {
 		maskMemory(t, mod, len(expectedMemory))
@@ -362,13 +350,11 @@ func TestSnapshotPreview1_EnvironSizesGet(t *testing.T) {
 
 func TestSnapshotPreview1_EnvironSizesGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithEnviron("a=b", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionEnvironSizesGet, ImportEnvironSizesGet, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 	validAddress := uint32(0) // arbitrary valid address as arguments to environ_sizes_get. We chose 0 here.
@@ -414,10 +400,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionClockResGet, ImportClockResGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ClockResGet", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.ClockResGet(mod, 0, 0))
@@ -440,10 +427,12 @@ func TestSnapshotPreview1_ClockTimeGet(t *testing.T) {
 	}
 
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sys)
+	defer mod.Close()
+
 	a.timeNowUnixNano = func() uint64 { return epochNanos }
 
 	t.Run("SnapshotPreview1.ClockTimeGet", func(t *testing.T) {
@@ -475,10 +464,12 @@ func TestSnapshotPreview1_ClockTimeGet_Errors(t *testing.T) {
 	epochNanos := uint64(1640995200000000000) // midnight UTC 2022-01-01
 
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionClockTimeGet, ImportClockTimeGet, moduleName, sys)
+	defer mod.Close()
+
 	a.timeNowUnixNano = func() uint64 { return epochNanos }
 
 	memorySize := mod.Memory().Size()
@@ -513,10 +504,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdAdvise, ImportFdAdvise, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdAdvise", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdAdvise(mod, 0, 0, 0, 0))
@@ -532,10 +524,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdAllocate, ImportFdAllocate, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdAllocate", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdAllocate(mod, 0, 0, 0))
@@ -554,11 +547,8 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 
 	setupFD := func() (publicwasm.Module, publicwasm.Function, *wasiAPI) {
 		ctx := context.Background()
-		sys, err := wasm.NewSystemContext()
-		require.NoError(t, err)
-
 		memFs := &MemFS{}
-		sys.OpenedFiles = map[uint32]*wasm.FileEntry{
+		sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 			fdToClose: {
 				Path: "/tmp",
 				FS:   memFs,
@@ -567,31 +557,45 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 				Path: "path to keep",
 				FS:   memFs,
 			},
-		}
+		})
+		require.NoError(t, err)
 
 		a, mod, fn := instantiateModule(t, ctx, FunctionFdClose, ImportFdClose, moduleName, sys)
 		return mod, fn, a
 	}
 
+	verify := func(mod publicwasm.Module) {
+		// Verify fdToClose is closed and removed from the opened FDs.
+		_, ok := sysContext(mod).OpenedFile(fdToClose)
+		require.False(t, ok)
+
+		// Verify fdToKeep is not closed
+		_, ok = sysContext(mod).OpenedFile(fdToKeep)
+		require.True(t, ok)
+	}
+
 	t.Run("SnapshotPreview1.FdClose", func(t *testing.T) {
 		mod, _, api := setupFD()
+		defer mod.Close()
 
 		errno := api.FdClose(mod, fdToClose)
 		require.Equal(t, wasi.ErrnoSuccess, errno)
-		require.NotContains(t, systemContext(mod).OpenedFiles, fdToClose) // Fd is closed and removed from the opened FDs.
-		require.Contains(t, systemContext(mod).OpenedFiles, fdToKeep)
+
+		verify(mod)
 	})
 	t.Run(FunctionFdClose, func(t *testing.T) {
 		mod, fn, _ := setupFD()
+		defer mod.Close()
 
 		ret, err := fn.Call(mod, uint64(fdToClose))
 		require.NoError(t, err)
-		require.Equal(t, wasi.ErrnoSuccess, wasi.Errno(ret[0]))           // cast because results are always uint64
-		require.NotContains(t, systemContext(mod).OpenedFiles, fdToClose) // Fd is closed and removed from the opened FDs.
-		require.Contains(t, systemContext(mod).OpenedFiles, fdToKeep)
+		require.Equal(t, wasi.ErrnoSuccess, wasi.Errno(ret[0])) // cast because results are always uint64
+
+		verify(mod)
 	})
 	t.Run("ErrnoBadF for an invalid FD", func(t *testing.T) {
 		mod, _, api := setupFD()
+		defer mod.Close()
 
 		errno := api.FdClose(mod, 42) // 42 is an arbitrary invalid FD
 		require.Equal(t, wasi.ErrnoBadf, errno)
@@ -601,10 +605,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdDatasync, ImportFdDatasync, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdDatasync", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdDatasync(mod, 0))
@@ -622,10 +627,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetFlags, ImportFdFdstatSetFlags, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFdstatSetFlags", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdFdstatSetFlags(mod, 0, 0))
@@ -641,10 +647,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdFdstatSetRights, ImportFdFdstatSetRights, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFdstatSetRights", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdFdstatSetRights(mod, 0, 0, 0))
@@ -660,10 +667,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatGet, ImportFdFilestatGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatGet", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdFilestatGet(mod, 0, 0))
@@ -679,10 +687,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetSize, ImportFdFilestatSetSize, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatSetSize", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdFilestatSetSize(mod, 0, 0))
@@ -698,10 +707,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdFilestatSetTimes, ImportFdFilestatSetTimes, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdFilestatSetTimes", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdFilestatSetTimes(mod, 0, 0, 0, 0))
@@ -717,10 +727,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdPread, ImportFdPread, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdPread", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdPread(mod, 0, 0, 0, 0, 0))
@@ -737,12 +748,11 @@ func TestSnapshotPreview1_FdPrestatGet(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[fd] = &wasm.FileEntry{Path: "/tmp"}
-
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sys)
+	defer mod.Close()
 
 	resultPrestat := uint32(1) // arbitrary offset
 	expectedMemory := []byte{
@@ -783,12 +793,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	sys.OpenedFiles = map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}}
-
 	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatGet, ImportFdPrestatGet, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 
@@ -827,12 +836,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[fd] = &wasm.FileEntry{Path: "/tmp", FS: &MemFS{}}
-
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sys)
+	defer mod.Close()
 
 	path := uint32(1)    // arbitrary offset
 	pathLen := uint32(3) // shorter than len("/tmp") to test the path is written for the length of pathLen
@@ -870,16 +878,15 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
-	sys.OpenedFiles = map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}}
-	pathLen := uint32(len("/tmp"))
-
 	a, mod, _ := instantiateModule(t, ctx, FunctionFdPrestatDirName, ImportFdPrestatDirName, moduleName, sys)
+	defer mod.Close()
 
 	memorySize := mod.Memory().Size()
 	validAddress := uint32(0) // Arbitrary valid address as arguments to fd_prestat_dir_name. We chose 0 here.
+	pathLen := uint32(len("/tmp"))
 
 	tests := []struct {
 		name          string
@@ -932,10 +939,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdPwrite, ImportFdPwrite, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdPwrite", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdPwrite(mod, 0, 0, 0, 0, 0))
@@ -950,8 +958,6 @@ func TestSnapshotPreview1_FdPwrite(t *testing.T) {
 
 func TestSnapshotPreview1_FdRead(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
 
 	fd := uint32(3)   // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 	iovs := uint32(1) // arbitrary offset
@@ -975,18 +981,16 @@ func TestSnapshotPreview1_FdRead(t *testing.T) {
 		'?',
 	)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sys)
-
 	// TestSnapshotPreview1_FdRead uses a matrix because setting up test files is complicated and has to be clean each time.
 	type fdReadFn func(ctx publicwasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
 	tests := []struct {
 		name   string
-		fdRead func() fdReadFn
+		fdRead func(*wasiAPI, *wasm.ModuleContext, publicwasm.Function) fdReadFn
 	}{
-		{"SnapshotPreview1.FdRead", func() fdReadFn {
+		{"SnapshotPreview1.FdRead", func(a *wasiAPI, _ *wasm.ModuleContext, _ publicwasm.Function) fdReadFn {
 			return a.FdRead
 		}},
-		{FunctionFdRead, func() fdReadFn {
+		{FunctionFdRead, func(_ *wasiAPI, mod *wasm.ModuleContext, fn publicwasm.Function) fdReadFn {
 			return func(ctx publicwasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
 				ret, err := fn.Call(mod, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultSize))
 				require.NoError(t, err)
@@ -1000,13 +1004,20 @@ func TestSnapshotPreview1_FdRead(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			// Create a fresh file to read the contents from
 			file, memFS := createFile(t, "test_path", []byte("wazero"))
-			sys.OpenedFiles[fd] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
+			sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+				fd: {Path: "test_path", FS: memFS, File: file},
+			})
+			require.NoError(t, err)
+
+			a, mod, fn := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sys)
+			defer mod.Close()
+
 			maskMemory(t, mod, len(expectedMemory))
 
 			ok := mod.Memory().Write(0, initialMemory)
 			require.True(t, ok)
 
-			errno := tc.fdRead()(mod, fd, iovs, iovsCount, resultSize)
+			errno := tc.fdRead(a, mod, fn)(mod, fd, iovs, iovsCount, resultSize)
 			require.Equal(t, wasi.ErrnoSuccess, errno)
 
 			actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
@@ -1021,12 +1032,13 @@ func TestSnapshotPreview1_FdRead_Errors(t *testing.T) {
 	file, memFS := createFile(t, "test_path", []byte{}) // file with empty contents
 
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		validFD: {Path: "test_path", FS: memFS, File: file},
+	})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[validFD] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
-
 	a, mod, _ := instantiateModule(t, ctx, FunctionFdRead, ImportFdRead, moduleName, sys)
+	defer mod.Close()
 
 	tests := []struct {
 		name                            string
@@ -1111,10 +1123,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdReaddir, ImportFdReaddir, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdReaddir", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdReaddir(mod, 0, 0, 0, 0, 0))
@@ -1130,10 +1143,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdRenumber, ImportFdRenumber, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdRenumber", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdRenumber(mod, 0, 0))
@@ -1152,12 +1166,13 @@ func TestSnapshotPreview1_FdSeek(t *testing.T) {
 	file, memFS := createFile(t, "test_path", []byte("wazero")) // arbitrary non-empty contents
 
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		fd: {Path: "test_path", FS: memFS, File: file},
+	})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[fd] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
-
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sys)
+	defer mod.Close()
 
 	// TestSnapshotPreview1_FdSeek uses a matrix because setting up test files is complicated and has to be clean each time.
 	type fdSeekFn func(ctx publicwasm.Module, fd uint32, offset uint64, whence, resultNewOffset uint32) wasi.Errno
@@ -1246,12 +1261,14 @@ func TestSnapshotPreview1_FdSeek_Errors(t *testing.T) {
 	validFD := uint32(3)                                        // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	file, memFS := createFile(t, "test_path", []byte("wazero")) // arbitrary valid file with non-empty contents
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		validFD: {Path: "test_path", FS: memFS, File: file},
+	})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[validFD] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
-
 	a, mod, _ := instantiateModule(t, ctx, FunctionFdSeek, ImportFdSeek, moduleName, sys)
+	defer mod.Close()
+
 	memorySize := mod.Memory().Size()
 
 	tests := []struct {
@@ -1293,10 +1310,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdSync, ImportFdSync, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdSync", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdSync(mod, 0))
@@ -1312,10 +1330,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionFdTell, ImportFdTell, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.FdTell", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.FdTell(mod, 0, 0))
@@ -1330,8 +1349,6 @@ func TestSnapshotPreview1_FdTell(t *testing.T) {
 
 func TestSnapshotPreview1_FdWrite(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
 
 	fd := uint32(3)   // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 	iovs := uint32(1) // arbitrary offset
@@ -1355,18 +1372,16 @@ func TestSnapshotPreview1_FdWrite(t *testing.T) {
 		'?',
 	)
 
-	a, mod, fn := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sys)
-
 	// TestSnapshotPreview1_FdWrite uses a matrix because setting up test files is complicated and has to be clean each time.
 	type fdWriteFn func(ctx publicwasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno
 	tests := []struct {
 		name    string
-		fdWrite func() fdWriteFn
+		fdWrite func(*wasiAPI, *wasm.ModuleContext, publicwasm.Function) fdWriteFn
 	}{
-		{"SnapshotPreview1.FdWrite", func() fdWriteFn {
+		{"SnapshotPreview1.FdWrite", func(a *wasiAPI, _ *wasm.ModuleContext, _ publicwasm.Function) fdWriteFn {
 			return a.FdWrite
 		}},
-		{FunctionFdWrite, func() fdWriteFn {
+		{FunctionFdWrite, func(_ *wasiAPI, mod *wasm.ModuleContext, fn publicwasm.Function) fdWriteFn {
 			return func(ctx publicwasm.Module, fd, iovs, iovsCount, resultSize uint32) wasi.Errno {
 				ret, err := fn.Call(mod, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultSize))
 				require.NoError(t, err)
@@ -1380,13 +1395,19 @@ func TestSnapshotPreview1_FdWrite(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			// Create a fresh file to write the contents to
 			file, memFS := createFile(t, "test_path", []byte{})
-			sys.OpenedFiles[fd] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
+			sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+				fd: {Path: "test_path", FS: memFS, File: file},
+			})
+			require.NoError(t, err)
+
+			a, mod, fn := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sys)
+			defer mod.Close()
 
 			maskMemory(t, mod, len(expectedMemory))
 			ok := mod.Memory().Write(0, initialMemory)
 			require.True(t, ok)
 
-			errno := tc.fdWrite()(mod, fd, iovs, iovsCount, resultSize)
+			errno := tc.fdWrite(a, mod, fn)(mod, fd, iovs, iovsCount, resultSize)
 			require.Equal(t, wasi.ErrnoSuccess, errno)
 
 			actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
@@ -1401,12 +1422,13 @@ func TestSnapshotPreview1_FdWrite_Errors(t *testing.T) {
 	validFD := uint32(3)                                // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	file, memFS := createFile(t, "test_path", []byte{}) // file with empty contents
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		validFD: {Path: "test_path", FS: memFS, File: file},
+	})
 	require.NoError(t, err)
 
-	sys.OpenedFiles[validFD] = &wasm.FileEntry{Path: "test_path", FS: memFS, File: file}
-
 	a, mod, _ := instantiateModule(t, ctx, FunctionFdWrite, ImportFdWrite, moduleName, sys)
+	defer mod.Close()
 
 	// Setup valid test memory
 	iovs, iovsCount := uint32(0), uint32(1)
@@ -1485,10 +1507,11 @@ func createFile(t *testing.T, path string, contents []byte) (*memFile, *MemFS) {
 // TestSnapshotPreview1_PathCreateDirectory only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_PathCreateDirectory(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathCreateDirectory, ImportPathCreateDirectory, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathCreateDirectory", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathCreateDirectory(mod, 0, 0, 0))
@@ -1504,10 +1527,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatGet, ImportPathFilestatGet, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathFilestatGet", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathFilestatGet(mod, 0, 0, 0, 0, 0))
@@ -1523,10 +1547,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathFilestatSetTimes, ImportPathFilestatSetTimes, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathFilestatSetTimes", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathFilestatSetTimes(mod, 0, 0, 0, 0, 0, 0, 0))
@@ -1542,10 +1567,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathLink, ImportPathLink, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathLink", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathLink(mod, 0, 0, 0, 0, 0, 0, 0))
@@ -1572,32 +1598,15 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 		'?',                          // `path` is after this
 		'w', 'a', 'z', 'e', 'r', 'o', // path
 	}
-	expectedFD := uint32(5) // arbitrary expected FD
+	expectedFD := byte(workdirFD + 1)
 	expectedMemory := append(
 		initialMemory,
-		'?',        // `resultOpenedFd` is after this
-		5, 0, 0, 0, // = epxectedFD (5)
+		'?', // `resultOpenedFd` is after this
+		expectedFD, 0, 0, 0,
 		'?',
 	)
 
-	// MemFS for testing
-	// Create a memFS for testing that has "./wazero" file.
-	memFS := &MemFS{
-		Files: map[string][]byte{
-			"wazero": {},
-		},
-	}
-
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	a, mod, fn := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sys)
-	// randSource is used to determine the new fd. Fix it to the expectedFD for testing.
-	a.randSource = func(b []byte) error {
-		binary.LittleEndian.PutUint32(b, expectedFD)
-		return nil
-	}
 
 	// TestSnapshotPreview1_PathOpen uses a matrix because setting up test files is complicated and has to be clean each time.
 	type pathOpenFn func(ctx publicwasm.Module, fd, dirflags, path, pathLen, oflags uint32,
@@ -1605,12 +1614,12 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 		fdFlags, resultOpenedFd uint32) wasi.Errno
 	pathOpenFns := []struct {
 		name     string
-		pathOpen func() pathOpenFn
+		pathOpen func(*wasiAPI, *wasm.ModuleContext, publicwasm.Function) pathOpenFn
 	}{
-		{"SnapshotPreview1.PathOpen", func() pathOpenFn {
+		{"SnapshotPreview1.PathOpen", func(a *wasiAPI, _ *wasm.ModuleContext, _ publicwasm.Function) pathOpenFn {
 			return a.PathOpen
 		}},
-		{FunctionPathOpen, func() pathOpenFn {
+		{FunctionPathOpen, func(_ *wasiAPI, mod *wasm.ModuleContext, fn publicwasm.Function) pathOpenFn {
 			return func(ctx publicwasm.Module, fd, dirflags, path, pathLen, oflags uint32,
 				fsRightsBase, fsRightsInheriting uint64,
 				fdFlags, resultOpenedFd uint32) wasi.Errno {
@@ -1639,23 +1648,31 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 			for _, tt := range tests {
 				tc := tt
 				t.Run(tc.name, func(t *testing.T) {
-					// Set up a fresh opened FD table
-					sys.OpenedFiles = map[uint32]*wasm.FileEntry{
+					// Create a memFS for testing that has "./wazero" file.
+					memFS := &MemFS{Files: map[string][]byte{"wazero": {}}}
+					sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 						workdirFD: {Path: ".", FS: memFS},
-					}
+					})
+					require.NoError(t, err)
+
+					a, mod, fn := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sys)
+					defer mod.Close()
 
 					maskMemory(t, mod, len(expectedMemory))
 					ok := mod.Memory().Write(0, initialMemory)
 					require.True(t, ok)
 
-					errno := pf.pathOpen()(mod, tc.fd, dirflags, path, pathLen, oflags, fsRightsBase, fsRightsInheriting, fdFlags, resultOpenedFd)
+					errno := pf.pathOpen(a, mod, fn)(mod, tc.fd, dirflags, path, pathLen, oflags, fsRightsBase, fsRightsInheriting, fdFlags, resultOpenedFd)
 					require.Equal(t, wasi.ErrnoSuccess, errno)
 
 					actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
 					require.True(t, ok)
 					require.Equal(t, expectedMemory, actual)
+
 					// verify the file was actually opened
-					require.Equal(t, tc.expectedPath, sys.OpenedFiles[expectedFD].Path)
+					f, ok := sys.OpenedFile(uint32(expectedFD))
+					require.True(t, ok)
+					require.Equal(t, tc.expectedPath, f.Path)
 				})
 			}
 		})
@@ -1671,18 +1688,17 @@ func TestSnapshotPreview1_PathOpen_Errors(t *testing.T) {
 		},
 	}
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	sys.OpenedFiles = map[uint32]*wasm.FileEntry{
+	sys, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
 		validFD: {Path: ".", FS: memFS},
-	}
+	})
+	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionPathOpen, ImportPathOpen, moduleName, sys)
+	defer mod.Close()
 
 	validPath := uint32(0)    // arbitrary offset
 	validPathLen := uint32(6) // the length of "wazero"
-	mod.Memory().Write(uint32(validPath), []byte{
+	mod.Memory().Write(validPath, []byte{
 		'w', 'a', 'z', 'e', 'r', 'o', // write to offset 0 (= validPath)
 	}) // wazero is the path to the file in the memFS
 
@@ -1739,10 +1755,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathReadlink, ImportPathReadlink, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathLink", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathReadlink(mod, 0, 0, 0, 0, 0, 0))
@@ -1758,10 +1775,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathRemoveDirectory, ImportPathRemoveDirectory, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathRemoveDirectory", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathRemoveDirectory(mod, 0, 0, 0))
@@ -1777,10 +1795,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathRename, ImportPathRename, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathRename", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathRename(mod, 0, 0, 0, 0, 0, 0))
@@ -1796,10 +1815,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathSymlink, ImportPathSymlink, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathSymlink", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathSymlink(mod, 0, 0, 0, 0, 0))
@@ -1815,10 +1835,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPathUnlinkFile, ImportPathUnlinkFile, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PathUnlinkFile", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PathUnlinkFile(mod, 0, 0, 0))
@@ -1834,10 +1855,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionPollOneoff, ImportPollOneoff, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.PollOneoff", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.PollOneoff(mod, 0, 0, 0, 0))
@@ -1852,7 +1874,7 @@ func TestSnapshotPreview1_PollOneoff(t *testing.T) {
 
 func TestSnapshotPreview1_ProcExit(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -1873,13 +1895,14 @@ 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) {
 			// When ProcExit is called, store.CallFunction returns immediately, returning the exit code as the error.
-			_, err := fn.Call(mod, uint64(tc.exitCode))
+			_, err = fn.Call(mod, uint64(tc.exitCode))
 			var code wasi.ExitCode
 			require.ErrorAs(t, err, &code)
 			require.Equal(t, code, wasi.ExitCode(tc.exitCode))
@@ -1890,10 +1913,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionProcRaise, ImportProcRaise, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.ProcRaise", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.ProcRaise(mod, 0))
@@ -1909,10 +1933,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionSchedYield, ImportSchedYield, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SchedYield", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.SchedYield(mod))
@@ -1936,10 +1961,12 @@ func TestSnapshotPreview1_RandomGet(t *testing.T) {
 	offset := uint32(1) // offset,
 	seed := int64(42)   // and seed value
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	defer mod.Close()
+
 	a.randSource = func(p []byte) error {
 		s := rand.NewSource(seed)
 		rng := rand.New(s)
@@ -1975,12 +2002,14 @@ func TestSnapshotPreview1_RandomGet(t *testing.T) {
 
 func TestSnapshotPreview1_RandomGet_Errors(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	validAddress := uint32(0) // arbitrary valid address
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	defer mod.Close()
+
 	memorySize := mod.Memory().Size()
 
 	tests := []struct {
@@ -2013,10 +2042,12 @@ func TestSnapshotPreview1_RandomGet_Errors(t *testing.T) {
 
 func TestSnapshotPreview1_RandomGet_SourceError(t *testing.T) {
 	ctx := context.Background()
-	sys, err := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(t, ctx, FunctionRandomGet, ImportRandomGet, moduleName, sys)
+	defer mod.Close()
+
 	a.randSource = func(p []byte) error {
 		return errors.New("random source error")
 	}
@@ -2028,10 +2059,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionSockRecv, ImportSockRecv, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockRecv", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.SockRecv(mod, 0, 0, 0, 0, 0, 0))
@@ -2047,10 +2079,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionSockSend, ImportSockSend, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockSend", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.SockSend(mod, 0, 0, 0, 0, 0))
@@ -2066,10 +2099,11 @@ 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 := wasm.NewSystemContext()
+	sys, err := newSysContext(nil, nil, nil)
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(t, ctx, FunctionSockShutdown, ImportSockShutdown, moduleName, sys)
+	defer mod.Close()
 
 	t.Run("SnapshotPreview1.SockShutdown", func(t *testing.T) {
 		require.Equal(t, wasi.ErrnoNosys, a.SockShutdown(mod, 0, 0))
@@ -2084,7 +2118,7 @@ func TestSnapshotPreview1_SockShutdown(t *testing.T) {
 
 const testMemoryPageSize = 1
 
-func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImport, moduleName string, sys *wasm.SystemContext) (*wasiAPI, *wasm.ModuleContext, publicwasm.Function) {
+func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImport, moduleName string, sys *wasm.SysContext) (*wasiAPI, *wasm.ModuleContext, publicwasm.Function) {
 	enabledFeatures := wasm.Features20191205
 	store := wasm.NewStore(interpreter.NewEngine(), enabledFeatures)
 
@@ -2097,7 +2131,7 @@ func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImpo
 	// Double-check what we created passes same validity as module-defined modules.
 	require.NoError(t, m.Validate(enabledFeatures))
 
-	_, err = store.Instantiate(ctx, m, m.NameSection.ModuleName) // TODO: close
+	_, err = store.Instantiate(ctx, m, m.NameSection.ModuleName, nil) // TODO: close
 	require.NoError(t, err)
 
 	m, err = text.DecodeModule([]byte(fmt.Sprintf(`(module
@@ -2108,9 +2142,8 @@ func instantiateModule(t *testing.T, ctx context.Context, wasiFunction, wasiImpo
 )`, wasiFunction, wasiImport)), enabledFeatures)
 	require.NoError(t, err)
 
-	mod, err := store.Instantiate(ctx, m, moduleName)
+	mod, err := store.Instantiate(ctx, m, moduleName, sys)
 	require.NoError(t, err)
-	mod.System = sys // TODO: temporary until #394
 
 	fn := mod.ExportedFunction(wasiFunction)
 	require.NotNil(t, fn)
@@ -2123,3 +2156,7 @@ func maskMemory(t *testing.T, mod publicwasm.Module, size int) {
 		require.True(t, mod.Memory().WriteByte(i, '?'))
 	}
 }
+
+func newSysContext(args, environ []string, openedFiles map[uint32]*wasm.FileEntry) (sys *wasm.SysContext, err error) {
+	return wasm.NewSysContext(math.MaxUint32, args, environ, new(bytes.Buffer), nil, nil, openedFiles)
+}
diff --git a/internal/wasm/global_test.go b/internal/wasm/global_test.go
index 215f2bc6..2fc55e24 100644
--- a/internal/wasm/global_test.go
+++ b/internal/wasm/global_test.go
@@ -260,7 +260,7 @@ func TestPublicModule_Global(t *testing.T) {
 		s := newStore()
 		t.Run(tc.name, func(t *testing.T) {
 			// Instantiate the module and get the export of the above global
-			module, err := s.Instantiate(context.Background(), tc.module, t.Name())
+			module, err := s.Instantiate(context.Background(), tc.module, t.Name(), nil)
 			require.NoError(t, err)
 
 			if global := module.ExportedGlobal("global"); tc.expected != nil {
diff --git a/internal/wasm/interpreter/interpreter_test.go b/internal/wasm/interpreter/interpreter_test.go
index 9fcdda01..39e468c6 100644
--- a/internal/wasm/interpreter/interpreter_test.go
+++ b/internal/wasm/interpreter/interpreter_test.go
@@ -175,7 +175,7 @@ func TestEngine_Call(t *testing.T) {
 	// Use exported functions to simplify instantiation of a Wasm function
 	e := NewEngine()
 	store := wasm.NewStore(e, wasm.Features20191205)
-	mod, err := store.Instantiate(context.Background(), m, t.Name())
+	mod, err := store.Instantiate(context.Background(), m, t.Name(), nil)
 	require.NoError(t, err)
 
 	fn := mod.ExportedFunction("fn")
@@ -207,7 +207,7 @@ func TestEngine_Call_HostFn(t *testing.T) {
 
 	e := NewEngine()
 	module := &wasm.ModuleInstance{Memory: memory}
-	modCtx := wasm.NewModuleContext(context.Background(), wasm.NewStore(e, wasm.Features20191205), module)
+	modCtx := wasm.NewModuleContext(context.Background(), wasm.NewStore(e, wasm.Features20191205), module, nil)
 
 	f := &wasm.FunctionInstance{
 		GoFunc: &hostFn,
diff --git a/internal/wasm/jit/asm/amd64/impl_test.go b/internal/wasm/jit/asm/amd64/impl_test.go
new file mode 100644
index 00000000..04cc9d8b
--- /dev/null
+++ b/internal/wasm/jit/asm/amd64/impl_test.go
@@ -0,0 +1,339 @@
+package amd64
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestNodeImpl_AssignJumpTarget(t *testing.T) {
+	n := &nodeImpl{}
+	target := &nodeImpl{}
+	n.AssignJumpTarget(target)
+	require.Equal(t, n.jumpTarget, target)
+}
+
+func TestNodeImpl_AssignDestinationConstant(t *testing.T) {
+	n := &nodeImpl{}
+	n.AssignDestinationConstant(12345)
+	require.Equal(t, int64(12345), n.dstConst)
+}
+
+func TestNodeImpl_AssignSourceConstant(t *testing.T) {
+	n := &nodeImpl{}
+	n.AssignSourceConstant(12345)
+	require.Equal(t, int64(12345), n.srcConst)
+}
+
+func TestNodeImpl_String(t *testing.T) {
+	for _, tc := range []struct {
+		in  *nodeImpl
+		exp string
+	}{
+		{
+			in:  &nodeImpl{instruction: NOP},
+			exp: "NOP",
+		},
+		{
+			in:  &nodeImpl{instruction: SETCC, types: operandTypesNoneToRegister, dstReg: REG_AX},
+			exp: "SETCC AX",
+		},
+		{
+			in:  &nodeImpl{instruction: JMP, types: operandTypesNoneToMemory, dstReg: REG_AX, dstConst: 100},
+			exp: "JMP [AX + 0x64]",
+		},
+		{
+			in:  &nodeImpl{instruction: JMP, types: operandTypesNoneToMemory, dstReg: REG_AX, dstConst: 100, dstMemScale: 8, dstMemIndex: REG_R11},
+			exp: "JMP [AX + 0x64 + R11*0x8]",
+		},
+		{
+			in:  &nodeImpl{instruction: JMP, types: operandTypesNoneToBranch, jumpTarget: &nodeImpl{instruction: JMP, types: operandTypesNoneToMemory, dstReg: REG_AX, dstConst: 100}},
+			exp: "JMP {JMP [AX + 0x64]}",
+		},
+		{
+			in:  &nodeImpl{instruction: IDIVQ, types: operandTypesRegisterToNone, srcReg: REG_DX},
+			exp: "IDIVQ DX",
+		},
+		{
+			in:  &nodeImpl{instruction: ADDL, types: operandTypesRegisterToRegister, srcReg: REG_DX, dstReg: REG_R14},
+			exp: "ADDL DX, R14",
+		},
+		{
+			in: &nodeImpl{instruction: MOVQ, types: operandTypesRegisterToMemory,
+				srcReg: REG_DX, dstReg: REG_R14, dstConst: 100},
+			exp: "MOVQ DX, [R14 + 0x64]",
+		},
+		{
+			in: &nodeImpl{instruction: MOVQ, types: operandTypesRegisterToMemory,
+				srcReg: REG_DX, dstReg: REG_R14, dstConst: 100, dstMemIndex: REG_CX, dstMemScale: 4},
+			exp: "MOVQ DX, [R14 + 0x64 + CX*0x4]",
+		},
+		{
+			in: &nodeImpl{instruction: CMPL, types: operandTypesRegisterToConst,
+				srcReg: REG_DX, dstConst: 100},
+			exp: "CMPL DX, 0x64",
+		},
+		{
+			in: &nodeImpl{instruction: MOVL, types: operandTypesMemoryToRegister,
+				srcReg: REG_DX, srcConst: 1, dstReg: REG_AX},
+			exp: "MOVL [DX + 0x1], AX",
+		},
+		{
+			in: &nodeImpl{instruction: MOVL, types: operandTypesMemoryToRegister,
+				srcReg: REG_DX, srcConst: 1, srcMemIndex: REG_R12, srcMemScale: 2,
+				dstReg: REG_AX},
+			exp: "MOVL [DX + 1 + R12*0x2], AX",
+		},
+		{
+			in: &nodeImpl{instruction: CMPQ, types: operandTypesMemoryToConst,
+				srcReg: REG_DX, srcConst: 1, srcMemIndex: REG_R12, srcMemScale: 2,
+				dstConst: 123},
+			exp: "CMPQ [DX + 1 + R12*0x2], 0x7b",
+		},
+		{
+			in:  &nodeImpl{instruction: MOVQ, types: operandTypesConstToMemory, srcConst: 123, dstReg: REG_AX, dstConst: 100, dstMemScale: 8, dstMemIndex: REG_R11},
+			exp: "MOVQ 0x7b, [AX + 0x64 + R11*0x8]",
+		},
+		{
+			in:  &nodeImpl{instruction: MOVQ, types: operandTypesConstToRegister, srcConst: 123, dstReg: REG_AX},
+			exp: "MOVQ 0x7b, AX",
+		},
+	} {
+		require.Equal(t, tc.exp, tc.in.String())
+	}
+}
+
+func TestAssemblerImpl_addNode(t *testing.T) {
+	a := &assemblerImpl{}
+
+	root := &nodeImpl{}
+	a.addNode(root)
+	require.Equal(t, a.root, root)
+	require.Equal(t, a.current, root)
+	require.Nil(t, root.next)
+
+	next := &nodeImpl{}
+	a.addNode(next)
+	require.Equal(t, a.root, root)
+	require.Equal(t, a.current, next)
+	require.Equal(t, next, root.next)
+	require.Nil(t, next.next)
+}
+
+func TestAssemblerImpl_newNode(t *testing.T) {
+	a := &assemblerImpl{}
+	actual := a.newNode(ADDL, operandTypesConstToMemory)
+	require.Equal(t, ADDL, actual.instruction)
+	require.Equal(t, operandTypeConst, actual.types.src)
+	require.Equal(t, operandTypeMemory, actual.types.dst)
+	require.Equal(t, actual, a.root)
+	require.Equal(t, actual, a.current)
+}
+
+func TestAssemblerImpl_encodeNode(t *testing.T) {
+	t.Run("encoder undefined", func(t *testing.T) {
+		a := &assemblerImpl{}
+		err := a.encodeNode(bytes.NewBuffer(nil), &nodeImpl{
+			types: operandTypes{operandTypeBranch, operandTypeRegister},
+		})
+		require.EqualError(t, err, "encoder undefined for [from:branch,to:register] operand type")
+	})
+	// TODO: adds ok cases.
+}
+
+func TestAssemblerImpl_Assemble(t *testing.T) {
+	t.Run("callback", func(t *testing.T) {
+		t.Run("ok", func(t *testing.T) {
+			a := &assemblerImpl{}
+			callbacked := false
+			a.AddOnGenerateCallBack(func(b []byte) error { callbacked = true; return nil })
+			_, err := a.Assemble()
+			require.NoError(t, err)
+			require.True(t, callbacked)
+		})
+		t.Run("error", func(t *testing.T) {
+			a := &assemblerImpl{}
+			a.AddOnGenerateCallBack(func(b []byte) error { return fmt.Errorf("some error") })
+			_, err := a.Assemble()
+			require.EqualError(t, err, "some error")
+		})
+	})
+	// TODO: adds actual e2e assembling case.
+}
+
+func TestAssemblerImpl_CompileStandAlone(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileStandAlone(RET)
+	actualNode := a.current
+	require.Equal(t, RET, actualNode.instruction)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeNone, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileConstToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileConstToRegister(MOVQ, 1000, REG_AX)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, int64(1000), actualNode.srcConst)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, operandTypeConst, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileRegisterToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileRegisterToRegister(MOVQ, REG_BX, REG_AX)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, operandTypeRegister, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileMemoryToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileMemoryToRegister(MOVQ, REG_BX, 100, REG_AX)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, int64(100), actualNode.srcConst)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, operandTypeMemory, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileRegisterToMemory(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileRegisterToMemory(MOVQ, REG_BX, REG_AX, 100)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, int64(100), actualNode.dstConst)
+	require.Equal(t, operandTypeRegister, actualNode.types.src)
+	require.Equal(t, operandTypeMemory, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileJump(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileJump(JMP)
+	actualNode := a.current
+	require.Equal(t, JMP, actualNode.instruction)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeBranch, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileJumpToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileJumpToRegister(JNE, REG_AX)
+	actualNode := a.current
+	require.Equal(t, JNE, actualNode.instruction)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileJumpToMemory(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileJumpToMemory(JNE, REG_AX, 100)
+	actualNode := a.current
+	require.Equal(t, JNE, actualNode.instruction)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, int64(100), actualNode.dstConst)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeMemory, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileReadInstructionAddress(t *testing.T) {
+	t.Skip("TODO: unimplemented")
+}
+
+func TestAssemblerImpl_CompileRegisterToRegisterWithMode(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileRegisterToRegisterWithMode(MOVQ, REG_BX, REG_AX, 123)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, byte(123), actualNode.mode)
+	require.Equal(t, operandTypeRegister, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileMemoryWithIndexToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileMemoryWithIndexToRegister(MOVQ, REG_BX, 100, REG_R10, 8, REG_AX)
+	actualNode := a.current
+	require.Equal(t, MOVQ, actualNode.instruction)
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, int64(100), actualNode.srcConst)
+	require.Equal(t, REG_R10, actualNode.srcMemIndex)
+	require.Equal(t, byte(8), actualNode.srcMemScale)
+	require.Equal(t, REG_AX, actualNode.dstReg)
+	require.Equal(t, operandTypeMemory, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileRegisterToConst(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileRegisterToConst(MOVQ, REG_BX, 123)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, int64(123), actualNode.dstConst)
+	require.Equal(t, operandTypeRegister, actualNode.types.src)
+	require.Equal(t, operandTypeConst, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileRegisterToNone(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileRegisterToNone(MOVQ, REG_BX)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, operandTypeRegister, actualNode.types.src)
+	require.Equal(t, operandTypeNone, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileNoneToRegister(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileNoneToRegister(MOVQ, REG_BX)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.dstReg)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeRegister, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileNoneToMemory(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileNoneToMemory(MOVQ, REG_BX, 1234)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.dstReg)
+	require.Equal(t, int64(1234), actualNode.dstConst)
+	require.Equal(t, operandTypeNone, actualNode.types.src)
+	require.Equal(t, operandTypeMemory, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileConstToMemory(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileConstToMemory(MOVQ, -9999, REG_BX, 1234)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.dstReg)
+	require.Equal(t, int64(-9999), actualNode.srcConst)
+	require.Equal(t, int64(1234), actualNode.dstConst)
+	require.Equal(t, operandTypeConst, actualNode.types.src)
+	require.Equal(t, operandTypeMemory, actualNode.types.dst)
+}
+
+func TestAssemblerImpl_CompileMemoryToConst(t *testing.T) {
+	a := &assemblerImpl{}
+	a.CompileMemoryToConst(MOVQ, REG_BX, 1234, -9999)
+	actualNode := a.current
+	require.Equal(t, REG_BX, actualNode.srcReg)
+	require.Equal(t, int64(1234), actualNode.srcConst)
+	require.Equal(t, int64(-9999), actualNode.dstConst)
+	require.Equal(t, operandTypeMemory, actualNode.types.src)
+	require.Equal(t, operandTypeConst, actualNode.types.dst)
+}
diff --git a/internal/wasm/jit/engine_test.go b/internal/wasm/jit/engine_test.go
index bf350805..ee1a630f 100644
--- a/internal/wasm/jit/engine_test.go
+++ b/internal/wasm/jit/engine_test.go
@@ -127,7 +127,7 @@ func TestEngine_Call(t *testing.T) {
 	// Use exported functions to simplify instantiation of a Wasm function
 	e := NewEngine()
 	store := wasm.NewStore(e, wasm.Features20191205)
-	mod, err := store.Instantiate(context.Background(), m, t.Name())
+	mod, err := store.Instantiate(context.Background(), m, t.Name(), nil)
 	require.NoError(t, err)
 
 	fn := mod.ExportedFunction("fn")
@@ -266,7 +266,7 @@ func TestEngine_Call_HostFn(t *testing.T) {
 
 	e := NewEngine()
 	module := &wasm.ModuleInstance{Memory: memory}
-	modCtx := wasm.NewModuleContext(context.Background(), wasm.NewStore(e, wasm.Features20191205), module)
+	modCtx := wasm.NewModuleContext(context.Background(), wasm.NewStore(e, wasm.Features20191205), module, nil)
 
 	f := &wasm.FunctionInstance{
 		GoFunc: &hostFn,
@@ -523,7 +523,7 @@ func TestSliceAllocatedOnHeap(t *testing.T) {
 	}})
 	require.NoError(t, err)
 
-	_, err = store.Instantiate(context.Background(), hm, hostModuleName)
+	_, err = store.Instantiate(context.Background(), hm, hostModuleName, nil)
 	require.NoError(t, err)
 
 	const valueStackCorruption = "value_stack_corruption"
@@ -574,7 +574,7 @@ func TestSliceAllocatedOnHeap(t *testing.T) {
 		},
 	}
 
-	mi, err := store.Instantiate(context.Background(), m, t.Name())
+	mi, err := store.Instantiate(context.Background(), m, t.Name(), nil)
 	require.NoError(t, err)
 
 	for _, fnName := range []string{valueStackCorruption, callStackCorruption} {
diff --git a/internal/wasm/store_test.go b/internal/wasm/store_test.go
index 84b5cbec..4297e7b2 100644
--- a/internal/wasm/store_test.go
+++ b/internal/wasm/store_test.go
@@ -5,14 +5,12 @@ import (
 	"encoding/binary"
 	"fmt"
 	"math"
-	"os"
 	"strconv"
 	"sync"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 
-	"github.com/tetratelabs/wazero/internal/cstring"
 	"github.com/tetratelabs/wazero/wasm"
 )
 
@@ -76,7 +74,7 @@ func TestModuleInstance_Memory(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			s := newStore()
 
-			instance, err := s.Instantiate(context.Background(), tc.input, "test")
+			instance, err := s.Instantiate(context.Background(), tc.input, "test", nil)
 			require.NoError(t, err)
 
 			mem := instance.ExportedMemory("memory")
@@ -93,7 +91,7 @@ func TestModuleContext_String(t *testing.T) {
 	s := newStore()
 
 	// Ensure paths that can create the host module can see the name.
-	m, err := s.Instantiate(context.Background(), &Module{}, "module")
+	m, err := s.Instantiate(context.Background(), &Module{}, "module", nil)
 	require.NoError(t, err)
 	require.Equal(t, "Module[module]", m.String())
 	require.Equal(t, "Module[module]", s.Module(m.module.Name).String())
@@ -106,7 +104,8 @@ func TestStore_Instantiate(t *testing.T) {
 
 	type key string
 	ctx := context.WithValue(context.Background(), key("a"), "b") // arbitrary non-default context
-	mod, err := s.Instantiate(ctx, m, "")
+	sys := &SysContext{}
+	mod, err := s.Instantiate(ctx, m, "", sys)
 	require.NoError(t, err)
 	defer mod.Close()
 
@@ -115,12 +114,7 @@ func TestStore_Instantiate(t *testing.T) {
 		require.Equal(t, s.modules[""], mod.module)
 		require.Equal(t, s.modules[""].Memory, mod.memory)
 		require.Equal(t, s, mod.store)
-		require.Equal(t, cstring.EmptyNullTerminatedStrings, mod.System.Args)
-		require.Equal(t, cstring.EmptyNullTerminatedStrings, mod.System.Environ)
-		require.Equal(t, os.Stdin, mod.System.Stdin)
-		require.Equal(t, os.Stdout, mod.System.Stdout)
-		require.Equal(t, os.Stderr, mod.System.Stderr)
-		require.Empty(t, mod.System.OpenedFiles)
+		require.Equal(t, sys, mod.sys)
 	})
 }
 
@@ -137,7 +131,7 @@ func TestStore_CloseModule(t *testing.T) {
 			initializer: func(t *testing.T, s *Store) {
 				m, err := NewHostModule(importedModuleName, map[string]interface{}{"fn": func(wasm.Module) {}})
 				require.NoError(t, err)
-				_, err = s.Instantiate(context.Background(), m, importedModuleName)
+				_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 				require.NoError(t, err)
 			},
 		},
@@ -149,7 +143,7 @@ func TestStore_CloseModule(t *testing.T) {
 					FunctionSection: []uint32{0},
 					CodeSection:     []*Code{{Body: []byte{OpcodeEnd}}},
 					ExportSection:   map[string]*Export{"fn": {Type: ExternTypeFunc, Index: 0, Name: "fn"}},
-				}, importedModuleName)
+				}, importedModuleName, nil)
 				require.NoError(t, err)
 			},
 		},
@@ -165,7 +159,7 @@ func TestStore_CloseModule(t *testing.T) {
 				MemorySection: &Memory{Min: 1},
 				GlobalSection: []*Global{{Type: &GlobalType{}, Init: &ConstantExpression{Opcode: OpcodeI32Const, Data: []byte{0x1}}}},
 				TableSection:  &Table{Min: 10},
-			}, importingModuleName)
+			}, importingModuleName, nil)
 			require.NoError(t, err)
 
 			_, ok := s.modules[importedModuleName]
@@ -199,7 +193,7 @@ func TestStore_concurrent(t *testing.T) {
 	var wg sync.WaitGroup
 
 	s := newStore()
-	_, err = s.Instantiate(context.Background(), m, importedModuleName)
+	_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 	require.NoError(t, err)
 
 	hm, ok := s.modules[importedModuleName]
@@ -222,7 +216,7 @@ func TestStore_concurrent(t *testing.T) {
 	for i := 0; i < goroutines; i++ {
 		go func(i int) {
 			defer wg.Done()
-			_, err := s.Instantiate(context.Background(), importingModule, strconv.Itoa(i))
+			_, err := s.Instantiate(context.Background(), importingModule, strconv.Itoa(i), nil)
 			require.NoError(t, err)
 		}(i)
 	}
@@ -254,17 +248,17 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 
 	t.Run("Fails if module name already in use", func(t *testing.T) {
 		s := newStore()
-		_, err = s.Instantiate(context.Background(), m, importedModuleName)
+		_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 		require.NoError(t, err)
 
 		// Trying to register it again should fail
-		_, err = s.Instantiate(context.Background(), m, importedModuleName)
+		_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 		require.EqualError(t, err, "module imported has already been instantiated")
 	})
 
 	t.Run("fail resolve import", func(t *testing.T) {
 		s := newStore()
-		_, err = s.Instantiate(context.Background(), m, importedModuleName)
+		_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 		require.NoError(t, err)
 
 		hm := s.modules[importedModuleName]
@@ -278,14 +272,14 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 				// But the second one tries to import uninitialized-module ->
 				{Type: ExternTypeFunc, Module: "non-exist", Name: "fn", DescFunc: 0},
 			},
-		}, importingModuleName)
+		}, importingModuleName, nil)
 		require.EqualError(t, err, "module[non-exist] not instantiated")
 	})
 
 	t.Run("compilation failed", func(t *testing.T) {
 		s := newStore()
 
-		_, err = s.Instantiate(context.Background(), m, importedModuleName)
+		_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 		require.NoError(t, err)
 
 		hm := s.modules[importedModuleName]
@@ -304,7 +298,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 			ImportSection: []*Import{
 				{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 			},
-		}, importingModuleName)
+		}, importingModuleName, nil)
 		require.EqualError(t, err, "compilation failed: some compilation error")
 	})
 
@@ -313,7 +307,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 		engine := s.engine.(*mockEngine)
 		engine.callFailIndex = 1
 
-		_, err = s.Instantiate(context.Background(), m, importedModuleName)
+		_, err = s.Instantiate(context.Background(), m, importedModuleName, nil)
 		require.NoError(t, err)
 
 		hm := s.modules[importedModuleName]
@@ -328,7 +322,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 			ImportSection: []*Import{
 				{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 			},
-		}, importingModuleName)
+		}, importingModuleName, nil)
 		require.EqualError(t, err, "start function[1] failed: call failed")
 	})
 }
@@ -340,7 +334,7 @@ func TestStore_ExportImportedHostFunction(t *testing.T) {
 	s := newStore()
 
 	// Add the host module
-	_, err = s.Instantiate(context.Background(), m, m.NameSection.ModuleName)
+	_, err = s.Instantiate(context.Background(), m, m.NameSection.ModuleName, nil)
 	require.NoError(t, err)
 
 	t.Run("Module is the importing module", func(t *testing.T) {
@@ -349,7 +343,7 @@ func TestStore_ExportImportedHostFunction(t *testing.T) {
 			ImportSection: []*Import{{Type: ExternTypeFunc, Module: "host", Name: "host_fn", DescFunc: 0}},
 			MemorySection: &Memory{Min: 1},
 			ExportSection: map[string]*Export{"host.fn": {Type: ExternTypeFunc, Name: "host.fn", Index: 0}},
-		}, "test")
+		}, "test", nil)
 		require.NoError(t, err)
 
 		mod, ok := s.modules["test"]
@@ -400,7 +394,7 @@ func TestFunctionInstance_Call(t *testing.T) {
 			store := NewStore(&mockEngine{shouldCompileFail: false, callFailIndex: -1}, Features20191205)
 
 			// Add the host module
-			hm, err := store.Instantiate(storeCtx, m, "host")
+			hm, err := store.Instantiate(storeCtx, m, "host", nil)
 			require.NoError(t, err)
 
 			// Make a module to import the function
@@ -414,7 +408,7 @@ func TestFunctionInstance_Call(t *testing.T) {
 				}},
 				MemorySection: &Memory{Min: 1},
 				ExportSection: map[string]*Export{functionName: {Type: ExternTypeFunc, Name: functionName, Index: 0}},
-			}, "test")
+			}, "test", nil)
 			require.NoError(t, err)
 
 			// This fails if the function wasn't invoked, or had an unexpected context.
diff --git a/internal/wasm/sys_test.go b/internal/wasm/sys_test.go
index 8771c155..1f9710e4 100644
--- a/internal/wasm/sys_test.go
+++ b/internal/wasm/sys_test.go
@@ -1,89 +1,149 @@
 package internalwasm
 
 import (
-	"os"
+	"bytes"
+	"io"
 	"testing"
 
 	"github.com/stretchr/testify/require"
-
-	"github.com/tetratelabs/wazero/internal/cstring"
 )
 
-func TestSystemContext_Defaults(t *testing.T) {
-	sys, err := NewSystemContext()
-	require.Nil(t, err)
-
-	require.Equal(t, cstring.EmptyNullTerminatedStrings, sys.Args)
-	require.Equal(t, cstring.EmptyNullTerminatedStrings, sys.Environ)
-	require.Equal(t, os.Stdin, sys.Stdin)
-	require.Equal(t, os.Stdout, sys.Stdout)
-	require.Equal(t, os.Stderr, sys.Stderr)
-	require.Empty(t, sys.OpenedFiles)
-}
-
-func TestSystemContext_WithArgs(t *testing.T) {
-	t.Run("valid", func(t *testing.T) {
-		sys, err := NewSystemContext()
-		require.Nil(t, err)
+func TestDefaultSysContext(t *testing.T) {
+	sys, err := NewSysContext(
+		0,   // max
+		nil, // args
+		nil, // environ
+		nil, // stdin
+		nil, // stdout
+		nil, // stderr
+		nil, // openedFiles
+	)
+	require.NoError(t, err)
 
-		err = sys.WithArgs("a", "bc")
-		require.NoError(t, err)
+	require.Nil(t, sys.Args())
+	require.Zero(t, sys.ArgsSize())
+	require.Nil(t, sys.Environ())
+	require.Zero(t, sys.EnvironSize())
+	require.Equal(t, eofReader{}, sys.Stdin())
+	require.Equal(t, io.Discard, sys.Stdout())
+	require.Equal(t, io.Discard, sys.Stderr())
+	require.Empty(t, sys.openedFiles)
 
-		require.Equal(t, &cstring.NullTerminatedStrings{
-			NullTerminatedValues: [][]byte{
-				{'a', 0},
-				{'b', 'c', 0},
-			},
-			TotalBufSize: 5,
-		}, sys.Args)
-	})
-	t.Run("error constructing args", func(t *testing.T) {
-		sys, err := NewSystemContext()
-		require.Nil(t, err)
-
-		err = sys.WithArgs("\xff\xfe\xfd", "foo", "bar")
-		require.EqualError(t, err, "arg[0] is not a valid UTF-8 string")
-	})
+	require.Equal(t, sys, DefaultSysContext())
 }
 
-func TestSystemContext_WithEnviron(t *testing.T) {
-	t.Run("valid", func(t *testing.T) {
-		sys, err := NewSystemContext()
-		require.Nil(t, err)
+func TestNewSysContext_Args(t *testing.T) {
+	tests := []struct {
+		name         string
+		args         []string
+		maxSize      uint32
+		expectedSize uint32
+		expectedErr  string
+	}{
+		{
+			name:         "ok",
+			maxSize:      10,
+			args:         []string{"a", "bc"},
+			expectedSize: 5,
+		},
+		{
+			name:        "exceeds max count",
+			maxSize:     1,
+			args:        []string{"a", "bc"},
+			expectedErr: "args invalid: exceeds maximum count",
+		},
+		{
+			name:        "exceeds max size",
+			maxSize:     4,
+			args:        []string{"a", "bc"},
+			expectedErr: "args invalid: exceeds maximum size",
+		},
+		{
+			name:        "null character",
+			maxSize:     10,
+			args:        []string{"a", string([]byte{'b', 0})},
+			expectedErr: "args invalid: contains NUL character",
+		},
+	}
 
-		err = sys.WithEnviron("a=b", "b=cd")
-		require.NoError(t, err)
+	for _, tt := range tests {
+		tc := tt
 
-		require.Equal(t, &cstring.NullTerminatedStrings{
-			NullTerminatedValues: [][]byte{
-				{'a', '=', 'b', 0},
-				{'b', '=', 'c', 'd', 0},
-			},
-			TotalBufSize: 9,
-		}, sys.Environ)
-	})
+		t.Run(tc.name, func(t *testing.T) {
+			sys, err := NewSysContext(
+				tc.maxSize, // max
+				tc.args,
+				nil,                              // environ
+				bytes.NewReader(make([]byte, 0)), // stdin
+				nil,                              // stdout
+				nil,                              // stderr
+				nil,                              // openedFiles
+			)
+			if tc.expectedErr == "" {
+				require.Nil(t, err)
+				require.Equal(t, tc.args, sys.Args())
+				require.Equal(t, tc.expectedSize, sys.ArgsSize())
+			} else {
+				require.EqualError(t, err, tc.expectedErr)
+			}
+		})
+	}
+}
 
-	errorTests := []struct {
+func TestNewSysContext_Environ(t *testing.T) {
+	tests := []struct {
 		name         string
-		environ      string
-		errorMessage string
+		environ      []string
+		maxSize      uint32
+		expectedSize uint32
+		expectedErr  string
 	}{
-		{name: "error invalid utf-8",
-			environ:      "non_utf8=\xff\xfe\xfd",
-			errorMessage: "environ[0] is not a valid UTF-8 string"},
-		{name: "error not '='-joined pair",
-			environ:      "no_equal_pair",
-			errorMessage: "environ[0] is not joined with '='"},
+		{
+			name:         "ok",
+			maxSize:      10,
+			environ:      []string{"a=b", "c=de"},
+			expectedSize: 9,
+		},
+		{
+			name:        "exceeds max count",
+			maxSize:     1,
+			environ:     []string{"a=b", "c=de"},
+			expectedErr: "environ invalid: exceeds maximum count",
+		},
+		{
+			name:        "exceeds max size",
+			maxSize:     4,
+			environ:     []string{"a=b", "c=de"},
+			expectedErr: "environ invalid: exceeds maximum size",
+		},
+		{
+			name:        "null character",
+			maxSize:     10,
+			environ:     []string{"a=b", string(append([]byte("c=d"), 0))},
+			expectedErr: "environ invalid: contains NUL character",
+		},
 	}
-	for _, tt := range errorTests {
+
+	for _, tt := range tests {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sys, err := NewSystemContext()
-			require.Nil(t, err)
-
-			err = sys.WithEnviron(tc.environ)
-			require.EqualError(t, err, tc.errorMessage)
+			sys, err := NewSysContext(
+				tc.maxSize, // max
+				nil,        // args
+				tc.environ,
+				bytes.NewReader(make([]byte, 0)), // stdin
+				nil,                              // stdout
+				nil,                              // stderr
+				nil,                              // openedFiles
+			)
+			if tc.expectedErr == "" {
+				require.Nil(t, err)
+				require.Equal(t, tc.environ, sys.Environ())
+				require.Equal(t, tc.expectedSize, sys.EnvironSize())
+			} else {
+				require.EqualError(t, err, tc.expectedErr)
+			}
 		})
 	}
 }
diff --git a/tests/bench/bench_test.go b/tests/bench/bench_test.go
index d85f2483..8d767356 100644
--- a/tests/bench/bench_test.go
+++ b/tests/bench/bench_test.go
@@ -18,11 +18,13 @@ var caseWasm []byte
 func BenchmarkEngines(b *testing.B) {
 	b.Run("interpreter", func(b *testing.B) {
 		m := instantiateHostFunctionModuleWithEngine(b, wazero.NewRuntimeConfigInterpreter())
+		defer m.Close()
 		runAllBenches(b, m)
 	})
 	if runtime.GOARCH == "amd64" {
 		b.Run("jit", func(b *testing.B) {
 			m := instantiateHostFunctionModuleWithEngine(b, wazero.NewRuntimeConfigJIT())
+			defer m.Close()
 			runAllBenches(b, m)
 		})
 	}
diff --git a/tests/bench/wasi_test.go b/tests/bench/wasi_test.go
index 565ffb94..8d14216b 100644
--- a/tests/bench/wasi_test.go
+++ b/tests/bench/wasi_test.go
@@ -1,7 +1,9 @@
 package bench
 
 import (
+	"bytes"
 	"context"
+	"math"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -25,10 +27,7 @@ var testMem = &wasm.MemoryInstance{
 }
 
 func Test_EnvironGet(t *testing.T) {
-	sys, err := wasm.NewSystemContext()
-	require.NoError(t, err)
-
-	err = sys.WithEnviron("a=b", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	require.NoError(t, err)
 
 	testCtx := newCtx(make([]byte, 20), sys)
@@ -39,12 +38,7 @@ func Test_EnvironGet(t *testing.T) {
 }
 
 func Benchmark_EnvironGet(b *testing.B) {
-	sys, err := wasm.NewSystemContext()
-	if err != nil {
-		b.Fatal(err)
-	}
-
-	err = sys.WithEnviron("a=b", "b=cd")
+	sys, err := newSysContext(nil, []string{"a=b", "b=cd"}, nil)
 	if err != nil {
 		b.Fatal(err)
 	}
@@ -69,10 +63,12 @@ func Benchmark_EnvironGet(b *testing.B) {
 	})
 }
 
-func newCtx(buf []byte, sys *wasm.SystemContext) *wasm.ModuleContext {
-	ret := wasm.NewModuleContext(context.Background(), nil, &wasm.ModuleInstance{
+func newCtx(buf []byte, sys *wasm.SysContext) *wasm.ModuleContext {
+	return wasm.NewModuleContext(context.Background(), nil, &wasm.ModuleInstance{
 		Memory: &wasm.MemoryInstance{Min: 1, Buffer: buf},
-	})
-	ret.System = sys
-	return ret
+	}, sys)
+}
+
+func newSysContext(args, environ []string, openedFiles map[uint32]*wasm.FileEntry) (sys *wasm.SysContext, err error) {
+	return wasm.NewSysContext(math.MaxUint32, args, environ, new(bytes.Buffer), nil, nil, openedFiles)
 }
diff --git a/tests/engine/adhoc_test.go b/tests/engine/adhoc_test.go
index cbb10c96..43d99818 100644
--- a/tests/engine/adhoc_test.go
+++ b/tests/engine/adhoc_test.go
@@ -59,6 +59,7 @@ func testHugeStack(t *testing.T, newRuntimeConfig func() *wazero.RuntimeConfig)
 	r := wazero.NewRuntimeWithConfig(newRuntimeConfig())
 	module, err := r.InstantiateModuleFromSource(hugestackWasm)
 	require.NoError(t, err)
+	defer module.Close()
 
 	fn := module.ExportedFunction("main")
 	require.NotNil(t, fn)
@@ -79,6 +80,7 @@ func testUnreachable(t *testing.T, newRuntimeConfig func() *wazero.RuntimeConfig
 
 	module, err := r.InstantiateModuleFromSource(unreachableWasm)
 	require.NoError(t, err)
+	defer module.Close()
 
 	_, err = module.ExportedFunction("main").Call(nil)
 	exp := `wasm runtime error: panic in host function
@@ -103,6 +105,7 @@ func testRecursiveEntry(t *testing.T, newRuntimeConfig func() *wazero.RuntimeCon
 
 	module, err := r.InstantiateModuleFromSource(recursiveWasm)
 	require.NoError(t, err)
+	defer module.Close()
 
 	_, err = module.ExportedFunction("main").Call(nil, 1)
 	require.NoError(t, err)
@@ -135,6 +138,7 @@ func testImportedAndExportedFunc(t *testing.T, newRuntimeConfig func() *wazero.R
 		(export "store_int" (func $store_int))
 		)`))
 	require.NoError(t, err)
+	defer module.Close()
 
 	// Call store_int and ensure it didn't return an error code.
 	results, err := module.ExportedFunction("store_int").Call(nil, 1, math.MaxUint64)
@@ -211,6 +215,7 @@ func testHostFunctions(t *testing.T, newRuntimeConfig func() *wazero.RuntimeConf
 	(export "call->test.identity_f64" (func $call->test.identity_f64))
 )`))
 		require.NoError(t, err)
+		defer m.Close()
 
 		t.Run(fmt.Sprintf("host function with f32 param%s", k), func(t *testing.T) {
 			name := "call->test.identity_f32"
diff --git a/tests/spectest/spec_test.go b/tests/spectest/spec_test.go
index 88719872..9d1f8910 100644
--- a/tests/spectest/spec_test.go
+++ b/tests/spectest/spec_test.go
@@ -267,7 +267,7 @@ func addSpectestModule(t *testing.T, store *wasm.Store) {
 		},
 	}
 	require.NoError(t, mod.Validate(wasm.Features20191205))
-	_, err := store.Instantiate(context.Background(), mod, "spectest")
+	_, err := store.Instantiate(context.Background(), mod, "spectest", nil)
 	require.NoError(t, err)
 }
 
@@ -335,7 +335,7 @@ func runTest(t *testing.T, newEngine func() wasm.Engine) {
 							}
 						}
 						moduleName = strings.TrimPrefix(moduleName, "$")
-						_, err = store.Instantiate(context.Background(), mod, moduleName)
+						_, err = store.Instantiate(context.Background(), mod, moduleName, nil)
 						lastInstantiatedModuleName = moduleName
 						require.NoError(t, err)
 					case "register":
@@ -458,7 +458,7 @@ func requireInstantiationError(t *testing.T, store *wasm.Store, buf []byte, msg
 		return
 	}
 
-	_, err = store.Instantiate(context.Background(), mod, t.Name())
+	_, err = store.Instantiate(context.Background(), mod, t.Name(), nil)
 	require.Error(t, err, msg)
 }
 
diff --git a/wasi_test.go b/wasi_test.go
index f52154ce..f10b1ded 100644
--- a/wasi_test.go
+++ b/wasi_test.go
@@ -52,8 +52,8 @@ func TestStartWASICommandWithConfig(t *testing.T) {
 
 	stdout := bytes.NewBuffer(nil)
 
-	// Configure WASI with baseline config
-	config := NewWASIConfig().WithStdout(stdout)
+	// Configure WASI to write stdout to a buffer, so that we can verify it later.
+	sys := NewSysConfig().WithStdout(stdout)
 	wasi, err := r.InstantiateModule(WASISnapshotPreview1())
 	require.NoError(t, err)
 	defer wasi.Close()
@@ -63,7 +63,7 @@ func TestStartWASICommandWithConfig(t *testing.T) {
 
 	// Re-use the same module many times.
 	for _, tc := range []string{"a", "b", "c"} {
-		mod, err := StartWASICommandWithConfig(r, m.WithName(tc), config.WithArgs(tc))
+		mod, err := StartWASICommandWithConfig(r, m.WithName(tc), sys.WithArgs(tc))
 		require.NoError(t, err)
 
 		// Ensure the scoped configuration applied. As the args are null-terminated, we append zero (NUL).