Repository: tetratelabs/wazero
Pull request #1216 (2023-03-09 04:02:50)

doc: adds 'How function call works'

mathetake: @evacchi this is something you might be interested in 😎 
codefromthecrypt: https://deploy-preview-1216--wazero.netlify.app/docs/how_function_call_works/


Patch:
diff --git a/Makefile b/Makefile
index 37ab9900..1e275ed7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,10 @@
 
-gofumpt := mvdan.cc/gofumpt@v0.4.0
-gosimports := github.com/rinchsan/gosimports/cmd/gosimports@v0.3.5
-golangci_lint := github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1
-asmfmt := github.com/klauspost/asmfmt/cmd/asmfmt@v1.3.2
+gofumpt       := mvdan.cc/gofumpt@v0.4.0
+gosimports    := github.com/rinchsan/gosimports/cmd/gosimports@v0.3.7
+golangci_lint := github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2
+asmfmt        := github.com/klauspost/asmfmt/cmd/asmfmt@v1.3.2
 # sync this with netlify.toml!
-hugo          := github.com/gohugoio/hugo@v0.110.0
+hugo          := github.com/gohugoio/hugo@v0.111.2
 
 # Make 3.81 doesn't support '**' globbing: Set explicitly instead of recursion.
 all_sources   := $(wildcard *.go */*.go */*/*.go */*/*/*.go */*/*/*.go */*/*/*/*.go)
diff --git a/builder.go b/builder.go
index dd3e01f8..6093fdd1 100644
--- a/builder.go
+++ b/builder.go
@@ -328,6 +328,13 @@ func (b *hostModuleBuilder) Compile(ctx context.Context) (CompiledModule, error)
 		return nil, err
 	}
 
+	// typeIDs are static and compile-time known.
+	typeIDs, err := b.r.store.GetFunctionTypeIDs(module.TypeSection)
+	if err != nil {
+		return nil, err
+	}
+	c.typeIDs = typeIDs
+
 	return c, nil
 }
 
diff --git a/config.go b/config.go
index df4fa8f3..e66c0b85 100644
--- a/config.go
+++ b/config.go
@@ -333,6 +333,7 @@ type compiledModule struct {
 	compiledEngine wasm.Engine
 	// closeWithModule prevents leaking compiled code when a module is compiled implicitly.
 	closeWithModule bool
+	typeIDs         []wasm.FunctionTypeID
 }
 
 // Name implements CompiledModule.Name
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index b8b94980..2fdb95f6 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -435,70 +435,38 @@ func fdFilestatSetTimesFn(_ context.Context, mod api.Module, params []uint64) Er
 		return ErrnoBadf
 	}
 
-	// Unchanging a part of time spec while executing utimes is extremely complex to add support for all platforms,
-	// and actually there's an outstanding issue on Go
-	// - https://github.com/golang/go/issues/32558.
-	// - https://go-review.googlesource.com/c/go/+/219638 (unmerged)
-	//
-	// Here, we emulate the behavior for empty flag (meaning "do not change") by get the current time stamp
-	// by explicitly executing File.Stat() prior to UtimesNano.
-	var atime, mtime int64
-	var nowAtime, statAtime, nowMtime, statMtime bool
+	// times[0] == atim, times[1] == mtim
+	times := [2]syscall.Timespec{}
+
+	// coerce atim into a timespec
 	if set, now := fstFlags&FileStatAdjustFlagsAtim != 0, fstFlags&FileStatAdjustFlagsAtimNow != 0; set && now {
 		return ErrnoInval
 	} else if set {
-		atime = int64(params[1])
+		times[0] = syscall.NsecToTimespec(int64(params[1]))
 	} else if now {
-		nowAtime = true
+		times[0].Nsec = platform.UTIME_NOW
 	} else {
-		statAtime = true
+		times[0].Nsec = platform.UTIME_OMIT
 	}
+
+	// coerce mtim into a timespec
 	if set, now := fstFlags&FileStatAdjustFlagsMtim != 0, fstFlags&FileStatAdjustFlagsMtimNow != 0; set && now {
 		return ErrnoInval
 	} else if set {
-		mtime = int64(params[2])
+		times[1] = syscall.NsecToTimespec(int64(params[2]))
 	} else if now {
-		nowMtime = true
+		times[1].Nsec = platform.UTIME_NOW
 	} else {
-		statMtime = true
-	}
-
-	// Handle if either parameter should be now.
-	if nowAtime || nowMtime {
-		now := sys.WalltimeNanos()
-		if nowAtime {
-			atime = now
-		}
-		if nowMtime {
-			mtime = now
-		}
-	}
-
-	// Handle if either parameter should be taken from stat.
-	if statAtime || statMtime {
-		if statAtime && statMtime {
-			return ErrnoSuccess // no change
-		}
-		// Get the current timestamp via Stat in order to un-change after calling FS.UtimesNano().
-		var st platform.Stat_t
-		if err := f.Stat(&st); err != nil {
-			return ToErrno(err)
-		}
-		if statAtime {
-			atime = st.Atim
-		}
-		if statMtime {
-			mtime = st.Mtim
-		}
+		times[1].Nsec = platform.UTIME_OMIT
 	}
 
 	// Try to update the file timestamps by file-descriptor.
-	err := platform.UtimesNanoFile(f.File, atime, mtime)
+	err := platform.UtimensFile(f.File, &times)
 
 	// Fall back to path based, despite it being less precise.
 	switch err {
 	case syscall.EPERM, syscall.ENOSYS:
-		err = f.FS.UtimesNano(f.Name, atime, mtime)
+		err = f.FS.Utimens(f.Name, &times, true)
 	}
 
 	return ToErrno(err)
diff --git a/internal/engine/compiler/RATIONALE.md b/internal/engine/compiler/RATIONALE.md
index ceb04b23..0fcea3a7 100644
--- a/internal/engine/compiler/RATIONALE.md
+++ b/internal/engine/compiler/RATIONALE.md
@@ -75,11 +75,3 @@ and as of Go 1.20, these assembler functions are considered as _unsafe_ for asyn
 
 From the Go runtime point of view, the execution of runtime-generated machine codes is considered as a part of
 that trampoline function. Therefore, runtime-generated machine code is also correctly considered unsafe for async preemption.
-
-## How to achieve function calls
-
-Given that we cannot use `call` instruction at all in native code, here's how
-we achieve the function calls back and forth among Go and (compiled) Wasm
-native functions.
-
-TODO:
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 03b81ca3..f33891d3 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -513,7 +513,7 @@ func (jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{
 
 // jsfsUtimes implements jsFn for the following
 //
-//	_, err := fsCall("utimes", path, atime, mtime) // syscall.UtimesNano
+//	_, err := fsCall("utimes", path, atime, mtime) // syscall.Utimens
 type jsfsUtimes struct{}
 
 func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
@@ -523,7 +523,10 @@ func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{
 	callback := args[3].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.RootFS().UtimesNano(path, atimeSec*1e9, mtimeSec*1e9)
+	times := [2]syscall.Timespec{
+		syscall.NsecToTimespec(atimeSec * 1e9), syscall.NsecToTimespec(mtimeSec * 1e9),
+	}
+	err := fsc.RootFS().Utimens(path, &times, true)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
diff --git a/internal/platform/futimens.go b/internal/platform/futimens.go
new file mode 100644
index 00000000..b908101e
--- /dev/null
+++ b/internal/platform/futimens.go
@@ -0,0 +1,146 @@
+package platform
+
+import (
+	"io/fs"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+const (
+	// UTIME_NOW is a special syscall.Timespec NSec value used to set the
+	// file's timestamp to a value close to, but not greater than the current
+	// system time.
+	UTIME_NOW = _UTIME_NOW
+
+	// UTIME_OMIT is a special syscall.Timespec NSec value used to avoid
+	// setting the file's timestamp.
+	UTIME_OMIT = _UTIME_OMIT
+)
+
+// Utimens set file access and modification times on a path resolved to the
+// current working directory, at nanosecond precision.
+//
+// # Parameters
+//
+// The `times` parameter includes the access and modification timestamps to
+// assign. Special syscall.Timespec NSec values UTIME_NOW and UTIME_OMIT may be
+// specified instead of real timestamps. A nil `times` parameter behaves the
+// same as if both were set to UTIME_NOW.
+//
+// When the `symlinkFollow` parameter is true and the path is a symbolic link,
+// the target of expanding that link is updated.
+//
+// # Errors
+//
+// The following errors are expected:
+//   - syscall.EINVAL: `path` is invalid.
+//   - syscall.EEXIST: `path` exists and is a directory.
+//   - syscall.ENOTDIR: `path` exists and is a file.
+//
+// # Notes
+//
+//   - This is similar to syscall.UtimesNano, except that doesn't have flags to
+//     control expansion of symbolic links. It also doesn't support special
+//     values UTIME_NOW or UTIME_NOW.
+//   - This is like `utimensat` with `AT_FDCWD` in POSIX. See
+//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+func Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	err := utimens(path, times, symlinkFollow)
+	return UnwrapOSError(err)
+}
+
+// UtimensFile is like Utimens, except it works on a file, not a path.
+//
+// # Notes
+//
+//   - Windows requires files to be open with syscall.O_RDWR, which means you
+//     cannot use this to update timestamps on a directory (syscall.EPERM).
+//   - This is like the function `futimens` in POSIX. See
+//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+func UtimensFile(f fs.File, times *[2]syscall.Timespec) error {
+	if f, ok := f.(fdFile); ok {
+		err := futimens(f.Fd(), times)
+		return UnwrapOSError(err)
+	}
+	return syscall.ENOSYS
+}
+
+func timesToPtr(times *[2]syscall.Timespec) unsafe.Pointer { //nolint:unused
+	var _p0 unsafe.Pointer
+	if times != nil {
+		_p0 = unsafe.Pointer(&times[0])
+	} else {
+		_p0 = unsafe.Pointer(&_zero)
+	}
+	return _p0
+}
+
+func utimensPortable(path string, times *[2]syscall.Timespec, symlinkFollow bool) error { //nolint:unused
+	if !symlinkFollow {
+		return syscall.ENOSYS
+	}
+
+	// Handle when both inputs are current system time.
+	if times == nil || times[0].Nsec == UTIME_NOW && times[1].Nsec == UTIME_NOW {
+		ts := nowTimespec()
+		return syscall.UtimesNano(path, []syscall.Timespec{ts, ts})
+	}
+
+	// When both inputs are omitted, there is nothing to change.
+	if times[0].Nsec == UTIME_OMIT && times[1].Nsec == UTIME_OMIT {
+		return nil
+	}
+
+	// Handle when neither input are special values
+	if times[0].Nsec != UTIME_NOW && times[1].Nsec != UTIME_NOW &&
+		times[0].Nsec != UTIME_OMIT && times[1].Nsec != UTIME_OMIT {
+		return syscall.UtimesNano(path, times[:])
+	}
+
+	// Now, either atim or mtim is a special value, but not both.
+
+	// Now, either one of the inputs is a special value, or neither. This means
+	// we don't have a risk of re-reading the clock or re-doing stat.
+	if atim, err := normalizeTimespec(path, times, 0); err != nil {
+		return err
+	} else if mtim, err := normalizeTimespec(path, times, 1); err != nil {
+		return err
+	} else {
+		return syscall.UtimesNano(path, []syscall.Timespec{atim, mtim})
+	}
+}
+
+func normalizeTimespec(path string, times *[2]syscall.Timespec, i int) (ts syscall.Timespec, err error) { //nolint:unused
+	switch times[i].Nsec {
+	case UTIME_NOW: // declined in Go per golang/go#31880.
+		ts = nowTimespec()
+		return
+	case UTIME_OMIT:
+		// UTIME_OMIT is expensive until progress is made in Go, as it requires a
+		// stat to read-back the value to re-apply.
+		// - https://github.com/golang/go/issues/32558.
+		// - https://go-review.googlesource.com/c/go/+/219638 (unmerged)
+		var st Stat_t
+		if err = stat(path, &st); err != nil {
+			return
+		}
+		switch i {
+		case 0:
+			ts = syscall.NsecToTimespec(st.Atim)
+		case 1:
+			ts = syscall.NsecToTimespec(st.Mtim)
+		default:
+			panic("BUG")
+		}
+		return
+	default: // not special
+		ts = times[i]
+		return
+	}
+}
+
+func nowTimespec() syscall.Timespec { //nolint:unused
+	now := time.Now().UnixNano()
+	return syscall.NsecToTimespec(now)
+}
diff --git a/internal/platform/utimes_darwin.go b/internal/platform/futimens_darwin.go
similarity index 57%
rename from internal/platform/utimes_darwin.go
rename to internal/platform/futimens_darwin.go
index a979e4ee..5b57a4b5 100644
--- a/internal/platform/utimes_darwin.go
+++ b/internal/platform/futimens_darwin.go
@@ -2,17 +2,34 @@ package platform
 
 import (
 	"syscall"
-	"unsafe"
+	_ "unsafe" // for go:linkname
 )
 
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	tv := []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
+const (
+	_AT_FDCWD               = -0x2
+	_AT_SYMLINK_NOFOLLOW    = 0x0020
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = true
+)
+
+//go:noescape
+//go:linkname utimensat syscall.utimensat
+func utimensat(dirfd int, path string, times *[2]syscall.Timespec, flags int) error
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	var flags int
+	if !symlinkFollow {
+		flags = _AT_SYMLINK_NOFOLLOW
 	}
+	return utimensat(_AT_FDCWD, path, times, flags)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	_p0 := timesToPtr(times)
 
 	// Warning: futimens only exists since High Sierra (10.13).
-	_, _, e1 := syscall_syscall6(libc_futimens_trampoline_addr, fd, uintptr(unsafe.Pointer(&tv[0])), 0, 0, 0, 0)
+	_, _, e1 := syscall_syscall6(libc_futimens_trampoline_addr, fd, uintptr(_p0), 0, 0, 0, 0)
 	if e1 != 0 {
 		return e1
 	}
@@ -20,7 +37,7 @@ func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
 }
 
 // libc_futimens_trampoline_addr is the address of the
-// `libc_futimens_trampoline` symbol, defined in `utimes_darwin.s`.
+// `libc_futimens_trampoline` symbol, defined in `futimens_darwin.s`.
 //
 // We use this to invoke the syscall through syscall_syscall6 imported below.
 var libc_futimens_trampoline_addr uintptr
diff --git a/internal/platform/utimes_darwin.s b/internal/platform/futimens_darwin.s
similarity index 100%
rename from internal/platform/utimes_darwin.s
rename to internal/platform/futimens_darwin.s
diff --git a/internal/platform/futimens_linux.go b/internal/platform/futimens_linux.go
new file mode 100644
index 00000000..07ebf2e2
--- /dev/null
+++ b/internal/platform/futimens_linux.go
@@ -0,0 +1,43 @@
+package platform
+
+import (
+	"syscall"
+	"unsafe"
+	_ "unsafe" // for go:linkname
+)
+
+const (
+	_AT_FDCWD               = -0x64
+	_AT_SYMLINK_NOFOLLOW    = 0x100
+	_UTIME_NOW              = (1 << 30) - 1
+	_UTIME_OMIT             = (1 << 30) - 2
+	SupportsSymlinkNoFollow = true
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) (err error) {
+	var flags int
+	if !symlinkFollow {
+		flags = _AT_SYMLINK_NOFOLLOW
+	}
+
+	var _p0 *byte
+	_p0, err = syscall.BytePtrFromString(path)
+	if err != nil {
+		return
+	}
+	return utimensat(_AT_FDCWD, uintptr(unsafe.Pointer(_p0)), times, flags)
+}
+
+// On linux, implement futimens via utimensat with the NUL path.
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	return utimensat(int(fd), 0 /* NUL */, times, 0)
+}
+
+// utimensat is like syscall.utimensat special-cased to accept a NUL string for the path value.
+func utimensat(dirfd int, strPtr uintptr, times *[2]syscall.Timespec, flags int) (err error) {
+	_, _, e1 := syscall.Syscall6(syscall.SYS_UTIMENSAT, uintptr(dirfd), strPtr, uintptr(unsafe.Pointer(times)), uintptr(flags), 0, 0)
+	if e1 != 0 {
+		err = e1
+	}
+	return
+}
diff --git a/internal/platform/futimens_unsupported.go b/internal/platform/futimens_unsupported.go
new file mode 100644
index 00000000..3fb3c92b
--- /dev/null
+++ b/internal/platform/futimens_unsupported.go
@@ -0,0 +1,23 @@
+//go:build !windows && !linux && !darwin
+
+package platform
+
+import "syscall"
+
+// Define values even if not used except as sentinels.
+const (
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = false
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return utimensPortable(path, times, symlinkFollow)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	// Go exports syscall.Futimes, which is microsecond granularity, and
+	// WASI tests expect nanosecond. We don't yet have a way to invoke the
+	// futimens syscall portably.
+	return syscall.ENOSYS
+}
diff --git a/internal/platform/futimens_windows.go b/internal/platform/futimens_windows.go
new file mode 100644
index 00000000..c0bc03fa
--- /dev/null
+++ b/internal/platform/futimens_windows.go
@@ -0,0 +1,70 @@
+package platform
+
+import (
+	"syscall"
+	"time"
+)
+
+// Define values even if not used except as sentinels.
+const (
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = false
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return utimensPortable(path, times, symlinkFollow)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	// Before Go 1.20, ERROR_INVALID_HANDLE was returned for too many reasons.
+	// Kick out so that callers can use path-based operations instead.
+	if !IsGo120 {
+		return syscall.ENOSYS
+	}
+
+	// Per docs, zero isn't a valid timestamp as it cannot be differentiated
+	// from nil. In both cases, it is a marker like syscall.UTIME_OMIT.
+	// See https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfiletime
+	a, w := timespecToFiletime(times)
+
+	if a == nil && w == nil {
+		return nil // both omitted, so nothing to change
+	}
+
+	// Attempt to get the stat by handle, which works for normal files
+	h := syscall.Handle(fd)
+
+	// Note: This returns ERROR_ACCESS_DENIED when the input is a directory.
+	return syscall.SetFileTime(h, nil, a, w)
+}
+
+func timespecToFiletime(times *[2]syscall.Timespec) (a, w *syscall.Filetime) {
+	// Handle when both inputs are current system time.
+	if times == nil || times[0].Nsec == UTIME_NOW && times[1].Nsec == UTIME_NOW {
+		now := time.Now().UnixNano()
+		ft := syscall.NsecToFiletime(now)
+		return &ft, &ft
+	}
+
+	// Now, either one of the inputs is current time, or neither. This
+	// means we don't have a risk of re-reading the clock.
+	a = timespecToFileTime(times, 0)
+	w = timespecToFileTime(times, 1)
+	return
+}
+
+func timespecToFileTime(times *[2]syscall.Timespec, i int) *syscall.Filetime {
+	if times[i].Nsec == UTIME_OMIT {
+		return nil
+	}
+
+	var nsec int64
+	if times[i].Nsec == UTIME_NOW {
+		nsec = time.Now().UnixNano()
+	} else {
+		nsec = syscall.TimespecToNsec(times[i])
+	}
+	ft := syscall.NsecToFiletime(nsec)
+	return &ft
+}
diff --git a/internal/platform/mmap.go b/internal/platform/mmap.go
index 70c2cdd1..ce53dfc5 100644
--- a/internal/platform/mmap.go
+++ b/internal/platform/mmap.go
@@ -65,8 +65,6 @@ func mmapCodeSegmentARM64(code io.Reader, size int) ([]byte, error) {
 	return mmapFunc, err
 }
 
-var _zero uintptr
-
 // mprotect is like syscall.Mprotect, defined locally so that freebsd compiles.
 func mprotect(b []byte, prot int) (err error) {
 	var _p0 unsafe.Pointer
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
index 99141d05..ccfa1a75 100644
--- a/internal/platform/platform.go
+++ b/internal/platform/platform.go
@@ -17,6 +17,8 @@ var IsGo120 = strings.Contains(runtime.Version(), "go1.20")
 // archRequirementsVerified is set by platform-specific init to true if the platform is supported
 var archRequirementsVerified bool
 
+var _zero uintptr
+
 // CompilerSupported is exported for tests and includes constraints here and also the assembler.
 func CompilerSupported() bool {
 	switch runtime.GOOS {
diff --git a/internal/platform/utimes.go b/internal/platform/utimes.go
deleted file mode 100644
index fac570af..00000000
--- a/internal/platform/utimes.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package platform
-
-import (
-	"io/fs"
-	"syscall"
-)
-
-// UtimesNano is like syscall.UtimesNano. This returns syscall.ENOENT if the
-// path doesn't exist.
-//
-// Note: This is like the function `utimensat` with `AT_FDCWD` in POSIX.
-// See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-func UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
-	err := syscall.UtimesNano(path, []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
-	})
-	return UnwrapOSError(err)
-}
-
-// UtimesNanoFile is like syscall.Futimes, but for nanosecond precision and
-// fs.File instead of a file descriptor. This returns syscall.EBADF if the file
-// or directory was closed, or syscall.EPERM if the file wasn't opened with
-// permission to update its utimes. On syscall.EPERM, or on syscall.ENOSYS, use
-// UtimesNano with the original path.
-//
-// Note: This is like the function `futimens` in POSIX.
-// See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-func UtimesNanoFile(f fs.File, atimeNsec, mtimeNsec int64) error {
-	if f, ok := f.(fdFile); ok {
-		err := futimens(f.Fd(), atimeNsec, mtimeNsec)
-		return UnwrapOSError(err)
-	}
-	return syscall.ENOSYS
-}
diff --git a/internal/platform/utimes_linux.go b/internal/platform/utimes_linux.go
deleted file mode 100644
index 2d79fe74..00000000
--- a/internal/platform/utimes_linux.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package platform
-
-import (
-	"syscall"
-	"unsafe"
-)
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	times := []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
-	}
-	_, _, err := syscall.Syscall6(syscall.SYS_UTIMENSAT, fd, uintptr(0), uintptr(unsafe.Pointer(&times[0])), uintptr(0), 0, 0)
-	if err != 0 {
-		return err
-	}
-	return nil
-}
diff --git a/internal/platform/utimes_unsupported.go b/internal/platform/utimes_unsupported.go
deleted file mode 100644
index f88c0bc6..00000000
--- a/internal/platform/utimes_unsupported.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build !windows && !linux && !darwin
-
-package platform
-
-import "syscall"
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	// Go exports syscall.Futimes, which is microsecond granularity, and
-	// WASI tests expect nanosecond. We don't yet have a way to invoke the
-	// futimens syscall portably.
-	return syscall.ENOSYS
-}
diff --git a/internal/platform/utimes_windows.go b/internal/platform/utimes_windows.go
deleted file mode 100644
index e5d4723c..00000000
--- a/internal/platform/utimes_windows.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package platform
-
-import "syscall"
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	// Before Go 1.20, ERROR_INVALID_HANDLE was returned for too many reasons.
-	// Kick out so that callers can use path-based operations instead.
-	if !IsGo120 {
-		return syscall.ENOSYS
-	}
-
-	// Attempt to get the stat by handle, which works for normal files
-	h := syscall.Handle(fd)
-
-	// Perform logic similar to what's done in syscall.UtimesNano
-	a := syscall.NsecToFiletime(atimeNsec)
-	w := syscall.NsecToFiletime(mtimeNsec)
-
-	// Note: This returns ERROR_ACCESS_DENIED when the input is a directory.
-	return syscall.SetFileTime(h, nil, &a, &w)
-}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 28b1983e..a02d2c15 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -124,9 +124,9 @@ func (d *dirFS) Symlink(oldName, link string) (err error) {
 	return platform.UnwrapOSError(err)
 }
 
-// UtimesNano implements FS.UtimesNano
-func (d *dirFS) UtimesNano(name string, atimeNsec, mtimeNsec int64) error {
-	return platform.UtimesNano(d.join(name), atimeNsec, mtimeNsec)
+// Utimens implements FS.Utimens
+func (d *dirFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return platform.Utimens(d.join(path), times, symlinkFollow)
 }
 
 // Truncate implements FS.Truncate
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index b9e420fa..445b99ff 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -308,10 +308,10 @@ func (c *CompositeFS) Link(oldName, newName string) error {
 	return c.fs[fromFS].Link(oldNamePath, newNamePath)
 }
 
-// UtimesNano implements FS.UtimesNano
-func (c *CompositeFS) UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
+// Utimens implements FS.Utimens
+func (c *CompositeFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
 	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].UtimesNano(relativePath, atimeNsec, mtimeNsec)
+	return c.fs[matchIndex].Utimens(relativePath, times, symlinkFollow)
 }
 
 // Symlink implements FS.Symlink
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index d330052d..71865e9c 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -269,23 +269,34 @@ type FS interface {
 	//   - syscall.EACCES: `path` doesn't have write access.
 	Truncate(path string, size int64) error
 
-	// UtimesNano is similar to syscall.UtimesNano, except the path is relative
-	// to this file system.
+	// Utimens set file access and modification times on a path relative to
+	// this file system, at nanosecond precision.
+	//
+	// # Parameters
+	//
+	// The `times` parameter includes the access and modification timestamps to
+	// assign. Special syscall.Timespec NSec values UTIME_NOW and UTIME_OMIT may be
+	// specified instead of real timestamps. A nil `times` parameter behaves the
+	// same as if both were set to UTIME_NOW.
+	//
+	// When the `symlinkFollow` parameter is true and the path is a symbolic link,
+	// the target of expanding that link is updated.
 	//
 	// # Errors
 	//
 	// The following errors are expected:
 	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` doesn't exist
+	//   - syscall.EEXIST: `path` exists and is a directory.
+	//   - syscall.ENOTDIR: `path` exists and is a file.
 	//
 	// # Notes
 	//
-	//   - To set wall clock time, retrieve it first from sys.Walltime.
-	//   - syscall.UtimesNano cannot change the ctime. Also, neither WASI nor
-	//     runtime.GOOS=js support changing it. Hence, ctime it is absent here.
-	//   - This is like the function `utimensat` with `AT_FDCWD` in POSIX.
-	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-	UtimesNano(path string, atimeNsec, mtimeNsec int64) error
+	//   - This is similar to syscall.Utimens, except that doesn't have flags to
+	//     control expansion of symbolic links. It also doesn't support special
+	//     values UTIME_NOW or UTIME_NOW.
+	//   - This is like `utimensat` with `AT_FDCWD` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+	Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error
 }
 
 // ReaderAtOffset gets an io.Reader from a fs.File that reads from an offset,
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index c89a551c..8e3cd40f 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -86,8 +86,8 @@ func (UnimplementedFS) Unlink(path string) error {
 	return syscall.ENOSYS
 }
 
-// UtimesNano implements FS.UtimesNano
-func (UnimplementedFS) UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
+// Utimens implements FS.Utimens
+func (UnimplementedFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
 	return syscall.ENOSYS
 }
 
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index 1f559647..c1bc55a0 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -286,6 +286,7 @@ func (s *Store) Instantiate(
 	module *Module,
 	name string,
 	sys *internalsys.Context,
+	typeIDs []FunctionTypeID,
 ) (*CallContext, error) {
 	// Collect any imported modules to avoid locking the store too long.
 	importedModuleNames := map[string]struct{}{}
@@ -305,7 +306,7 @@ func (s *Store) Instantiate(
 	}
 
 	// Instantiate the module and add it to the store so that other modules can import it.
-	if callCtx, err := s.instantiate(ctx, module, name, sys, importedModules); err != nil {
+	if callCtx, err := s.instantiate(ctx, module, name, sys, importedModules, typeIDs); err != nil {
 		_ = s.deleteModule(name)
 		return nil, err
 	} else {
@@ -325,12 +326,8 @@ func (s *Store) instantiate(
 	name string,
 	sysCtx *internalsys.Context,
 	modules map[string]*ModuleInstance,
+	typeIDs []FunctionTypeID,
 ) (*CallContext, error) {
-	typeIDs, err := s.getFunctionTypeIDs(module.TypeSection)
-	if err != nil {
-		return nil, err
-	}
-
 	importedFunctions, importedGlobals, importedTables, importedMemory, err := resolveImports(module, modules)
 	if err != nil {
 		return nil, err
@@ -562,7 +559,7 @@ func executeConstExpression(importedGlobals []*GlobalInstance, expr *ConstantExp
 	return
 }
 
-func (s *Store) getFunctionTypeIDs(ts []*FunctionType) ([]FunctionTypeID, error) {
+func (s *Store) GetFunctionTypeIDs(ts []*FunctionType) ([]FunctionTypeID, error) {
 	ret := make([]FunctionTypeID, len(ts))
 	for i, t := range ts {
 		inst, err := s.getFunctionTypeID(t)
diff --git a/netlify.toml b/netlify.toml
index 87cf5839..4d4d72d5 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -3,7 +3,7 @@
   publish = "public"
 
 [build.environment]
-  HUGO_VERSION = "0.110.0"
+  HUGO_VERSION = "0.111.2"
 
 [context.production]
   command = "git submodule update --init && hugo --gc --minify"
diff --git a/runtime.go b/runtime.go
index c48f7d73..edd4252f 100644
--- a/runtime.go
+++ b/runtime.go
@@ -203,6 +203,13 @@ func (r *runtime) CompileModule(ctx context.Context, binary []byte) (CompiledMod
 
 	c := &compiledModule{module: internal, compiledEngine: r.store.Engine}
 
+	// typeIDs are static and compile-time known.
+	typeIDs, err := r.store.GetFunctionTypeIDs(internal.TypeSection)
+	if err != nil {
+		return nil, err
+	}
+	c.typeIDs = typeIDs
+
 	listeners, err := buildListeners(ctx, internal)
 	if err != nil {
 		return nil, err
@@ -276,7 +283,7 @@ func (r *runtime) InstantiateModule(
 	}
 
 	// Instantiate the module.
-	mod, err = r.store.Instantiate(ctx, code.module, name, sysCtx)
+	mod, err = r.store.Instantiate(ctx, code.module, name, sysCtx, code.typeIDs)
 	if err != nil {
 		// If there was an error, don't leak the compiled module.
 		if code.closeWithModule {
diff --git a/site/content/docs/_index.md b/site/content/docs/_index.md
index ee0ee0c5..bd36fc08 100644
--- a/site/content/docs/_index.md
+++ b/site/content/docs/_index.md
@@ -151,11 +151,19 @@ Interpreter is a naive interpreter-based implementation of Wasm virtual machine.
 Its implementation doesn't have any platform (GOARCH, GOOS) specific code,
 therefore interpreter can be used for any compilation target available for Go (such as riscv64).
 
-## How function call works
+## How do function calls work?
 
-How function calls are performed in wazero differ depending on the type of [Engine](#engine).
-For those who are curious about how wazero performs function calls under the hood,
-please refer to [the dedicated documentation](how_function_call_works)
+WebAssembly runtimes let you call functions defined in wasm. How this works in
+wazero is different depending on your `RuntimeConfig`.
+
+* `RuntimeConfigCompiler` compiles machine code from your wasm, and jumps to
+  that when invoking a function.
+* `RuntimeConfigInterpreter` does not generate code. It interprets wasm and
+  executes go statements that correspond to WebAssembly instructions.
+
+How the compiler works precisely is a large topic. If you are interested in
+digging deeper, please look at [the dedicated documentation](how_do_compiler_functions_work)
+on this topic!
 
 ## Rationales behind wazero
 
diff --git a/site/content/docs/how_do_compiler_functions_work.md b/site/content/docs/how_do_compiler_functions_work.md
new file mode 100644
index 00000000..a9aa3817
--- /dev/null
+++ b/site/content/docs/how_do_compiler_functions_work.md
@@ -0,0 +1,220 @@
+# How do compiler functions work?
+
+WebAssembly runtimes let you call functions defined in wasm. How this works in
+wazero is different depending on your `RuntimeConfig`.
+
+* `RuntimeConfigCompiler` compiles machine code from your wasm, and jumps to
+  that when invoking a function.
+* `RuntimeConfigInterpreter` does not generate code. It interprets wasm and
+  executes go statements that correspond to WebAssembly instructions.
+
+How the compiler works precisely is a large topic, and discussed at length on
+this page. For more general information on architecture, etc., please refer to
+[Docs](..).
+
+## Engines
+
+Our [Docs](..) introduce the "engine" concept of wazero. More precisely, there
+are three types of engines, `Engine`, `ModuleEngine` and `CallEngine`. Each has
+a different scope and role:
+
+- `Engine` has the same lifetime as `Runtime`. This compiles a `CompiledModule`
+  into machine code, which is both cached and memory-mapped as an executable.
+- `ModuleEngine` is a virtual machine with the same lifetime as its [Module][api-module].
+  Notably, this binds each [function instance][spec-function-instance] to
+  corresponding machine code owned by its `Engine`.
+- `CallEngine` corresponds to an exported [api.Function][api-function] in a
+  [Module][api-module]. This implements `Function.Call(...)` by invoking
+  machine code corresponding to a function instance in `ModuleEngine` and
+  managing the [call stack][call-stack] representing the invocation.
+
+Here is a diagram showing the relationships of these engines:
+
+```goat
+      .-----------> Instantiated module                                 Exported Function
+     /1:N                   |                                                  |
+    /                       |                                                  v
+   |     +----------+       v        +----------------+                  +------------+
+   |     |  Engine  |--------------->|  ModuleEngine  |----------------->| CallEngine |
+   |     +----------+                +----------------+                  +------------+
+   |          |                               |                            |      |
+   .          |                               |                            |      |
+ main.wasm -->|        .--------------------->|          '-----------------+      |
+              |       /                       |          |                        |
+              v      .                        v          v                        v
+      +--------------+      +-----------------------------------+            +----------+
+      | Machine Code |      |[(func_instance, machine_code),...]|            |Call Stack|
+      +--------------+      +-----------------------------------+            +----------+
+                                               ^                                  ^
+                                               |                                  |
+                                               |                                  |
+                                               +----------------------------------+
+                                                               |
+                                                               |
+                                                               |
+                                                        Function.Call()
+```
+
+## Callbacks from machine code to Go
+
+Go source can be compiled to invoke native library functions using CGO.
+However, [CGO is not GO][cgo-not-go]. To call native functions in pure Go, we
+need a different approach with unique constraints.
+
+The most notable constraints are:
+* machine code must not manipulate the Goroutine or system stack
+* we cannot modify the signal handler of Go at runtime
+
+### Handling the call stack
+
+One constraint is the generated machine code must not manipulate Goroutine
+(or system) stack. Otherwise, the Go runtime gets corrupted, which results in
+fatal execution errors. This means we cannot[^1] call Go functions (host
+functions) directly from machine code (compiled from wasm). This is routinely
+needed in WebAssembly, as system calls such as WASI are defined in Go, but
+invoked from Wasm. To handle this, we employ a "trampoline strategy".
+
+Let's explain the "trampoline strategy" with an example. `random_get` is a host
+function defined in Go, called from machine code compiled from guest `main`
+function. Let's say the wasm function corresponding to that is called `_start`.
+`_start` function is called by wazero by default on `Instantiate`.
+
+Here is a TinyGo source file describing this.
+```go
+//go:import wasi_snapshot_preview1 random_get
+func random_get(age int32)package main
+
+import "unsafe"
+
+// random_get is a function defined on the host, specifically, the wazero
+// program written in Go.
+//
+//go:wasm-module wasi_snapshot_preview1
+//export random_get
+func random_get(ptr uintptr, size uint32) (errno uint32)
+
+// main is compiled to wasm, so this is the guest. Conventionally, this ends up
+// named `_start`.
+func main() {
+    // Define a buffer to hold random data
+	size := uint32(8)
+    buf := make([]byte, size)
+
+	// Fill the buffer with random data using an imported host function.
+    // The host needs to know where in guest memory to place the random data.
+	// To communicate this, we have to convert buf to a uintptr.
+    errno := random_get(uintptr(unsafe.Pointer(&buf[0])), size)
+    if errno != 0 {
+        panic(errno)
+    }
+}
+```
+
+When `_start` calls `random_get`, it exits execution first. wazero calls the Go
+function mapped to `random_get` like a usual Go program. Finally, wazero
+transfers control back to machine code again, resuming `_start` after the call
+instruction to `random_get`.
+
+Here's what the "trampoline strategy" looks like in a diagram. For simplicity,
+we'll say the wasm memory offset of the `buf` is zero, but it will be different
+in real execution.
+```goat
+   |                                     Go              |           Machine Code
+   |                                                           (compiled from main.wasm)
+   |                                                     |
+   v
+   |                        `Instantiate(ctx, mainWasm)` |
+   |                                     |
+   v                                     v               |
+   |                            +----------------+                  +------------+
+   |                            |func exec_native|-------|--------> |func _start |
+   v                            +----------------+                  +------------+
+   |                                                     |         /
+   |            Go func call    +----------------+                / ptr=0,size=8
+   v           .----------------|func exec_native|<------|-------. status=call_host_fn(name=rand_get)
+   |          /  ptr=0,size=8   +----------------+     exit
+   |         v                                           |
+   v   +-------------+          +----------------+
+   |   |func rand_get|--------->|func exec_native|-------|-------.
+   |   +-------------+ errno=0  +----------------+    continue    \ errno=0
+   v                                                     |         \
+   |                                                     |          +------------+
+   |                                                     |          |func _start |
+   v                                                     |          +------------+
+```
+
+### Signal handling
+
+Code compiled to wasm use [runtime traps][spec-trap] to abort execution. For
+example, a `panic` compiled with TinyGo becomes a wasm function named
+`runtime._panic`, which issues an [unreachable][spec-unreachable] instruction
+after printing the message to STDERR.
+
+```go
+package main
+
+func main() {
+	panic("help")
+}
+```
+
+Native JIT compilers set custom signal handlers for [Wasm runtime traps][spec-trap],
+such as the [unreachable][spec-unreachable] instruction. However, we cannot
+safely [modify the signal handler of Go at runtime][signal-handler-discussion].
+As described in the first section, wazero always exits the execution of machine
+code. Machine code sets status when it encounters an `unreachable` instruction.
+This is read by wazero, which propagates it back with `ErrRuntimeUnreachable`.
+
+Here's a diagram showing this:
+```goat
+   |                               Go                 |                             Machine Code
+   |                                                                          (compiled from main.wasm)
+   |                                                  |
+   v
+   |                   `Instantiate(ctx, mainWasm)`   |
+   |                                |
+   v                                v                 |
+   |                       +----------------+                                     +------------+
+   |                       |func exec_native|---------|-------------------------> |func _start |
+   v                       +----------------+                                     +------------+
+   |                                                  |                                 |
+   |                       +----------------+                  exit           +--------------------+
+   v                       |func exec_native|<--------|---------------------- |func runtime._panic |
+   |                       +----------------+            status=unreachable   +--------------------+
+   |                              |                   |
+   v                              |
+   |                panic(WasmRuntimeErrUnreachable)  |
+```
+
+One thing you will notice above is that the calls between wasm functions, such
+as from `_start` to `runtime._panic` do not use a trampoline. The trampoline
+strategy is only used between wasm and the host.
+
+## Summary
+
+When an exported wasm function is called, using a wazero API, such as
+`Function.Call()`, wazero allocates a `CallEngine` and starts invocation. This
+begins with jumping to machine code compiled from the Wasm binary. When that
+code makes a callback to the host, it exits execution, passing control back to
+`exec_native` which then calls a Go function and resumes the machine code
+afterwards. In the face of Wasm runtime errors, we exit the machine code
+execution with the proper status, and return the control back to `exec_native`
+function, just like host function calls. Just instead of calling a Go function,
+we call `panic` with a corresponding error. This jumping is why the strategy is
+called a trampoline, and only used between the guest wasm and the host running
+it.
+
+For more details, see [RATIONALE.md][compiler-rationale].
+
+[call-stack]: https://en.wikipedia.org/wiki/Call_stack
+[api-function]: https://pkg.go.dev/github.com/tetratelabs/wazero@v1.0.0-rc.1/api#Function
+[api-module]: https://pkg.go.dev/github.com/tetratelabs/wazero@v1.0.0-rc.1/api#Module
+[spec-function-instance]: https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#function-instances%E2%91%A0
+[spec-trap]: https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#trap
+[spec-unreachable]: https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#syntax-instr-control
+[compiler-rationale]: https://github.com/tetratelabs/wazero/blob/v1.0.0-rc.1/internal/engine/compiler/RATIONALE.md
+[signal-handler-discussion]: https://gophers.slack.com/archives/C1C1YSQBT/p1675992411241409
+[cgo-not-go]: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=757s
+
+[^1]: it's technically possible to call it directly, but that would come with performing "stack switching" in the native code.
+  It's almost the same as what wazero does: exiting the execution of machine code, then call the target Go function (using the caller of machine code as a "trampoline").
diff --git a/site/content/docs/how_function_call_works.md b/site/content/docs/how_function_call_works.md
deleted file mode 100644
index 024e4af9..00000000
--- a/site/content/docs/how_function_call_works.md
+++ /dev/null
@@ -1,1 +0,0 @@
-🚧 TODO 🚧


Test patch:
diff --git a/builder_test.go b/builder_test.go
index 02b9567c..1ca4a6e7 100644
--- a/builder_test.go
+++ b/builder_test.go
@@ -279,6 +279,11 @@ func TestNewHostModuleBuilder_Compile(t *testing.T) {
 
 			require.Equal(t, b.r.store.Engine, m.compiledEngine)
 
+			// TypeIDs must be assigned to compiledModule.
+			expTypeIDs, err := b.r.store.GetFunctionTypeIDs(tc.expected.TypeSection)
+			require.NoError(t, err)
+			require.Equal(t, expTypeIDs, m.typeIDs)
+
 			// Built module must be instantiable by Engine.
 			mod, err := b.r.InstantiateModule(testCtx, m, NewModuleConfig())
 			require.NoError(t, err)
@@ -353,6 +358,8 @@ func requireHostModuleEquals(t *testing.T, expected, actual *wasm.Module) {
 	// `require.Equal(t, expected, actual)` fails reflect pointers don't match, so brute compare:
 	for _, tp := range expected.TypeSection {
 		tp.CacheNumInUint64()
+		// When creating the compiled module, we get the type IDs for types, which results in caching type keys.
+		_ = tp.String()
 	}
 	require.Equal(t, expected.TypeSection, actual.TypeSection)
 	require.Equal(t, expected.ImportSection, actual.ImportSection)
diff --git a/cache_test.go b/cache_test.go
index 2df01a07..fcc6ad28 100644
--- a/cache_test.go
+++ b/cache_test.go
@@ -27,6 +27,18 @@ func TestCompilationCache(t *testing.T) {
 		foo, bar := getCacheSharedRuntimes(ctx, t)
 		cacheInst := foo.cache
 
+		// Create a different type id on the bar's store so that we can emulate that bar instantiated the module before facWasm.
+		_, err := bar.store.GetFunctionTypeIDs(
+			// Arbitrary one is fine as long as it is not used in facWasm.
+			[]*wasm.FunctionType{{Params: []wasm.ValueType{
+				wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32,
+				wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeI32,
+				wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeI32,
+				wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeV128, wasm.ValueTypeI32, wasm.ValueTypeI32,
+				wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32, wasm.ValueTypeI32,
+			}}})
+		require.NoError(t, err)
+
 		// add interpreter first, to ensure compiler support isn't order dependent
 		eng := foo.cache.engs[engineKindInterpreter]
 		if platform.CompilerSupported() {
@@ -41,8 +53,12 @@ func TestCompilationCache(t *testing.T) {
 		barCompiled, err := bar.CompileModule(ctx, facWasm)
 		require.NoError(t, err)
 
-		// Ensures compiled modules are the same.
-		require.Equal(t, compiled, barCompiled)
+		// Ensures compiled modules are the same modulo type IDs, which is unique per store.
+		require.Equal(t, compiled.(*compiledModule).module, barCompiled.(*compiledModule).module)
+		require.Equal(t, compiled.(*compiledModule).closeWithModule, barCompiled.(*compiledModule).closeWithModule)
+		require.Equal(t, compiled.(*compiledModule).compiledEngine, barCompiled.(*compiledModule).compiledEngine)
+		// TypeIDs must be different as we create a different type ID on bar beforehand.
+		require.NotEqual(t, compiled.(*compiledModule).typeIDs, barCompiled.(*compiledModule).typeIDs)
 
 		// Two runtimes are completely separate except the compilation cache,
 		// therefore it should be ok to instantiate the same name module for each of them.
diff --git a/internal/engine/compiler/engine_test.go b/internal/engine/compiler/engine_test.go
index 7d98b842..fcd5dbdb 100644
--- a/internal/engine/compiler/engine_test.go
+++ b/internal/engine/compiler/engine_test.go
@@ -264,7 +264,10 @@ func TestCompiler_SliceAllocatedOnHeap(t *testing.T) {
 	err = s.Engine.CompileModule(testCtx, hm, nil, false)
 	require.NoError(t, err)
 
-	_, err = s.Instantiate(testCtx, hm, hostModuleName, nil)
+	typeIDs, err := s.GetFunctionTypeIDs(hm.TypeSection)
+	require.NoError(t, err)
+
+	_, err = s.Instantiate(testCtx, hm, hostModuleName, nil, typeIDs)
 	require.NoError(t, err)
 
 	const stackCorruption = "value_stack_corruption"
@@ -320,7 +323,10 @@ func TestCompiler_SliceAllocatedOnHeap(t *testing.T) {
 	err = s.Engine.CompileModule(testCtx, m, nil, false)
 	require.NoError(t, err)
 
-	mi, err := s.Instantiate(testCtx, m, t.Name(), nil)
+	typeIDs, err = s.GetFunctionTypeIDs(m.TypeSection)
+	require.NoError(t, err)
+
+	mi, err := s.Instantiate(testCtx, m, t.Name(), nil, typeIDs)
 	require.NoError(t, err)
 
 	for _, fnName := range []string{stackCorruption, callStackCorruption} {
diff --git a/internal/integration_test/spectest/spectest.go b/internal/integration_test/spectest/spectest.go
index 0028dda5..4415e59a 100644
--- a/internal/integration_test/spectest/spectest.go
+++ b/internal/integration_test/spectest/spectest.go
@@ -337,7 +337,10 @@ func addSpectestModule(t *testing.T, ctx context.Context, s *wasm.Store, enabled
 	err = s.Engine.CompileModule(ctx, mod, nil, false)
 	require.NoError(t, err)
 
-	_, err = s.Instantiate(ctx, mod, mod.NameSection.ModuleName, sys.DefaultContext(nil))
+	typeIDs, err := s.GetFunctionTypeIDs(mod.TypeSection)
+	require.NoError(t, err)
+
+	_, err = s.Instantiate(ctx, mod, mod.NameSection.ModuleName, sys.DefaultContext(nil), typeIDs)
 	require.NoError(t, err)
 }
 
@@ -404,7 +407,10 @@ func Run(t *testing.T, testDataFS embed.FS, ctx context.Context, fc filecache.Ca
 						err = s.Engine.CompileModule(ctx, mod, nil, false)
 						require.NoError(t, err, msg)
 
-						_, err = s.Instantiate(ctx, mod, moduleName, nil)
+						typeIDs, err := s.GetFunctionTypeIDs(mod.TypeSection)
+						require.NoError(t, err)
+
+						_, err = s.Instantiate(ctx, mod, moduleName, nil, typeIDs)
 						lastInstantiatedModuleName = moduleName
 						require.NoError(t, err)
 					case "register":
@@ -542,7 +548,10 @@ func Run(t *testing.T, testDataFS embed.FS, ctx context.Context, fc filecache.Ca
 							err = s.Engine.CompileModule(ctx, mod, nil, false)
 							require.NoError(t, err, msg)
 
-							_, err = s.Instantiate(ctx, mod, t.Name(), nil)
+							typeIDs, err := s.GetFunctionTypeIDs(mod.TypeSection)
+							require.NoError(t, err)
+
+							_, err = s.Instantiate(ctx, mod, t.Name(), nil, typeIDs)
 							require.NoError(t, err, msg)
 						} else {
 							requireInstantiationError(t, ctx, s, buf, msg)
@@ -578,7 +587,10 @@ func requireInstantiationError(t *testing.T, ctx context.Context, s *wasm.Store,
 		return
 	}
 
-	_, err = s.Instantiate(ctx, mod, t.Name(), nil)
+	typeIDs, err := s.GetFunctionTypeIDs(mod.TypeSection)
+	require.NoError(t, err)
+
+	_, err = s.Instantiate(ctx, mod, t.Name(), nil, typeIDs)
 	require.Error(t, err, msg)
 }
 
diff --git a/internal/platform/bench_test.go b/internal/platform/bench_test.go
index 1e3ddcde..51cbdfee 100644
--- a/internal/platform/bench_test.go
+++ b/internal/platform/bench_test.go
@@ -3,6 +3,7 @@ package platform
 import (
 	"os"
 	"path"
+	"syscall"
 	"testing"
 )
 
@@ -12,7 +13,7 @@ func Benchmark_IsTerminal(b *testing.B) {
 	}
 }
 
-func Benchmark_UtimesNano(b *testing.B) {
+func Benchmark_UtimensFile(b *testing.B) {
 	tmpDir := b.TempDir()
 	f, err := os.Create(path.Join(tmpDir, "file"))
 	if err != nil {
@@ -20,9 +21,14 @@ func Benchmark_UtimesNano(b *testing.B) {
 	}
 	defer f.Close()
 
+	times := &[2]syscall.Timespec{
+		{Sec: 123, Nsec: 4 * 1e3},
+		{Sec: 123, Nsec: 4 * 1e3},
+	}
+
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		if err := UtimesNanoFile(f, 1, 1); err != nil {
+		if err := UtimensFile(f, times); err != nil {
 			b.Fatal(err)
 		}
 	}
diff --git a/internal/platform/futimens_test.go b/internal/platform/futimens_test.go
new file mode 100644
index 00000000..4f91a37e
--- /dev/null
+++ b/internal/platform/futimens_test.go
@@ -0,0 +1,226 @@
+package platform
+
+import (
+	"os"
+	"path"
+	"runtime"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestUtimens(t *testing.T) {
+	t.Run("doesn't exist", func(t *testing.T) {
+		err := Utimens("nope", nil, true)
+		require.EqualErrno(t, syscall.ENOENT, err)
+		err = Utimens("nope", nil, false)
+		if SupportsSymlinkNoFollow {
+			require.EqualErrno(t, syscall.ENOENT, err)
+		} else {
+			require.EqualErrno(t, syscall.ENOSYS, err)
+		}
+	})
+	testFutimens(t, true)
+}
+
+func testFutimens(t *testing.T, usePath bool) {
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	//
+	// Negative isn't tested as most platforms don't return consistent results.
+	tests := []struct {
+		name  string
+		times *[2]syscall.Timespec
+	}{
+		{
+			name: "nil",
+		},
+		{
+			name: "a=omit,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=now,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=omit,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: 4 * 1e3},
+			},
+		},
+		{
+			name: "a=set,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=set,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 223, Nsec: 5 * 1e3},
+			},
+		},
+	}
+	for _, fileType := range []string{"dir", "file", "link", "link-follow"} {
+		for _, tt := range tests {
+			tc := tt
+			fileType := fileType
+			name := fileType + " " + tc.name
+			symlinkNoFollow := fileType == "link"
+
+			// symlinkNoFollow is invalid for file descriptor based operations,
+			// because the default for open is to follow links. You can't avoid
+			// this. O_NOFOLLOW is used only to return ELOOP on a link.
+			if !usePath && symlinkNoFollow {
+				continue
+			}
+
+			t.Run(name, func(t *testing.T) {
+				tmpDir := t.TempDir()
+				file := path.Join(tmpDir, "file")
+				err := os.WriteFile(file, []byte{}, 0o700)
+				require.NoError(t, err)
+
+				link := file + "-link"
+				require.NoError(t, os.Symlink(file, link))
+
+				dir := path.Join(tmpDir, "dir")
+				err = os.Mkdir(dir, 0o700)
+				require.NoError(t, err)
+
+				var path, statPath string
+				switch fileType {
+				case "dir":
+					path = dir
+					statPath = dir
+				case "file":
+					path = file
+					statPath = file
+				case "link":
+					path = link
+					statPath = link
+				case "link-follow":
+					path = link
+					statPath = file
+				default:
+					panic(tc)
+				}
+
+				var oldSt Stat_t
+				require.NoError(t, Lstat(statPath, &oldSt))
+
+				if usePath {
+					err = Utimens(path, tc.times, !symlinkNoFollow)
+					if symlinkNoFollow && !SupportsSymlinkNoFollow {
+						require.EqualErrno(t, syscall.ENOSYS, err)
+						return
+					}
+					require.NoError(t, err)
+				} else {
+					flag := syscall.O_RDWR
+					if path == dir {
+						flag = syscall.O_RDONLY
+						if runtime.GOOS == "windows" {
+							// windows requires O_RDWR, which is invalid for directories
+							t.Skip("windows cannot update timestamps on a dir")
+						}
+					}
+					f, err := OpenFile(path, flag, 0)
+					require.NoError(t, err)
+					err = UtimensFile(f, tc.times)
+					require.NoError(t, f.Close())
+					require.NoError(t, err)
+				}
+
+				var newSt Stat_t
+				require.NoError(t, Lstat(statPath, &newSt))
+
+				if CompilerSupported() {
+					if tc.times != nil && tc.times[0].Nsec == UTIME_OMIT {
+						require.Equal(t, oldSt.Atim, newSt.Atim)
+					} else if tc.times == nil || tc.times[0].Nsec == UTIME_NOW {
+						now := time.Now().UnixNano()
+						require.True(t, newSt.Atim <= now, "expected atim %d <= now %d", newSt.Atim, now)
+					} else {
+						require.Equal(t, tc.times[0].Nano(), newSt.Atim)
+					}
+				}
+
+				// When compiler isn't supported, we can still check mtim.
+				if tc.times != nil && tc.times[1].Nsec == UTIME_OMIT {
+					require.Equal(t, oldSt.Mtim, newSt.Mtim)
+				} else if tc.times == nil || tc.times[1].Nsec == UTIME_NOW {
+					now := time.Now().UnixNano()
+					require.True(t, newSt.Mtim <= now, "expected mtim %d <= now %d", newSt.Mtim, now)
+				} else {
+					require.Equal(t, tc.times[1].Nano(), newSt.Mtim)
+				}
+			})
+		}
+	}
+}
+
+func TestUtimensFile(t *testing.T) {
+	switch runtime.GOOS {
+	case "linux", "darwin": // supported
+	case "freebsd": // TODO: support freebsd w/o CGO
+	case "windows":
+		if !IsGo120 {
+			t.Skip("windows only works after Go 1.20") // TODO: possibly 1.19 ;)
+		}
+	default: // expect ENOSYS and callers need to fall back to Utimens
+		t.Skip("unsupported GOOS", runtime.GOOS)
+	}
+
+	testFutimens(t, false)
+
+	t.Run("closed file", func(t *testing.T) {
+		file := path.Join(t.TempDir(), "file")
+		err := os.WriteFile(file, []byte{}, 0o700)
+		require.NoError(t, err)
+		fileF, err := OpenFile(file, syscall.O_RDWR, 0)
+		require.NoError(t, err)
+		require.NoError(t, fileF.Close())
+
+		err = UtimensFile(fileF, nil)
+		require.EqualErrno(t, syscall.EBADF, err)
+	})
+
+	t.Run("closed dir", func(t *testing.T) {
+		dir := path.Join(t.TempDir(), "dir")
+		err := os.Mkdir(dir, 0o700)
+		require.NoError(t, err)
+		dirF, err := OpenFile(dir, syscall.O_RDONLY, 0)
+		require.NoError(t, err)
+		require.NoError(t, dirF.Close())
+
+		err = UtimensFile(dirF, nil)
+		require.EqualErrno(t, syscall.EBADF, err)
+	})
+}
diff --git a/internal/platform/utimes_test.go b/internal/platform/utimes_test.go
deleted file mode 100644
index 596dc162..00000000
--- a/internal/platform/utimes_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package platform
-
-import (
-	"io/fs"
-	"os"
-	"path"
-	"runtime"
-	"syscall"
-	"testing"
-	"time"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-func TestUtimesNano(t *testing.T) {
-	tmpDir := t.TempDir()
-	file := path.Join(tmpDir, "file")
-	err := os.WriteFile(file, []byte{}, 0o700)
-	require.NoError(t, err)
-
-	dir := path.Join(tmpDir, "dir")
-	err = os.Mkdir(dir, 0o700)
-	require.NoError(t, err)
-
-	t.Run("doesn't exist", func(t *testing.T) {
-		err := UtimesNano("nope",
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.ENOENT, err)
-	})
-
-	type test struct {
-		name                 string
-		path                 string
-		atimeNsec, mtimeNsec int64
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []test{
-		{
-			name:      "file positive",
-			path:      file,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{
-			name:      "dir positive",
-			path:      dir,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", path: file},
-		{name: "dir zero", path: dir},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := UtimesNano(tc.path, tc.atimeNsec, tc.mtimeNsec)
-			require.NoError(t, err)
-
-			var stat Stat_t
-			require.NoError(t, Stat(tc.path, &stat))
-			if CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-}
-
-func TestUtimesNanoFile(t *testing.T) {
-	switch runtime.GOOS {
-	case "linux", "darwin": // supported
-	case "freebsd": // TODO: support freebsd w/o CGO
-	case "windows":
-		if !IsGo120 {
-			t.Skip("windows only works after Go 1.20") // TODO: possibly 1.19 ;)
-		}
-	default: // expect ENOSYS and callers need to fall back to UtimesNano
-		t.Skip("unsupported GOOS", runtime.GOOS)
-	}
-
-	tmpDir := t.TempDir()
-
-	file := path.Join(tmpDir, "file")
-	err := os.WriteFile(file, []byte{}, 0o700)
-	require.NoError(t, err)
-	fileF, err := OpenFile(file, syscall.O_RDWR, 0)
-	require.NoError(t, err)
-	defer fileF.Close()
-
-	dir := path.Join(tmpDir, "dir")
-	err = os.Mkdir(dir, 0o700)
-	require.NoError(t, err)
-	dirF, err := OpenFile(dir, syscall.O_RDONLY, 0)
-	require.NoError(t, err)
-	defer fileF.Close()
-
-	type test struct {
-		name                 string
-		file                 fs.File
-		atimeNsec, mtimeNsec int64
-		expectedErr          error
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []*test{
-		{
-			name:      "file positive",
-			file:      fileF,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", file: fileF},
-		{
-			name:      "dir positive",
-			file:      dirF,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "dir zero", file: dirF},
-	}
-
-	// In windows, trying to update the time of a directory fails, as it is
-	// addressed by path, not by file descriptor.
-	if runtime.GOOS == "windows" {
-		for _, tt := range tests {
-			if tt.file == dirF {
-				tt.expectedErr = syscall.EPERM
-			}
-		}
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := UtimesNanoFile(tc.file, tc.atimeNsec, tc.mtimeNsec)
-			if tc.expectedErr != nil {
-				require.EqualErrno(t, tc.expectedErr.(syscall.Errno), err)
-				return
-			}
-
-			var stat Stat_t
-			require.NoError(t, StatFile(tc.file, &stat))
-			if CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-
-	require.NoError(t, fileF.Close())
-	t.Run("closed file", func(t *testing.T) {
-		err := UtimesNanoFile(fileF,
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.EBADF, err)
-	})
-
-	require.NoError(t, dirF.Close())
-	t.Run("closed dir", func(t *testing.T) {
-		err := UtimesNanoFile(dirF,
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.EBADF, err)
-	})
-}
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 25f0d88a..06326494 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -91,7 +91,7 @@ func TestAdapt_UtimesNano(t *testing.T) {
 	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
-	err := testFS.UtimesNano(path, 1, 1)
+	err := testFS.Utimens(path, nil, true)
 	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 9753b626..6a68396e 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -8,6 +8,7 @@ import (
 	"runtime"
 	"syscall"
 	"testing"
+	"time"
 
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
@@ -498,11 +499,167 @@ func TestDirFS_Unlink(t *testing.T) {
 	})
 }
 
-func TestDirFS_UtimesNano(t *testing.T) {
+func TestDirFS_Utimesns(t *testing.T) {
 	tmpDir := t.TempDir()
 	testFS := NewDirFS(tmpDir)
 
-	testUtimesNano(t, tmpDir, testFS)
+	file := "file"
+	err := os.WriteFile(pathutil.Join(tmpDir, file), []byte{}, 0o700)
+	require.NoError(t, err)
+
+	dir := "dir"
+	err = os.Mkdir(pathutil.Join(tmpDir, dir), 0o700)
+	require.NoError(t, err)
+
+	t.Run("doesn't exist", func(t *testing.T) {
+		err := testFS.Utimens("nope", nil, true)
+		require.EqualErrno(t, syscall.ENOENT, err)
+		err = testFS.Utimens("nope", nil, false)
+		if platform.SupportsSymlinkNoFollow {
+			require.EqualErrno(t, syscall.ENOENT, err)
+		} else {
+			require.EqualErrno(t, syscall.ENOSYS, err)
+		}
+	})
+
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	//
+	// Negative isn't tested as most platforms don't return consistent results.
+	tests := []struct {
+		name  string
+		times *[2]syscall.Timespec
+	}{
+		{
+			name: "nil",
+		},
+		{
+			name: "a=omit,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=now,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=omit,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: 4 * 1e3},
+			},
+		},
+		{
+			name: "a=set,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=set,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 223, Nsec: 5 * 1e3},
+			},
+		},
+	}
+
+	for _, fileType := range []string{"dir", "file", "link", "link-follow"} {
+		for _, tt := range tests {
+			tc := tt
+			fileType := fileType
+			name := fileType + " " + tc.name
+			symlinkNoFollow := fileType == "link"
+
+			t.Run(name, func(t *testing.T) {
+				tmpDir := t.TempDir()
+				testFS := NewDirFS(tmpDir)
+
+				file := pathutil.Join(tmpDir, "file")
+				err := os.WriteFile(file, []byte{}, 0o700)
+				require.NoError(t, err)
+
+				link := file + "-link"
+				require.NoError(t, os.Symlink(file, link))
+
+				dir := pathutil.Join(tmpDir, "dir")
+				err = os.Mkdir(dir, 0o700)
+				require.NoError(t, err)
+
+				var path, statPath string
+				switch fileType {
+				case "dir":
+					path = "dir"
+					statPath = "dir"
+				case "file":
+					path = "file"
+					statPath = "file"
+				case "link":
+					path = "file-link"
+					statPath = "file-link"
+				case "link-follow":
+					path = "file-link"
+					statPath = "file"
+				default:
+					panic(tc)
+				}
+
+				var oldSt platform.Stat_t
+				require.NoError(t, testFS.Lstat(statPath, &oldSt))
+
+				err = testFS.Utimens(path, tc.times, !symlinkNoFollow)
+				if symlinkNoFollow && !platform.SupportsSymlinkNoFollow {
+					require.EqualErrno(t, syscall.ENOSYS, err)
+					return
+				}
+				require.NoError(t, err)
+
+				var newSt platform.Stat_t
+				require.NoError(t, testFS.Lstat(statPath, &newSt))
+
+				if platform.CompilerSupported() {
+					if tc.times != nil && tc.times[0].Nsec == platform.UTIME_OMIT {
+						require.Equal(t, oldSt.Atim, newSt.Atim)
+					} else if tc.times == nil || tc.times[0].Nsec == platform.UTIME_NOW {
+						now := time.Now().UnixNano()
+						require.True(t, newSt.Atim <= now, "expected atim %d <= now %d", newSt.Atim, now)
+					} else {
+						require.Equal(t, tc.times[0].Nano(), newSt.Atim)
+					}
+				}
+
+				// When compiler isn't supported, we can still check mtim.
+				if tc.times != nil && tc.times[1].Nsec == platform.UTIME_OMIT {
+					require.Equal(t, oldSt.Mtim, newSt.Mtim)
+				} else if tc.times == nil || tc.times[1].Nsec == platform.UTIME_NOW {
+					now := time.Now().UnixNano()
+					require.True(t, newSt.Mtim <= now, "expected mtim %d <= now %d", newSt.Mtim, now)
+				} else {
+					require.Equal(t, tc.times[1].Nano(), newSt.Mtim)
+				}
+			})
+		}
+	}
 }
 
 func TestDirFS_OpenFile(t *testing.T) {
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index da7ebc31..518b19d5 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -122,7 +122,7 @@ func TestReadFS_UtimesNano(t *testing.T) {
 	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
-	err := testFS.UtimesNano(path, 1, 1)
+	err := testFS.Utimens(path, nil, true)
 	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 924facb7..0f7ed744 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -13,7 +13,6 @@ import (
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
-	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -389,65 +388,6 @@ func testReadlink(t *testing.T, readFS, writeFS FS) {
 	})
 }
 
-func testUtimesNano(t *testing.T, tmpDir string, testFS FS) {
-	file := "file"
-	err := os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700)
-	require.NoError(t, err)
-
-	dir := "dir"
-	err = os.Mkdir(path.Join(tmpDir, dir), 0o700)
-	require.NoError(t, err)
-
-	t.Run("doesn't exist", func(t *testing.T) {
-		err := testFS.UtimesNano("nope",
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.ENOENT, err)
-	})
-
-	type test struct {
-		name                 string
-		path                 string
-		atimeNsec, mtimeNsec int64
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []test{
-		{
-			name:      "file positive",
-			path:      file,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{
-			name:      "dir positive",
-			path:      dir,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", path: file},
-		{name: "dir zero", path: dir},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := testFS.UtimesNano(tc.path, tc.atimeNsec, tc.mtimeNsec)
-			require.NoError(t, err)
-
-			var stat platform.Stat_t
-			require.NoError(t, testFS.Stat(tc.path, &stat))
-			if platform.CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-}
-
 var (
 	//go:embed testdata
 	testdata     embed.FS
diff --git a/internal/testing/enginetest/enginetest.go b/internal/testing/enginetest/enginetest.go
index 111aa95a..cfe42b05 100644
--- a/internal/testing/enginetest/enginetest.go
+++ b/internal/testing/enginetest/enginetest.go
@@ -74,7 +74,10 @@ func RunTestEngine_MemoryGrowInRecursiveCall(t *testing.T, et EngineTester) {
 	err = s.Engine.CompileModule(testCtx, hm, nil, false)
 	require.NoError(t, err)
 
-	_, err = s.Instantiate(testCtx, hm, hostModuleName, nil)
+	typeIDs, err := s.GetFunctionTypeIDs(hm.TypeSection)
+	require.NoError(t, err)
+
+	_, err = s.Instantiate(testCtx, hm, hostModuleName, nil, typeIDs)
 	require.NoError(t, err)
 
 	m := &wasm.Module{
@@ -106,7 +109,10 @@ func RunTestEngine_MemoryGrowInRecursiveCall(t *testing.T, et EngineTester) {
 	err = s.Engine.CompileModule(testCtx, m, nil, false)
 	require.NoError(t, err)
 
-	inst, err := s.Instantiate(testCtx, m, t.Name(), nil)
+	typeIDs, err = s.GetFunctionTypeIDs(m.TypeSection)
+	require.NoError(t, err)
+
+	inst, err := s.Instantiate(testCtx, m, t.Name(), nil, typeIDs)
 	require.NoError(t, err)
 
 	growFn = inst.Function(2)
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index 4cec6e84..b0a0e616 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -39,7 +39,7 @@ func TestCallContext_String(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			// Ensure paths that can create the host module can see the name.
-			m, err := s.Instantiate(testCtx, &Module{}, tc.moduleName, nil)
+			m, err := s.Instantiate(testCtx, &Module{}, tc.moduleName, nil, nil)
 			defer m.Close(testCtx) //nolint
 
 			require.NoError(t, err)
@@ -78,7 +78,7 @@ func TestCallContext_Close(t *testing.T) {
 		t.Run(fmt.Sprintf("%s calls ns.CloseWithExitCode(module.name))", tc.name), func(t *testing.T) {
 			for _, ctx := range []context.Context{nil, testCtx} { // Ensure it doesn't crash on nil!
 				moduleName := t.Name()
-				m, err := s.Instantiate(ctx, &Module{}, moduleName, nil)
+				m, err := s.Instantiate(ctx, &Module{}, moduleName, nil, nil)
 				require.NoError(t, err)
 
 				// We use side effects to see if Close called ns.CloseWithExitCode (without repeating store_test.go).
@@ -107,7 +107,7 @@ func TestCallContext_Close(t *testing.T) {
 		_, err := fsCtx.OpenFile(testFS, "/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
-		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx)
+		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
 		// We use side effects to determine if Close in fact called Context.Close (without repeating sys_test.go).
@@ -142,7 +142,7 @@ func TestCallContext_Close(t *testing.T) {
 		_, err := fsCtx.OpenFile(testFS, "/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
-		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx)
+		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
 		require.EqualError(t, m.Close(testCtx), "error closing")
@@ -182,7 +182,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		t.Run(fmt.Sprintf("%s calls ns.CloseWithExitCode(module.name))", tc.name), func(t *testing.T) {
 			for _, ctx := range []context.Context{nil, testCtx} { // Ensure it doesn't crash on nil!
 				moduleName := t.Name()
-				m, err := s.Instantiate(ctx, &Module{}, moduleName, nil)
+				m, err := s.Instantiate(ctx, &Module{}, moduleName, nil, nil)
 				require.NoError(t, err)
 
 				// We use side effects to see if Close called ns.CloseWithExitCode (without repeating store_test.go).
@@ -211,7 +211,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		_, err := fsCtx.OpenFile(testFS, "/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
-		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx)
+		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
 		// We use side effects to determine if Close in fact called Context.Close (without repeating sys_test.go).
@@ -240,7 +240,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		_, err := fsCtx.OpenFile(testFS, path, os.O_RDONLY, 0)
 		require.NoError(t, err)
 
-		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx)
+		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
 		require.EqualError(t, m.Close(testCtx), "error closing")
diff --git a/internal/wasm/global_test.go b/internal/wasm/global_test.go
index a0e5327a..7cb95cb6 100644
--- a/internal/wasm/global_test.go
+++ b/internal/wasm/global_test.go
@@ -296,7 +296,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(), nil)
+			module, err := s.Instantiate(context.Background(), tc.module, t.Name(), nil, nil)
 			require.NoError(t, err)
 
 			if global := module.ExportedGlobal("global"); tc.expected != nil {
diff --git a/internal/wasm/store_test.go b/internal/wasm/store_test.go
index 5643bc1f..ba5fc476 100644
--- a/internal/wasm/store_test.go
+++ b/internal/wasm/store_test.go
@@ -80,7 +80,7 @@ func TestModuleInstance_Memory(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			s := newStore()
 
-			instance, err := s.Instantiate(testCtx, tc.input, "test", nil)
+			instance, err := s.Instantiate(testCtx, tc.input, "test", nil, nil)
 			require.NoError(t, err)
 
 			mem := instance.ExportedMemory("memory")
@@ -109,7 +109,7 @@ func TestStore_Instantiate(t *testing.T) {
 	require.NoError(t, err)
 
 	sysCtx := sys.DefaultContext(nil)
-	mod, err := s.Instantiate(testCtx, m, "", sysCtx)
+	mod, err := s.Instantiate(testCtx, m, "", sysCtx, []FunctionTypeID{0})
 	require.NoError(t, err)
 	defer mod.Close(testCtx)
 
@@ -150,7 +150,7 @@ func TestStore_CloseWithExitCode(t *testing.T) {
 				CodeSection:               []*Code{{Body: []byte{OpcodeEnd}}},
 				ExportSection:             []*Export{{Type: ExternTypeFunc, Index: 0, Name: "fn"}},
 				FunctionDefinitionSection: []*FunctionDefinition{{funcType: v_v}},
-			}, importedModuleName, nil)
+			}, importedModuleName, nil, []FunctionTypeID{0})
 			require.NoError(t, err)
 
 			m2, err := s.Instantiate(testCtx, &Module{
@@ -160,7 +160,7 @@ func TestStore_CloseWithExitCode(t *testing.T) {
 				MemoryDefinitionSection: []*MemoryDefinition{{}},
 				GlobalSection:           []*Global{{Type: &GlobalType{}, Init: &ConstantExpression{Opcode: OpcodeI32Const, Data: const1}}},
 				TableSection:            []*Table{{Min: 10}},
-			}, importingModuleName, nil)
+			}, importingModuleName, nil, []FunctionTypeID{0})
 			require.NoError(t, err)
 
 			if tc.testClosed {
@@ -187,7 +187,7 @@ func TestStore_hammer(t *testing.T) {
 	require.NoError(t, err)
 
 	s := newStore()
-	imported, err := s.Instantiate(testCtx, m, importedModuleName, nil)
+	imported, err := s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 	require.NoError(t, err)
 
 	_, ok := s.nameToNode[imported.Name()]
@@ -219,7 +219,7 @@ func TestStore_hammer(t *testing.T) {
 		N = 100
 	}
 	hammer.NewHammer(t, P, N).Run(func(name string) {
-		mod, instantiateErr := s.Instantiate(testCtx, importingModule, name, sys.DefaultContext(nil))
+		mod, instantiateErr := s.Instantiate(testCtx, importingModule, name, sys.DefaultContext(nil), []FunctionTypeID{0})
 		require.NoError(t, instantiateErr)
 		require.NoError(t, mod.Close(testCtx))
 	}, nil)
@@ -241,7 +241,7 @@ func TestStore_hammer_close(t *testing.T) {
 	require.NoError(t, err)
 
 	s := newStore()
-	imported, err := s.Instantiate(testCtx, m, importedModuleName, nil)
+	imported, err := s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 	require.NoError(t, err)
 
 	_, ok := s.nameToNode[imported.Name()]
@@ -267,7 +267,7 @@ func TestStore_hammer_close(t *testing.T) {
 	const instCount = 10000
 	instances := make([]api.Module, instCount)
 	for i := 0; i < instCount; i++ {
-		mod, instantiateErr := s.Instantiate(testCtx, importingModule, strconv.Itoa(i), sys.DefaultContext(nil))
+		mod, instantiateErr := s.Instantiate(testCtx, importingModule, strconv.Itoa(i), sys.DefaultContext(nil), []FunctionTypeID{0})
 		require.NoError(t, instantiateErr)
 		instances[i] = mod
 	}
@@ -301,17 +301,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(testCtx, m, importedModuleName, nil)
+		_, err = s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 		require.NoError(t, err)
 
 		// Trying to register it again should fail
-		_, err = s.Instantiate(testCtx, m, importedModuleName, nil)
+		_, err = s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 		require.EqualError(t, err, "module[imported] has already been instantiated")
 	})
 
 	t.Run("fail resolve import", func(t *testing.T) {
 		s := newStore()
-		_, err = s.Instantiate(testCtx, m, importedModuleName, nil)
+		_, err = s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 		require.NoError(t, err)
 
 		hm := s.nameToNode[importedModuleName]
@@ -325,14 +325,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, nil)
+		}, importingModuleName, nil, nil)
 		require.EqualError(t, err, "module[non-exist] not instantiated")
 	})
 
 	t.Run("creating engine failed", func(t *testing.T) {
 		s := newStore()
 
-		_, err = s.Instantiate(testCtx, m, importedModuleName, nil)
+		_, err = s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 		require.NoError(t, err)
 
 		hm := s.nameToNode[importedModuleName]
@@ -354,7 +354,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 		}
 		importingModule.BuildFunctionDefinitions()
 
-		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil)
+		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil, []FunctionTypeID{0})
 		require.EqualError(t, err, "some engine creation error")
 	})
 
@@ -363,7 +363,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 		engine := s.Engine.(*mockEngine)
 		engine.callFailIndex = 1
 
-		_, err = s.Instantiate(testCtx, m, importedModuleName, nil)
+		_, err = s.Instantiate(testCtx, m, importedModuleName, nil, []FunctionTypeID{0})
 		require.NoError(t, err)
 
 		hm := s.nameToNode[importedModuleName]
@@ -381,7 +381,7 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 		}
 		importingModule.BuildFunctionDefinitions()
 
-		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil)
+		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil, []FunctionTypeID{0})
 		require.EqualError(t, err, "start function[1] failed: call failed")
 	})
 }
diff --git a/runtime_test.go b/runtime_test.go
index 27517323..18e08e11 100644
--- a/runtime_test.go
+++ b/runtime_test.go
@@ -44,32 +44,32 @@ func TestRuntime_CompileModule(t *testing.T) {
 	tests := []struct {
 		name          string
 		runtime       Runtime
-		wasm          []byte
+		wasm          *wasm.Module
 		moduleBuilder HostModuleBuilder
 		expected      func(CompiledModule)
 	}{
 		{
 			name: "no name section",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{}),
+			wasm: &wasm.Module{},
 		},
 		{
 			name: "empty NameSection.ModuleName",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{NameSection: &wasm.NameSection{}}),
+			wasm: &wasm.Module{NameSection: &wasm.NameSection{}},
 		},
 		{
 			name: "NameSection.ModuleName",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{NameSection: &wasm.NameSection{ModuleName: "test"}}),
+			wasm: &wasm.Module{NameSection: &wasm.NameSection{ModuleName: "test"}},
 			expected: func(compiled CompiledModule) {
 				require.Equal(t, "test", compiled.Name())
 			},
 		},
 		{
 			name: "FunctionSection, but not exported",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{
+			wasm: &wasm.Module{
 				TypeSection:     []*wasm.FunctionType{{Params: []api.ValueType{api.ValueTypeI32}}},
 				FunctionSection: []wasm.Index{0},
 				CodeSection:     []*wasm.Code{{Body: []byte{wasm.OpcodeEnd}}},
-			}),
+			},
 			expected: func(compiled CompiledModule) {
 				require.Nil(t, compiled.ImportedFunctions())
 				require.Zero(t, len(compiled.ExportedFunctions()))
@@ -77,7 +77,7 @@ func TestRuntime_CompileModule(t *testing.T) {
 		},
 		{
 			name: "FunctionSection exported",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{
+			wasm: &wasm.Module{
 				TypeSection:     []*wasm.FunctionType{{Params: []api.ValueType{api.ValueTypeI32}}},
 				FunctionSection: []wasm.Index{0},
 				CodeSection:     []*wasm.Code{{Body: []byte{wasm.OpcodeEnd}}},
@@ -86,7 +86,7 @@ func TestRuntime_CompileModule(t *testing.T) {
 					Name:  "function",
 					Index: 0,
 				}},
-			}),
+			},
 			expected: func(compiled CompiledModule) {
 				require.Nil(t, compiled.ImportedFunctions())
 				f := compiled.ExportedFunctions()["function"]
@@ -95,9 +95,9 @@ func TestRuntime_CompileModule(t *testing.T) {
 		},
 		{
 			name: "MemorySection, but not exported",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{
+			wasm: &wasm.Module{
 				MemorySection: &wasm.Memory{Min: 2, Max: 3, IsMaxEncoded: true},
-			}),
+			},
 			expected: func(compiled CompiledModule) {
 				require.Nil(t, compiled.ImportedMemories())
 				require.Zero(t, len(compiled.ExportedMemories()))
@@ -105,14 +105,14 @@ func TestRuntime_CompileModule(t *testing.T) {
 		},
 		{
 			name: "MemorySection exported",
-			wasm: binaryencoding.EncodeModule(&wasm.Module{
+			wasm: &wasm.Module{
 				MemorySection: &wasm.Memory{Min: 2, Max: 3, IsMaxEncoded: true},
 				ExportSection: []*wasm.Export{{
 					Type:  wasm.ExternTypeMemory,
 					Name:  "memory",
 					Index: 0,
 				}},
-			}),
+			},
 			expected: func(compiled CompiledModule) {
 				require.Nil(t, compiled.ImportedMemories())
 				mem := compiled.ExportedMemories()["memory"]
@@ -124,20 +124,29 @@ func TestRuntime_CompileModule(t *testing.T) {
 		},
 	}
 
-	r := NewRuntime(testCtx)
-	defer r.Close(testCtx)
+	_r := NewRuntime(testCtx)
+	defer _r.Close(testCtx)
+
+	r := _r.(*runtime)
 
 	for _, tt := range tests {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			m, err := r.CompileModule(testCtx, tc.wasm)
+			bin := binaryencoding.EncodeModule(tc.wasm)
+
+			m, err := r.CompileModule(testCtx, bin)
 			require.NoError(t, err)
 			if tc.expected == nil {
 				tc.expected = func(CompiledModule) {}
 			}
 			tc.expected(m)
-			require.Equal(t, r.(*runtime).store.Engine, m.(*compiledModule).compiledEngine)
+			require.Equal(t, r.store.Engine, m.(*compiledModule).compiledEngine)
+
+			// TypeIDs must be assigned to compiledModule.
+			expTypeIDs, err := r.store.GetFunctionTypeIDs(tc.wasm.TypeSection)
+			require.NoError(t, err)
+			require.Equal(t, expTypeIDs, m.(*compiledModule).typeIDs)
 		})
 	}
 }