Repository: tetratelabs/wazero
Pull request #520 (2022-05-02 06:24:03)

Makes ModuleConfig an interface and fixes mutability bug
This makes wazero.ModuleConfig an interface instead of a struct to
prevent it from being used incorrectly. For example, even though the
fields are not exported, someone can mistakenly instantiate this
when it is a struct, and in doing so violate internal assumptions.

This also fixes a mutability bug in the implementation.

Follow-up from #519 and the last in this series


Patch:
diff --git a/config.go b/config.go
index 4cd796ab..86203c58 100644
--- a/config.go
+++ b/config.go
@@ -23,7 +23,7 @@ import (
 type RuntimeConfig interface {
 
 	// WithFeatureBulkMemoryOperations adds instructions modify ranges of memory or table entries
-	// ("bulk-memory-operations"). This defaults to false as the feature was not finished in WebAssembly 1.0 (20191205).
+	// ("bulk-memory-operations"). This defaults to false as the feature was not finished in WebAssembly 1.0.
 	//
 	// Here are the notable effects:
 	// * Adds `memory.fill`, `memory.init`, `memory.copy` and `data.drop` instructions.
@@ -36,29 +36,29 @@ type RuntimeConfig interface {
 	// See https://github.com/WebAssembly/spec/blob/main/proposals/bulk-memory-operations/Overview.md
 	// See https://github.com/WebAssembly/spec/blob/main/proposals/reference-types/Overview.md
 	// See https://github.com/WebAssembly/spec/pull/1287
-	WithFeatureBulkMemoryOperations(enabled bool) RuntimeConfig
+	WithFeatureBulkMemoryOperations(bool) RuntimeConfig
 
-	// WithFeatureMultiValue enables multiple values ("multi-value"). This defaults to false as the feature was not finished
-	// in WebAssembly 1.0 (20191205).
+	// WithFeatureMultiValue enables multiple values ("multi-value"). This defaults to false as the feature was not
+	// finished in WebAssembly 1.0 (20191205).
 	//
 	// Here are the notable effects:
 	// * Function (`func`) types allow more than one result
 	// * Block types (`block`, `loop` and `if`) can be arbitrary function types
 	//
 	// See https://github.com/WebAssembly/spec/blob/main/proposals/multi-value/Overview.md
-	WithFeatureMultiValue(enabled bool) RuntimeConfig
+	WithFeatureMultiValue(bool) RuntimeConfig
 
 	// WithFeatureMutableGlobal allows globals to be mutable. This defaults to true as the feature was finished in
 	// WebAssembly 1.0 (20191205).
 	//
 	// When false, an api.Global can never be cast to an api.MutableGlobal, and any source that includes global vars
 	// will fail to parse.
-	WithFeatureMutableGlobal(enabled bool) RuntimeConfig
+	WithFeatureMutableGlobal(bool) RuntimeConfig
 
 	// WithFeatureNonTrappingFloatToIntConversion enables non-trapping float-to-int conversions.
-	// ("nontrapping-float-to-int-conversion"). This defaults to false as the feature was not in WebAssembly 1.0 (20191205).
+	// ("nontrapping-float-to-int-conversion"). This defaults to false as the feature was not in WebAssembly 1.0.
 	//
-	// The only effect of enabling this is allowing the following instructions, which return 0 on NaN instead of panicking.
+	// The only effect of enabling is allowing the following instructions, which return 0 on NaN instead of panicking.
 	// * `i32.trunc_sat_f32_s`
 	// * `i32.trunc_sat_f32_u`
 	// * `i32.trunc_sat_f64_s`
@@ -69,19 +69,19 @@ type RuntimeConfig interface {
 	// * `i64.trunc_sat_f64_u`
 	//
 	// See https://github.com/WebAssembly/spec/blob/main/proposals/nontrapping-float-to-int-conversion/Overview.md
-	WithFeatureNonTrappingFloatToIntConversion(enabled bool) RuntimeConfig
+	WithFeatureNonTrappingFloatToIntConversion(bool) RuntimeConfig
 
-	// WithFeatureSignExtensionOps enables sign extension instructions ("sign-extension-ops"). This defaults to false as the
-	// feature was not in WebAssembly 1.0 (20191205).
+	// WithFeatureSignExtensionOps enables sign extension instructions ("sign-extension-ops"). This defaults to false
+	// as the feature was not in WebAssembly 1.0.
 	//
 	// Here are the notable effects:
 	// * Adds instructions `i32.extend8_s`, `i32.extend16_s`, `i64.extend8_s`, `i64.extend16_s` and `i64.extend32_s`
 	//
 	// See https://github.com/WebAssembly/spec/blob/main/proposals/sign-extension-ops/Overview.md
-	WithFeatureSignExtensionOps(enabled bool) RuntimeConfig
+	WithFeatureSignExtensionOps(bool) RuntimeConfig
 
-	// WithMemoryCapacityPages is a function that determines memory capacity in pages (65536 bytes per page). The inputs are
-	// the min and possibly nil max defined by the module, and the default is to return the min.
+	// WithMemoryCapacityPages is a function that determines memory capacity in pages (65536 bytes per page). The input
+	// are the min and possibly nil max defined by the module, and the default is to return the min.
 	//
 	// Ex. To set capacity to max when exists:
 	//	c = c.WithMemoryCapacityPages(func(minPages uint32, maxPages *uint32) uint32 {
@@ -93,9 +93,9 @@ type RuntimeConfig interface {
 	//
 	// This function is used at compile time (ModuleBuilder.Build or Runtime.CompileModule). Compile will err if the
 	// function returns a value lower than minPages or greater than WithMemoryLimitPages.
-	WithMemoryCapacityPages(maxCapacityPages func(minPages uint32, maxPages *uint32) uint32) RuntimeConfig
+	WithMemoryCapacityPages(func(minPages uint32, maxPages *uint32) uint32) RuntimeConfig
 
-	// WithMemoryLimitPages limits the maximum number of pages a module can define from 65536 pages (4GiB) to a lower value.
+	// WithMemoryLimitPages limits the maximum number of pages a module can define from 65536 pages (4GiB) to the input.
 	//
 	// Notes:
 	// * If a module defines no memory max value, Runtime.CompileModule sets max to the limit.
@@ -105,9 +105,9 @@ type RuntimeConfig interface {
 	//
 	// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#grow-mem
 	// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#memory-types%E2%91%A0
-	WithMemoryLimitPages(memoryLimitPages uint32) RuntimeConfig
+	WithMemoryLimitPages(uint32) RuntimeConfig
 
-	// WithWasmCore1 enables features included in the WebAssembly Core Specification 1.0 (20191205). Selecting this
+	// WithWasmCore1 enables features included in the WebAssembly Core Specification 1.0. Selecting this
 	// overwrites any currently accumulated features with only those included in this W3C recommendation.
 	//
 	// This is default because as of mid 2022, this is the only version that is a Web Standard (W3C Recommendation).
@@ -145,66 +145,56 @@ var engineLessConfig = &runtimeConfig{
 	memoryCapacityPages: func(minPages uint32, maxPages *uint32) uint32 { return minPages },
 }
 
-// clone ensures all fields are copied even if nil.
-func (c *runtimeConfig) clone() *runtimeConfig {
-	return &runtimeConfig{
-		enabledFeatures:     c.enabledFeatures,
-		newEngine:           c.newEngine,
-		memoryLimitPages:    c.memoryLimitPages,
-		memoryCapacityPages: c.memoryCapacityPages,
-	}
-}
-
 // NewRuntimeConfigJIT compiles WebAssembly modules into runtime.GOARCH-specific assembly for optimal performance.
 //
 // Note: This panics at runtime the runtime.GOOS or runtime.GOARCH does not support JIT. Use NewRuntimeConfig to safely
 // detect and fallback to NewRuntimeConfigInterpreter if needed.
 func NewRuntimeConfigJIT() RuntimeConfig {
-	ret := engineLessConfig.clone()
+	ret := *engineLessConfig // copy
 	ret.newEngine = jit.NewEngine
-	return ret
+	return &ret
 }
 
 // NewRuntimeConfigInterpreter interprets WebAssembly modules instead of compiling them into assembly.
 func NewRuntimeConfigInterpreter() RuntimeConfig {
-	ret := engineLessConfig.clone()
+	ret := *engineLessConfig // copy
 	ret.newEngine = interpreter.NewEngine
-	return ret
+	return &ret
 }
 
 // WithFeatureBulkMemoryOperations implements RuntimeConfig.WithFeatureBulkMemoryOperations
 func (c *runtimeConfig) WithFeatureBulkMemoryOperations(enabled bool) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = ret.enabledFeatures.Set(wasm.FeatureBulkMemoryOperations, enabled)
-	return ret
+	return &ret
 }
 
 // WithFeatureMultiValue implements RuntimeConfig.WithFeatureMultiValue
 func (c *runtimeConfig) WithFeatureMultiValue(enabled bool) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = ret.enabledFeatures.Set(wasm.FeatureMultiValue, enabled)
-	return ret
+	return &ret
 }
 
 // WithFeatureMutableGlobal implements RuntimeConfig.WithFeatureMutableGlobal
 func (c *runtimeConfig) WithFeatureMutableGlobal(enabled bool) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = ret.enabledFeatures.Set(wasm.FeatureMutableGlobal, enabled)
-	return ret
+	return &ret
 }
 
 // WithFeatureNonTrappingFloatToIntConversion implements RuntimeConfig.WithFeatureNonTrappingFloatToIntConversion
 func (c *runtimeConfig) WithFeatureNonTrappingFloatToIntConversion(enabled bool) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = ret.enabledFeatures.Set(wasm.FeatureNonTrappingFloatToIntConversion, enabled)
-	return ret
+	return &ret
 }
 
 // WithFeatureSignExtensionOps implements RuntimeConfig.WithFeatureSignExtensionOps
 func (c *runtimeConfig) WithFeatureSignExtensionOps(enabled bool) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = ret.enabledFeatures.Set(wasm.FeatureSignExtensionOps, enabled)
-	return ret
+	return &ret
 }
 
 // WithMemoryCapacityPages implements RuntimeConfig.WithMemoryCapacityPages
@@ -212,33 +202,33 @@ func (c *runtimeConfig) WithMemoryCapacityPages(maxCapacityPages func(minPages u
 	if maxCapacityPages == nil {
 		return c // Instead of erring.
 	}
-	ret := c.clone()
+	ret := *c // copy
 	ret.memoryCapacityPages = maxCapacityPages
-	return ret
+	return &ret
 }
 
 // WithMemoryLimitPages implements RuntimeConfig.WithMemoryLimitPages
 func (c *runtimeConfig) WithMemoryLimitPages(memoryLimitPages uint32) RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.memoryLimitPages = memoryLimitPages
-	return ret
+	return &ret
 }
 
 // WithWasmCore1 implements RuntimeConfig.WithWasmCore1
 func (c *runtimeConfig) WithWasmCore1() RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = wasm.Features20191205
-	return ret
+	return &ret
 }
 
 // WithWasmCore2 implements RuntimeConfig.WithWasmCore2
 func (c *runtimeConfig) WithWasmCore2() RuntimeConfig {
-	ret := c.clone()
+	ret := *c // copy
 	ret.enabledFeatures = wasm.Features20220419
-	return ret
+	return &ret
 }
 
-// CompiledCode is a WebAssembly 1.0 (20191205) module ready to be instantiated (Runtime.InstantiateModule) as an
+// CompiledCode is a WebAssembly 1.0 module ready to be instantiated (Runtime.InstantiateModule) as an
 // api.Module.
 //
 // Note: In WebAssembly language, this is a decoded, validated, and possibly also compiled module. wazero avoids using
@@ -273,9 +263,159 @@ func (c *compiledCode) Close(_ context.Context) error {
 // Note: While wazero supports Windows as a platform, host functions using ModuleConfig follow a UNIX dialect.
 // See RATIONALE.md for design background and relationship to WebAssembly System Interfaces (WASI).
 //
-// TODO: This is accidentally mutable. A follow-up PR should change it to be immutable as that's how baseline
-// configuration can be used safely in modules instantiated on different goroutines.
-type ModuleConfig struct {
+// Note: ModuleConfig is immutable. Each WithXXX function returns a new instance including the corresponding change.
+type ModuleConfig interface {
+
+	// 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_snapshot_preview1" 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
+	WithArgs(...string) ModuleConfig
+
+	// 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_snapshot_preview1" 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
+	WithEnv(key, value string) ModuleConfig
+
+	// WithFS assigns the file system to use for any paths beginning at "/". Defaults to not found.
+	//
+	// Ex. This sets a read-only, embedded file-system to serve files under the root ("/") and working (".") directories:
+	//
+	//	//go:embed testdata/index.html
+	//	var testdataIndex embed.FS
+	//
+	//	rooted, err := fs.Sub(testdataIndex, "testdata")
+	//	require.NoError(t, err)
+	//
+	//	// "index.html" is accessible as both "/index.html" and "./index.html" because we didn't use WithWorkDirFS.
+	//	config := wazero.NewModuleConfig().WithFS(rooted)
+	//
+	// Note: This sets WithWorkDirFS to the same file-system unless already set.
+	WithFS(fs.FS) ModuleConfig
+
+	// WithImport replaces a specific import module and name with a new one. This allows you to break up a monolithic
+	// module imports, such as "env". This can also help reduce cyclic dependencies.
+	//
+	// For example, if a module was compiled with one module owning all imports:
+	//	(import "js" "tbl" (table $tbl 4 funcref))
+	//	(import "js" "increment" (func $increment (result i32)))
+	//	(import "js" "decrement" (func $decrement (result i32)))
+	//	(import "js" "wasm_increment" (func $wasm_increment (result i32)))
+	//	(import "js" "wasm_decrement" (func $wasm_decrement (result i32)))
+	//
+	// Use this function to import "increment" and "decrement" from the module "go" and other imports from "wasm":
+	//	config.WithImportModule("js", "wasm")
+	//	config.WithImport("wasm", "increment", "go", "increment")
+	//	config.WithImport("wasm", "decrement", "go", "decrement")
+	//
+	// Upon instantiation, imports resolve as if they were compiled like so:
+	//	(import "wasm" "tbl" (table $tbl 4 funcref))
+	//	(import "go" "increment" (func $increment (result i32)))
+	//	(import "go" "decrement" (func $decrement (result i32)))
+	//	(import "wasm" "wasm_increment" (func $wasm_increment (result i32)))
+	//	(import "wasm" "wasm_decrement" (func $wasm_decrement (result i32)))
+	//
+	// Note: Any WithImport instructions happen in order, after any WithImportModule instructions.
+	WithImport(oldModule, oldName, newModule, newName string) ModuleConfig
+
+	// WithImportModule replaces every import with oldModule with newModule. This is helpful for modules who have
+	// transitioned to a stable status since the underlying wasm was compiled.
+	//
+	// For example, if a module was compiled like below, with an old module for WASI:
+	//	(import "wasi_unstable" "args_get" (func (param i32, i32) (result i32)))
+	//
+	// Use this function to update it to the current version:
+	//	config.WithImportModule("wasi_unstable", wasi.ModuleSnapshotPreview1)
+	//
+	// See WithImport for a comprehensive example.
+	// Note: Any WithImportModule instructions happen in order, before any WithImport instructions.
+	WithImportModule(oldModule, newModule string) ModuleConfig
+
+	// WithName configures the module name. Defaults to what was decoded from the module source.
+	//
+	// If the source was in WebAssembly 1.0 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
+	WithName(string) ModuleConfig
+
+	// WithStartFunctions configures the functions to call after the module is instantiated. Defaults to "_start".
+	//
+	// Note: If any function doesn't exist, it is skipped. However, all functions that do exist are called in order.
+	WithStartFunctions(...string) ModuleConfig
+
+	// 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_snapshot_preview1" 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 api.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
+	WithStderr(io.Writer) ModuleConfig
+
+	// 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_snapshot_preview1" 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 api.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
+	WithStdin(io.Reader) ModuleConfig
+
+	// 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_snapshot_preview1" 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 api.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
+	WithStdout(io.Writer) ModuleConfig
+
+	// WithWorkDirFS indicates the file system to use for any paths beginning at "./". Defaults to the same as WithFS.
+	//
+	// Ex. This sets a read-only, embedded file-system as the root ("/"), and a mutable one as the working directory ("."):
+	//
+	//	//go:embed appA
+	//	var rootFS embed.FS
+	//
+	//	// Files relative to this source under appA are available under "/" and files relative to "/work/appA" under ".".
+	//	config := wazero.NewModuleConfig().WithFS(rootFS).WithWorkDirFS(os.DirFS("/work/appA"))
+	//
+	// Note: os.DirFS documentation includes important notes about isolation, which also applies to fs.Sub. As of Go 1.18,
+	// the built-in file-systems are not jailed (chroot). See https://github.com/golang/go/issues/42322
+	WithWorkDirFS(fs.FS) ModuleConfig
+}
+
+type moduleConfig struct {
 	name           string
 	startFunctions []string
 	stdin          io.Reader
@@ -300,8 +440,8 @@ type ModuleConfig struct {
 	replacedImportModules map[string]string
 }
 
-func NewModuleConfig() *ModuleConfig {
-	return &ModuleConfig{
+func NewModuleConfig() ModuleConfig {
+	return &moduleConfig{
 		startFunctions: []string{"_start"},
 		environKeys:    map[string]int{},
 		preopenFD:      uint32(3), // after stdin/stdout/stderr
@@ -310,205 +450,101 @@ func NewModuleConfig() *ModuleConfig {
 	}
 }
 
-// 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 (c *ModuleConfig) WithName(name string) *ModuleConfig {
-	c.name = name
-	return c
+// WithArgs implements ModuleConfig.WithArgs
+func (c *moduleConfig) WithArgs(args ...string) ModuleConfig {
+	ret := *c // copy
+	ret.args = args
+	return &ret
 }
 
-// WithImport replaces a specific import module and name with a new one. This allows you to break up a monolithic
-// module imports, such as "env". This can also help reduce cyclic dependencies.
-//
-// For example, if a module was compiled with one module owning all imports:
-//	(import "js" "tbl" (table $tbl 4 funcref))
-//	(import "js" "increment" (func $increment (result i32)))
-//	(import "js" "decrement" (func $decrement (result i32)))
-//	(import "js" "wasm_increment" (func $wasm_increment (result i32)))
-//	(import "js" "wasm_decrement" (func $wasm_decrement (result i32)))
-//
-// Use this function to import "increment" and "decrement" from the module "go" and other imports from "wasm":
-//	config.WithImportModule("js", "wasm")
-//	config.WithImport("wasm", "increment", "go", "increment")
-//	config.WithImport("wasm", "decrement", "go", "decrement")
-//
-// Upon instantiation, imports resolve as if they were compiled like so:
-//	(import "wasm" "tbl" (table $tbl 4 funcref))
-//	(import "go" "increment" (func $increment (result i32)))
-//	(import "go" "decrement" (func $decrement (result i32)))
-//	(import "wasm" "wasm_increment" (func $wasm_increment (result i32)))
-//	(import "wasm" "wasm_decrement" (func $wasm_decrement (result i32)))
-//
-// Note: Any WithImport instructions happen in order, after any WithImportModule instructions.
-func (c *ModuleConfig) WithImport(oldModule, oldName, newModule, newName string) *ModuleConfig {
-	if c.replacedImports == nil {
-		c.replacedImports = map[string][2]string{}
+// WithEnv implements ModuleConfig.WithEnv
+func (c *moduleConfig) WithEnv(key, value string) ModuleConfig {
+	ret := *c // copy
+	// Check to see if this key already exists and update it.
+	if i, ok := ret.environKeys[key]; ok {
+		ret.environ[i+1] = value // environ is pair-indexed, so the value is 1 after the key.
+	} else {
+		ret.environKeys[key] = len(ret.environ)
+		ret.environ = append(ret.environ, key, value)
+	}
+	return &ret
+}
+
+// WithFS implements ModuleConfig.WithFS
+func (c *moduleConfig) WithFS(fs fs.FS) ModuleConfig {
+	ret := *c // copy
+	ret.setFS("/", fs)
+	return &ret
+}
+
+// WithImport implements ModuleConfig.WithImport
+func (c *moduleConfig) WithImport(oldModule, oldName, newModule, newName string) ModuleConfig {
+	ret := *c // copy
+	if ret.replacedImports == nil {
+		ret.replacedImports = map[string][2]string{}
 	}
 	var builder strings.Builder
 	builder.WriteString(oldModule)
 	builder.WriteByte(0) // delimit with NUL as module and name can be any UTF-8 characters.
 	builder.WriteString(oldName)
-	c.replacedImports[builder.String()] = [2]string{newModule, newName}
-	return c
+	ret.replacedImports[builder.String()] = [2]string{newModule, newName}
+	return &ret
 }
 
-// WithImportModule replaces every import with oldModule with newModule. This is helpful for modules who have
-// transitioned to a stable status since the underlying wasm was compiled.
-//
-// For example, if a module was compiled like below, with an old module for WASI:
-//	(import "wasi_unstable" "args_get" (func (param i32, i32) (result i32)))
-//
-// Use this function to update it to the current version:
-//	config.WithImportModule("wasi_unstable", wasi.ModuleSnapshotPreview1)
-//
-// See WithImport for a comprehensive example.
-// Note: Any WithImportModule instructions happen in order, before any WithImport instructions.
-func (c *ModuleConfig) WithImportModule(oldModule, newModule string) *ModuleConfig {
-	if c.replacedImportModules == nil {
-		c.replacedImportModules = map[string]string{}
+// WithImportModule implements ModuleConfig.WithImportModule
+func (c *moduleConfig) WithImportModule(oldModule, newModule string) ModuleConfig {
+	ret := *c // copy
+	if ret.replacedImportModules == nil {
+		ret.replacedImportModules = map[string]string{}
 	}
-	c.replacedImportModules[oldModule] = newModule
-	return c
-}
-
-// WithStartFunctions configures the functions to call after the module is instantiated. Defaults to "_start".
-//
-// Note: If any function doesn't exist, it is skipped. However, all functions that do exist are called in order.
-func (c *ModuleConfig) WithStartFunctions(startFunctions ...string) *ModuleConfig {
-	c.startFunctions = startFunctions
-	return c
-}
-
-// 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_snapshot_preview1" 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 api.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 *ModuleConfig) WithStdin(stdin io.Reader) *ModuleConfig {
-	c.stdin = stdin
-	return c
+	ret.replacedImportModules[oldModule] = newModule
+	return &ret
 }
 
-// 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_snapshot_preview1" 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 api.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 *ModuleConfig) WithStdout(stdout io.Writer) *ModuleConfig {
-	c.stdout = stdout
-	return c
+// WithName implements ModuleConfig.WithName
+func (c *moduleConfig) WithName(name string) ModuleConfig {
+	ret := *c // copy
+	ret.name = name
+	return &ret
 }
 
-// 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_snapshot_preview1" 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 api.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 *ModuleConfig) WithStderr(stderr io.Writer) *ModuleConfig {
-	c.stderr = stderr
-	return c
+// WithStartFunctions implements ModuleConfig.WithStartFunctions
+func (c *moduleConfig) WithStartFunctions(startFunctions ...string) ModuleConfig {
+	ret := *c // copy
+	ret.startFunctions = startFunctions
+	return &ret
 }
 
-// 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_snapshot_preview1" 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 *ModuleConfig) WithArgs(args ...string) *ModuleConfig {
-	c.args = args
-	return c
+// WithStderr implements ModuleConfig.WithStderr
+func (c *moduleConfig) WithStderr(stderr io.Writer) ModuleConfig {
+	ret := *c // copy
+	ret.stderr = stderr
+	return &ret
 }
 
-// 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_snapshot_preview1" 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 *ModuleConfig) WithEnv(key, value string) *ModuleConfig {
-	// 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
+// WithStdin implements ModuleConfig.WithStdin
+func (c *moduleConfig) WithStdin(stdin io.Reader) ModuleConfig {
+	ret := *c // copy
+	ret.stdin = stdin
+	return &ret
 }
 
-// WithFS assigns the file system to use for any paths beginning at "/". Defaults to not found.
-//
-// Ex. This sets a read-only, embedded file-system to serve files under the root ("/") and working (".") directories:
-//
-//	//go:embed testdata/index.html
-//	var testdataIndex embed.FS
-//
-//	rooted, err := fs.Sub(testdataIndex, "testdata")
-//	require.NoError(t, err)
-//
-//	// "index.html" is accessible as both "/index.html" and "./index.html" because we didn't use WithWorkDirFS.
-//	config := wazero.NewModuleConfig().WithFS(rooted)
-//
-// Note: This sets WithWorkDirFS to the same file-system unless already set.
-func (c *ModuleConfig) WithFS(fs fs.FS) *ModuleConfig {
-	c.setFS("/", fs)
-	return c
+// WithStdout implements ModuleConfig.WithStdout
+func (c *moduleConfig) WithStdout(stdout io.Writer) ModuleConfig {
+	ret := *c // copy
+	ret.stdout = stdout
+	return &ret
 }
 
-// WithWorkDirFS indicates the file system to use for any paths beginning at "./". Defaults to the same as WithFS.
-//
-// Ex. This sets a read-only, embedded file-system as the root ("/"), and a mutable one as the working directory ("."):
-//
-//	//go:embed appA
-//	var rootFS embed.FS
-//
-//	// Files relative to this source under appA are available under "/" and files relative to "/work/appA" under ".".
-//	config := wazero.NewModuleConfig().WithFS(rootFS).WithWorkDirFS(os.DirFS("/work/appA"))
-//
-// Note: os.DirFS documentation includes important notes about isolation, which also applies to fs.Sub. As of Go 1.18,
-// the built-in file-systems are not jailed (chroot). See https://github.com/golang/go/issues/42322
-func (c *ModuleConfig) WithWorkDirFS(fs fs.FS) *ModuleConfig {
-	c.setFS(".", fs)
-	return c
+// WithWorkDirFS implements ModuleConfig.WithWorkDirFS
+func (c *moduleConfig) WithWorkDirFS(fs fs.FS) ModuleConfig {
+	ret := *c // copy
+	ret.setFS(".", fs)
+	return &ret
 }
 
 // setFS maps a path to a file-system. This is only used for base paths: "/" and ".".
-func (c *ModuleConfig) setFS(path string, fs fs.FS) {
+func (c *moduleConfig) setFS(path string, fs fs.FS) {
 	// Check to see if this key already exists and update it.
 	entry := &wasm.FileEntry{Path: path, FS: fs}
 	if fd, ok := c.preopenPaths[path]; ok {
@@ -521,7 +557,7 @@ func (c *ModuleConfig) setFS(path string, fs fs.FS) {
 }
 
 // toSysContext creates a baseline wasm.SysContext configured by ModuleConfig.
-func (c *ModuleConfig) toSysContext() (sys *wasm.SysContext, err error) {
+func (c *moduleConfig) toSysContext() (sys *wasm.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 {
@@ -562,7 +598,7 @@ func (c *ModuleConfig) toSysContext() (sys *wasm.SysContext, err error) {
 	return wasm.NewSysContext(math.MaxUint32, c.args, environ, c.stdin, c.stdout, c.stderr, preopens)
 }
 
-func (c *ModuleConfig) replaceImports(module *wasm.Module) *wasm.Module {
+func (c *moduleConfig) replaceImports(module *wasm.Module) *wasm.Module {
 	if (c.replacedImportModules == nil && c.replacedImports == nil) || module.ImportSection == nil {
 		return module
 	}
diff --git a/wasm.go b/wasm.go
index 9d1ca33a..55596921 100644
--- a/wasm.go
+++ b/wasm.go
@@ -76,7 +76,7 @@ type Runtime interface {
 	//	defer wasm.Close()
 	//
 	// Note: When the context is nil, it defaults to context.Background.
-	InstantiateModuleFromCodeWithConfig(ctx context.Context, source []byte, config *ModuleConfig) (api.Module, error)
+	InstantiateModuleFromCodeWithConfig(ctx context.Context, source []byte, config ModuleConfig) (api.Module, error)
 
 	// InstantiateModule instantiates the module namespace or errs if the configuration was invalid.
 	//
@@ -114,7 +114,7 @@ type Runtime interface {
 	//
 	// Note: When the context is nil, it defaults to context.Background.
 	// Note: Config is copied during instantiation: Later changes to config do not affect the instantiated result.
-	InstantiateModuleWithConfig(ctx context.Context, compiled CompiledCode, config *ModuleConfig) (mod api.Module, err error)
+	InstantiateModuleWithConfig(ctx context.Context, compiled CompiledCode, config ModuleConfig) (api.Module, error)
 }
 
 func NewRuntime() Runtime {
@@ -217,7 +217,7 @@ func (r *runtime) InstantiateModuleFromCode(ctx context.Context, source []byte)
 }
 
 // InstantiateModuleFromCodeWithConfig implements Runtime.InstantiateModuleFromCodeWithConfig
-func (r *runtime) InstantiateModuleFromCodeWithConfig(ctx context.Context, source []byte, config *ModuleConfig) (api.Module, error) {
+func (r *runtime) InstantiateModuleFromCodeWithConfig(ctx context.Context, source []byte, config ModuleConfig) (api.Module, error) {
 	if compiled, err := r.CompileModule(ctx, source); err != nil {
 		return nil, err
 	} else {
@@ -233,17 +233,22 @@ func (r *runtime) InstantiateModule(ctx context.Context, compiled CompiledCode)
 }
 
 // InstantiateModuleWithConfig implements Runtime.InstantiateModuleWithConfig
-func (r *runtime) InstantiateModuleWithConfig(ctx context.Context, compiled CompiledCode, config *ModuleConfig) (mod api.Module, err error) {
-	var sysCtx *wasm.SysContext
-	if sysCtx, err = config.toSysContext(); err != nil {
-		return
-	}
-
+func (r *runtime) InstantiateModuleWithConfig(ctx context.Context, compiled CompiledCode, mConfig ModuleConfig) (mod api.Module, err error) {
 	code, ok := compiled.(*compiledCode)
 	if !ok {
 		panic(fmt.Errorf("unsupported wazero.CompiledCode implementation: %#v", compiled))
 	}
 
+	config, ok := mConfig.(*moduleConfig)
+	if !ok {
+		panic(fmt.Errorf("unsupported wazero.ModuleConfig implementation: %#v", mConfig))
+	}
+
+	var sysCtx *wasm.SysContext
+	if sysCtx, err = config.toSysContext(); err != nil {
+		return
+	}
+
 	name := config.name
 	if name == "" && code.module.NameSection != nil && code.module.NameSection.ModuleName != "" {
 		name = code.module.NameSection.ModuleName


Test patch:
diff --git a/config_test.go b/config_test.go
index 3523190d..176f25fa 100644
--- a/config_test.go
+++ b/config_test.go
@@ -195,87 +195,87 @@ func TestRuntimeConfig_FeatureToggle(t *testing.T) {
 func TestModuleConfig(t *testing.T) {
 	tests := []struct {
 		name     string
-		with     func(*ModuleConfig) *ModuleConfig
-		expected *ModuleConfig
+		with     func(ModuleConfig) ModuleConfig
+		expected ModuleConfig
 	}{
 		{
 			name: "WithName",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithName("wazero")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				name: "wazero",
 			},
 		},
 		{
 			name: "WithName - empty",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithName("")
 			},
-			expected: &ModuleConfig{},
+			expected: &moduleConfig{},
 		},
 		{
 			name: "WithImport",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "abort", "assemblyscript", "abort")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"assemblyscript", "abort"}},
 			},
 		},
 		{
 			name: "WithImport - empty to non-empty - module",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("", "abort", "assemblyscript", "abort")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"\000abort": {"assemblyscript", "abort"}},
 			},
 		},
 		{
 			name: "WithImport - non-empty to empty - module",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "abort", "", "abort")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"", "abort"}},
 			},
 		},
 		{
 			name: "WithImport - empty to non-empty - name",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "", "assemblyscript", "abort")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000": {"assemblyscript", "abort"}},
 			},
 		},
 		{
 			name: "WithImport - non-empty to empty - name",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "abort", "assemblyscript", "")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"assemblyscript", ""}},
 			},
 		},
 		{
 			name: "WithImport - override",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "abort", "assemblyscript", "abort").
 					WithImport("env", "abort", "go", "exit")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"go", "exit"}},
 			},
 		},
 		{
 			name: "WithImport - twice",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImport("env", "abort", "assemblyscript", "abort").
 					WithImport("wasi_unstable", "proc_exit", "wasi_snapshot_preview1", "proc_exit")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImports: map[string][2]string{
 					"env\000abort":               {"assemblyscript", "abort"},
 					"wasi_unstable\000proc_exit": {"wasi_snapshot_preview1", "proc_exit"},
@@ -284,48 +284,48 @@ func TestModuleConfig(t *testing.T) {
 		},
 		{
 			name: "WithImportModule",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImportModule("env", "assemblyscript")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImportModules: map[string]string{"env": "assemblyscript"},
 			},
 		},
 		{
 			name: "WithImportModule - empty to non-empty",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImportModule("", "assemblyscript")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImportModules: map[string]string{"": "assemblyscript"},
 			},
 		},
 		{
 			name: "WithImportModule - non-empty to empty",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImportModule("env", "")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImportModules: map[string]string{"env": ""},
 			},
 		},
 		{
 			name: "WithImportModule - override",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImportModule("env", "assemblyscript").
 					WithImportModule("env", "go")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImportModules: map[string]string{"env": "go"},
 			},
 		},
 		{
 			name: "WithImportModule - twice",
-			with: func(c *ModuleConfig) *ModuleConfig {
+			with: func(c ModuleConfig) ModuleConfig {
 				return c.WithImportModule("env", "go").
 					WithImportModule("wasi_unstable", "wasi_snapshot_preview1")
 			},
-			expected: &ModuleConfig{
+			expected: &moduleConfig{
 				replacedImportModules: map[string]string{
 					"env":           "go",
 					"wasi_unstable": "wasi_snapshot_preview1",
@@ -337,9 +337,11 @@ func TestModuleConfig(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			input := &ModuleConfig{}
+			input := &moduleConfig{}
 			rc := tc.with(input)
 			require.Equal(t, tc.expected, rc)
+			// The source wasn't modified
+			require.Equal(t, &moduleConfig{}, input)
 		})
 	}
 }
@@ -347,21 +349,21 @@ func TestModuleConfig(t *testing.T) {
 func TestModuleConfig_replaceImports(t *testing.T) {
 	tests := []struct {
 		name       string
-		config     *ModuleConfig
+		config     ModuleConfig
 		input      *wasm.Module
 		expected   *wasm.Module
 		expectSame bool
 	}{
 		{
 			name:       "no config, no imports",
-			config:     &ModuleConfig{},
+			config:     &moduleConfig{},
 			input:      &wasm.Module{},
 			expected:   &wasm.Module{},
 			expectSame: true,
 		},
 		{
 			name:   "no config",
-			config: &ModuleConfig{},
+			config: &moduleConfig{},
 			input: &wasm.Module{
 				ImportSection: []*wasm.Import{
 					{
@@ -380,7 +382,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		},
 		{
 			name: "replacedImportModules",
-			config: &ModuleConfig{
+			config: &moduleConfig{
 				replacedImportModules: map[string]string{"wasi_unstable": "wasi_snapshot_preview1"},
 			},
 			input: &wasm.Module{
@@ -414,7 +416,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		},
 		{
 			name: "replacedImportModules doesn't match",
-			config: &ModuleConfig{
+			config: &moduleConfig{
 				replacedImportModules: map[string]string{"env": ""},
 			},
 			input: &wasm.Module{
@@ -435,7 +437,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		},
 		{
 			name: "replacedImports",
-			config: &ModuleConfig{
+			config: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"assemblyscript", "abort"}},
 			},
 			input: &wasm.Module{
@@ -469,7 +471,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		},
 		{
 			name: "replacedImports don't match",
-			config: &ModuleConfig{
+			config: &moduleConfig{
 				replacedImports: map[string][2]string{"env\000abort": {"assemblyscript", "abort"}},
 			},
 			input: &wasm.Module{
@@ -490,7 +492,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		},
 		{
 			name: "replacedImportModules and replacedImports",
-			config: &ModuleConfig{
+			config: &moduleConfig{
 				replacedImportModules: map[string]string{"js": "wasm"},
 				replacedImports: map[string][2]string{
 					"wasm\000increment": {"go", "increment"},
@@ -561,7 +563,7 @@ func TestModuleConfig_replaceImports(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			actual := tc.config.replaceImports(tc.input)
+			actual := tc.config.(*moduleConfig).replaceImports(tc.input)
 			if tc.expectSame {
 				require.Same(t, tc.input, actual)
 			} else {
@@ -578,7 +580,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 
 	tests := []struct {
 		name     string
-		input    *ModuleConfig
+		input    ModuleConfig
 		expected *wasm.SysContext
 	}{
 		{
@@ -783,7 +785,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sys, err := tc.input.toSysContext()
+			sys, err := tc.input.(*moduleConfig).toSysContext()
 			require.NoError(t, err)
 			require.Equal(t, tc.expected, sys)
 		})
@@ -793,7 +795,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 func TestModuleConfig_toSysContext_Errors(t *testing.T) {
 	tests := []struct {
 		name        string
-		input       *ModuleConfig
+		input       ModuleConfig
 		expectedErr string
 	}{
 		{
@@ -836,7 +838,7 @@ func TestModuleConfig_toSysContext_Errors(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			_, err := tc.input.toSysContext()
+			_, err := tc.input.(*moduleConfig).toSysContext()
 			require.EqualError(t, err, tc.expectedErr)
 		})
 	}
diff --git a/wasm_test.go b/wasm_test.go
index 58d6ef14..c396fcc0 100644
--- a/wasm_test.go
+++ b/wasm_test.go
@@ -477,6 +477,21 @@ func TestInstantiateModuleWithConfig_PanicsOnWrongCompiledCodeImpl(t *testing.T)
 	require.EqualError(t, err, "unsupported wazero.CompiledCode implementation: <nil>")
 }
 
+func TestInstantiateModuleWithConfig_PanicsOnWrongModuleConfigImpl(t *testing.T) {
+	r := NewRuntime()
+	code, err := r.CompileModule(testCtx, []byte(`(module)`))
+	require.NoError(t, err)
+	defer code.Close(testCtx)
+
+	// It causes maintenance to define an impl of ModuleConfig in tests just to verify the error when it is wrong.
+	// Instead, we pass nil which is implicitly the wrong type, as that's less work!
+	err = require.CapturePanic(func() {
+		_, _ = r.InstantiateModuleWithConfig(testCtx, code, nil)
+	})
+
+	require.EqualError(t, err, "unsupported wazero.ModuleConfig implementation: <nil>")
+}
+
 // TestInstantiateModuleWithConfig_WithName tests that we can pre-validate (cache) a module and instantiate it under
 // different names. This pattern is used in wapc-go.
 func TestInstantiateModuleWithConfig_WithName(t *testing.T) {