Repository: tetratelabs/wazero
Pull request #1439 (2023-05-06 00:23:03)

Adds Read and Pread to platform.File
This adds `Read` and `Pread` to `platform.File` backfilling some tests and porting others.

Notably, this no longer supports special casing `io.EOF` when the read buffer is exactly the length of bytes remaining in the file, obviating a follow-up read of length zero. The reason we can't do this internally is `io.EOF` is a special flow control error Go defines, not an actual syscall error. However, it is quite normal to not be able to handle this and the calling code likely would loop around another time for the same reason. Meanwhile the code is a lot less complex as we don't have to test the io.EOF edge case.

Another note is I left stdin alone as I know @achille-roussel and maybe @evacchi are working on non-blocking support there. I didn't want to distract that or cause drift. We can address it later, and meanwhile, I put in a TODO.

See #1417

evacchi: FYI: I am not working on non-blocking I/O myself yet, because I didn't want to overlap with @achille-roussel (actually we can sync offline)

Patch:
diff --git a/experimental/experimental.go b/experimental/experimental.go
index 1d917af7..47022f20 100644
--- a/experimental/experimental.go
+++ b/experimental/experimental.go
@@ -12,11 +12,11 @@ import (
 type InternalModule interface {
 	api.Module
 
-	// NumGlobal returns the count of all globals in the
-	// module.
+	// NumGlobal returns the count of all globals in the module.
 	NumGlobal() int
 
-	// Global provides a read-only view for a given global
-	// index. Panics if idx > GlobalsCount().
-	Global(idx int) api.Global
+	// Global provides a read-only view for a given global index.
+	//
+	// The methods panics if i is out of bounds.
+	Global(i int) api.Global
 }
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 26616996..305bffc9 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -2,7 +2,6 @@ package wasi_snapshot_preview1
 
 import (
 	"context"
-	"errors"
 	"io"
 	"io/fs"
 	"math"
@@ -692,6 +691,23 @@ var fdRead = newHostFunc(
 	"fd", "iovs", "iovs_len", "result.nread",
 )
 
+// preader tracks an offset across multiple reads.
+type preader struct {
+	f      platform.File
+	offset int64
+}
+
+// Read implements the same function as documented on platform.File.
+func (w *preader) Read(p []byte) (n int, errno syscall.Errno) {
+	if len(p) == 0 {
+		return 0, 0 // less overhead on zero-length reads.
+	}
+
+	n, err := w.f.Pread(p, w.offset)
+	w.offset += int64(n)
+	return n, err
+}
+
 func fdReadFn(_ context.Context, mod api.Module, params []uint64) syscall.Errno {
 	return fdReadOrPread(mod, params, false)
 }
@@ -701,23 +717,21 @@ func fdReadOrPread(mod api.Module, params []uint64, isPread bool) syscall.Errno
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 
 	fd := int32(params[0])
-
-	r, ok := fsc.LookupFile(fd)
-	if !ok {
-		return syscall.EBADF
-	}
-
-	var reader io.Reader = r.File.File()
-
 	iovs := uint32(params[1])
 	iovsCount := uint32(params[2])
 
 	var resultNread uint32
-	if isPread {
+	var reader func(p []byte) (n int, errno syscall.Errno)
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return syscall.EBADF
+	} else if f.File.AccessMode() == syscall.O_WRONLY {
+		return syscall.EBADF
+	} else if isPread {
 		offset := int64(params[3])
-		reader = sysfs.ReaderAtOffset(r.File.File(), offset)
+		reader = (&preader{f: f.File, offset: offset}).Read
 		resultNread = uint32(params[4])
 	} else {
+		reader = f.File.Read
 		resultNread = uint32(params[3])
 	}
 
@@ -741,14 +755,13 @@ func fdReadOrPread(mod api.Module, params []uint64, isPread bool) syscall.Errno
 			return syscall.EFAULT
 		}
 
-		n, err := reader.Read(b)
+		n, errno := reader(b)
 		nread += uint32(n)
 
-		shouldContinue, errno := fdRead_shouldContinueRead(uint32(n), l, err)
 		if errno != 0 {
 			return errno
-		} else if !shouldContinue {
-			break
+		} else if n < int(l) {
+			break // stop when we read less than capacity.
 		}
 	}
 	if !mem.WriteUint32Le(resultNread, nread) {
@@ -758,23 +771,6 @@ func fdReadOrPread(mod api.Module, params []uint64, isPread bool) syscall.Errno
 	}
 }
 
-// fdRead_shouldContinueRead decides whether to continue reading the next iovec
-// based on the amount read (n/l) and a possible error returned from io.Reader.
-//
-// Note: When there are both bytes read (n) and an error, this continues.
-// See /RATIONALE.md "Why ignore the error returned by io.Reader when n > 1?"
-func fdRead_shouldContinueRead(n, l uint32, err error) (bool, syscall.Errno) {
-	if errors.Is(err, io.EOF) {
-		return false, 0 // EOF isn't an error, and we shouldn't continue.
-	} else if err != nil && n == 0 {
-		return false, syscall.EIO
-	} else if err != nil {
-		return false, 0 // Allow the caller to process n bytes.
-	}
-	// Continue reading, unless there's a partial read or nothing to read.
-	return n == l && n != 0, 0
-}
-
 // fdReaddir is the WASI function named FdReaddirName which reads directory
 // entries from a directory.
 //
diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go
index 1a929907..37e6a776 100644
--- a/imports/wasi_snapshot_preview1/poll.go
+++ b/imports/wasi_snapshot_preview1/poll.go
@@ -255,6 +255,9 @@ func writeEvent(outBuf []byte, evt *event) {
 func getStdioFileReader(mod api.Module) *internalsys.StdioFileReader {
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 	if file, ok := fsc.LookupFile(internalsys.FdStdin); ok {
+		// TODO: Make a new StdioFile which implements platform.File similar
+		// to lazyDir. This can add an additional function needed here, so we'd
+		// end up casting file.File not file.File.File()
 		if reader, typeOk := file.File.File().(*internalsys.StdioFileReader); typeOk {
 			return reader
 		}
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 42111b73..ccab42d6 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -3,7 +3,6 @@ package gojs
 import (
 	"context"
 	"fmt"
-	"io"
 	"os"
 	"syscall"
 
@@ -13,7 +12,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/gojs/util"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
-	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
 
@@ -233,33 +231,26 @@ func (jsfsRead) invoke(ctx context.Context, mod api.Module, args ...interface{})
 	fOffset := args[4] // nil unless Pread
 	callback := args[5].(funcWrapper)
 
-	n, err := syscallRead(mod, fd, fOffset, buf.Unwrap()[offset:offset+byteCount])
-	return callback.invoke(ctx, mod, goos.RefJsfs, err, n) // note: error first
+	var err error
+	n, errno := syscallRead(mod, fd, fOffset, buf.Unwrap()[offset:offset+byteCount])
+	if errno != 0 {
+		err = errno
+	}
+	// It is safe to cast to uint32 because n <= uint32(byteCount).
+	return callback.invoke(ctx, mod, goos.RefJsfs, err, uint32(n)) // note: error first
 }
 
 // syscallRead is like syscall.Read
-func syscallRead(mod api.Module, fd int32, offset interface{}, p []byte) (n uint32, err error) {
+func syscallRead(mod api.Module, fd int32, offset interface{}, p []byte) (n int, errno syscall.Errno) {
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 
-	f, ok := fsc.LookupFile(fd)
-	if !ok {
-		err = syscall.EBADF
-	}
-
-	var reader io.Reader = f.File.File()
-
-	if offset != nil {
-		reader = sysfs.ReaderAtOffset(f.File.File(), toInt64(offset))
-	}
-
-	if nRead, e := reader.Read(p); e == nil || e == io.EOF {
-		// fs_js.go cannot parse io.EOF so coerce it to nil.
-		// See https://github.com/golang/go/issues/43913
-		n = uint32(nRead)
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return 0, syscall.EBADF
+	} else if offset != nil {
+		return f.File.Pread(p, toInt64(offset))
 	} else {
-		err = e
+		return f.File.Read(p)
 	}
-	return
 }
 
 // jsfsWrite implements jsFn for syscall.Write and syscall.Pwrite.
diff --git a/internal/platform/error.go b/internal/platform/error.go
index 82ed9b6c..3219e526 100644
--- a/internal/platform/error.go
+++ b/internal/platform/error.go
@@ -21,7 +21,7 @@ func UnwrapOSError(err error) syscall.Errno {
 	// Note: Once we have our own file type, we should never see these.
 	switch err {
 	case nil, io.EOF:
-		return 0
+		return 0 // EOF is not a syscall.Errno
 	case fs.ErrInvalid:
 		return syscall.EINVAL
 	case fs.ErrPermission:
diff --git a/internal/platform/file.go b/internal/platform/file.go
index 1a5aadf3..6aad4b23 100644
--- a/internal/platform/file.go
+++ b/internal/platform/file.go
@@ -58,37 +58,90 @@ type File interface {
 	//   - Windows allows you to stat a closed directory.
 	Stat() (Stat_t, syscall.Errno)
 
-	// Chmod changes the mode of the file.
+	// Read attempts to read all bytes in the file into `p`, and returns the
+	// count read even on error.
 	//
 	// # Errors
 	//
 	// A zero syscall.Errno is success. The below are expected otherwise:
 	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EBADF: the file or directory was closed.
+	//   - syscall.EBADF: the file or directory was closed or not readable.
+	//   - syscall.EISDIR: the file was a directory.
 	//
 	// # Notes
 	//
-	//   - This is like syscall.Fchmod and `fchmod` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchmod.html
-	//   - Windows ignores the execute bit, and any permissions come back as
-	//     group and world. For example, chmod of 0400 reads back as 0444, and
-	//     0700 0666. Also, permissions on directories aren't supported at all.
-	Chmod(fs.FileMode) syscall.Errno
+	//   - This is like io.Reader and `read` in POSIX, preferring semantics of
+	//     io.Reader. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/read.html
+	//   - Unlike io.Reader, there is no io.EOF returned on end-of-file. To
+	//     read the file completely, the caller must repeat until `n` is zero.
+	Read(p []byte) (n int, errno syscall.Errno)
 
-	// Chown changes the owner and group of a file.
+	// Pread attempts to read all bytes in the file into `p`, starting at the
+	// offset `off`, and returns the count read even on error.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EBADF: the file or directory was closed or not readable.
+	//   - syscall.EINVAL: the offset was negative.
+	//   - syscall.EISDIR: the file was a directory.
+	//
+	// # Notes
+	//
+	//   - This is like io.ReaderAt and `pread` in POSIX, preferring semantics
+	//     of io.ReaderAt. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/pread.html
+	//   - Unlike io.ReaderAt, there is no io.EOF returned on end-of-file. To
+	//     read the file completely, the caller must repeat until `n` is zero.
+	Pread(p []byte, off int64) (n int, errno syscall.Errno)
+
+	// Write attempts to write all bytes in `p` to the file, and returns the
+	// count written even on error.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EBADF: the file or directory was closed or not writeable.
+	//
+	// # Notes
+	//
+	//   - This is like io.Writer and `write` in POSIX, preferring semantics of
+	//     io.Writer. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/write.html
+	Write(p []byte) (n int, errno syscall.Errno)
+
+	// Pwrite attempts to write all bytes in `p` to the file at the given
+	// offset `off`, and returns the count written even on error.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EBADF: the file or directory was closed or not writeable.
+	//   - syscall.EINVAL: the offset was negative.
+	//
+	// # Notes
+	//
+	//   - This is like io.WriterAt and `pwrite` in POSIX, preferring semantics
+	//     of io.WriterAt. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/pwrite.html
+	Pwrite(p []byte, off int64) (n int, errno syscall.Errno)
+
+	// Truncate truncates a file to a specified length.
 	//
 	// # Errors
 	//
 	// A zero syscall.Errno is success. The below are expected otherwise:
 	//   - syscall.ENOSYS: the implementation does not support this function.
 	//   - syscall.EBADF: the file or directory was closed.
+	//   - syscall.EINVAL: the `size` is negative.
+	//   - syscall.EISDIR: the file was a directory.
 	//
 	// # Notes
 	//
-	//   - This is like syscall.Fchown and `fchown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchown.html
-	//   - This always returns syscall.ENOSYS on windows.
-	Chown(uid, gid int) syscall.Errno
+	//   - This is like syscall.Ftruncate and `ftruncate` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/ftruncate.html
+	//   - Windows does not error when calling Truncate on a closed file.
+	Truncate(size int64) syscall.Errno
 
 	// Sync synchronizes changes to the file.
 	//
@@ -122,53 +175,37 @@ type File interface {
 	//   - As this is commonly missing, some implementations dispatch to Sync.
 	Datasync() syscall.Errno
 
-	// Truncate truncates a file to a specified length.
+	// Chmod changes the mode of the file.
 	//
 	// # Errors
 	//
 	// A zero syscall.Errno is success. The below are expected otherwise:
 	//   - syscall.ENOSYS: the implementation does not support this function.
 	//   - syscall.EBADF: the file or directory was closed.
-	//   - syscall.EINVAL: the `size` is negative.
-	//   - syscall.EISDIR: the file was a directory.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Ftruncate and `ftruncate` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/ftruncate.html
-	//   - Windows does not error when calling Truncate on a closed file.
-	Truncate(size int64) syscall.Errno
-
-	// Write attempts to write all bytes in `p` to the file, and returns the
-	// count written even on error.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EBADF: the file or directory was closed or not writeable.
 	//
 	// # Notes
 	//
-	//   - This is like io.Writer and `write` in POSIX, preferring semantics of
-	//     io.Writer. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/write.html
-	Write(p []byte) (n int, errno syscall.Errno)
+	//   - This is like syscall.Fchmod and `fchmod` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchmod.html
+	//   - Windows ignores the execute bit, and any permissions come back as
+	//     group and world. For example, chmod of 0400 reads back as 0444, and
+	//     0700 0666. Also, permissions on directories aren't supported at all.
+	Chmod(fs.FileMode) syscall.Errno
 
-	// Pwrite attempts to write all bytes in `p` to the file at the given
-	// offset `off`, and returns the count written even on error.
+	// Chown changes the owner and group of a file.
 	//
 	// # Errors
 	//
 	// A zero syscall.Errno is success. The below are expected otherwise:
 	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EBADF: the file or directory was closed or not writeable.
-	//   - syscall.EINVAL: the offset was negative.
+	//   - syscall.EBADF: the file or directory was closed.
 	//
 	// # Notes
 	//
-	//   - This is like io.WriterAt and `pwrite` in POSIX, preferring semantics
-	//     of io.WriterAt. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/pwrite.html
-	Pwrite(p []byte, off int64) (n int, errno syscall.Errno)
+	//   - This is like syscall.Fchown and `fchown` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchown.html
+	//   - This always returns syscall.ENOSYS on windows.
+	Chown(uid, gid int) syscall.Errno
 
 	// Close closes the underlying file.
 	//
@@ -194,13 +231,28 @@ func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
 	return Stat_t{}, syscall.ENOSYS
 }
 
-// Chmod implements File.Chmod
-func (UnimplementedFile) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
+// Read implements File.Read
+func (UnimplementedFile) Read([]byte) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
 }
 
-// Chown implements File.Chown
-func (UnimplementedFile) Chown(int, int) syscall.Errno {
+// Pread implements File.Pread
+func (UnimplementedFile) Pread([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Write implements File.Write
+func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Pwrite implements File.Pwrite
+func (UnimplementedFile) Pwrite([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Truncate implements File.Truncate
+func (UnimplementedFile) Truncate(int64) syscall.Errno {
 	return syscall.ENOSYS
 }
 
@@ -214,19 +266,14 @@ func (UnimplementedFile) Datasync() syscall.Errno {
 	return 0 // not syscall.ENOSYS
 }
 
-// Truncate implements File.Truncate
-func (UnimplementedFile) Truncate(int64) syscall.Errno {
+// Chmod implements File.Chmod
+func (UnimplementedFile) Chmod(fs.FileMode) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Write implements File.Write
-func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Pwrite implements File.Pwrite
-func (UnimplementedFile) Pwrite([]byte, int64) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
+// Chown implements File.Chown
+func (UnimplementedFile) Chown(int, int) syscall.Errno {
+	return syscall.ENOSYS
 }
 
 func NewFsFile(openPath string, openFlag int, f fs.File) File {
@@ -262,48 +309,61 @@ func (f *fsFile) Stat() (Stat_t, syscall.Errno) {
 	return st, errno
 }
 
-// Chmod implements File.Chmod
-func (f *fsFile) Chmod(mode fs.FileMode) syscall.Errno {
-	if f, ok := f.file.(chmodFile); ok {
-		return UnwrapOSError(f.Chmod(mode))
+// Read implements File.Read
+func (f *fsFile) Read(p []byte) (n int, errno syscall.Errno) {
+	if len(p) == 0 {
+		return 0, 0 // less overhead on zero-length reads.
 	}
-	return syscall.ENOSYS
-}
 
-// Chown implements File.Chown
-func (f *fsFile) Chown(uid, gid int) syscall.Errno {
-	if f, ok := f.file.(fdFile); ok {
-		return fchown(f.Fd(), uid, gid)
+	if f.accessMode == syscall.O_WRONLY {
+		return 0, syscall.EBADF
 	}
-	return syscall.ENOSYS
+	if w, ok := f.File().(io.Reader); ok {
+		n, err := w.Read(p)
+		return n, UnwrapOSError(err)
+	}
+	return 0, syscall.EBADF
 }
 
-// Sync implements File.Sync
-func (f *fsFile) Sync() syscall.Errno {
-	return sync(f.file)
-}
+// Pread implements File.Pread
+func (f *fsFile) Pread(p []byte, off int64) (n int, errno syscall.Errno) {
+	if len(p) == 0 {
+		return 0, 0 // less overhead on zero-length reads.
+	}
 
-// Datasync implements File.Datasync
-func (f *fsFile) Datasync() syscall.Errno {
-	return datasync(f.file)
-}
+	if f.accessMode == syscall.O_WRONLY {
+		return 0, syscall.EBADF
+	}
 
-// Truncate implements File.Truncate
-func (f *fsFile) Truncate(size int64) syscall.Errno {
-	if tf, ok := f.file.(truncateFile); ok {
-		errno := UnwrapOSError(tf.Truncate(size))
-		if errno == 0 {
-			return 0
+	// Simple case, handle with io.ReaderAt.
+	if w, ok := f.File().(io.ReaderAt); ok {
+		n, err := w.ReadAt(p, off)
+		return n, UnwrapOSError(err)
+	}
+
+	// See /RATIONALE.md "fd_pread: io.Seeker fallback when io.ReaderAt is not supported"
+	if rs, ok := f.File().(io.ReadSeeker); ok {
+		// Determine the current position in the file, as we need to revert it.
+		currentOffset, err := rs.Seek(0, io.SeekCurrent)
+		if err != nil {
+			return 0, UnwrapOSError(err)
 		}
 
-		// Operating systems return different syscall.Errno instead of EISDIR
-		// double-check on any err until we can assure this per OS.
-		if isOpenDir(f) {
-			return syscall.EISDIR
+		// Put the read position back when complete.
+		defer func() { _, _ = rs.Seek(currentOffset, io.SeekStart) }()
+
+		// If the current offset isn't in sync with this reader, move it.
+		if off != currentOffset {
+			if _, err = rs.Seek(off, io.SeekStart); err != nil {
+				return 0, UnwrapOSError(err)
+			}
 		}
-		return errno
+
+		n, err := rs.Read(p)
+		return n, UnwrapOSError(err)
 	}
-	return syscall.ENOSYS
+
+	return 0, syscall.ENOSYS // unsupported
 }
 
 // Write implements File.Write
@@ -319,7 +379,7 @@ func (f *fsFile) Write(p []byte) (n int, errno syscall.Errno) {
 		n, err := w.Write(p)
 		return n, UnwrapOSError(err)
 	}
-	return 0, syscall.EBADF
+	return 0, syscall.ENOSYS // unsupported
 }
 
 // Pwrite implements File.Pwrite
@@ -328,11 +388,58 @@ func (f *fsFile) Pwrite(p []byte, off int64) (n int, errno syscall.Errno) {
 		return 0, 0 // less overhead on zero-length writes.
 	}
 
+	if f.accessMode == syscall.O_RDONLY {
+		return 0, syscall.EBADF
+	}
 	if w, ok := f.File().(io.WriterAt); ok {
 		n, err := w.WriteAt(p, off)
 		return n, UnwrapOSError(err)
 	}
-	return 0, syscall.EBADF
+	return 0, syscall.ENOSYS // unsupported
+}
+
+// Truncate implements File.Truncate
+func (f *fsFile) Truncate(size int64) syscall.Errno {
+	if tf, ok := f.file.(truncateFile); ok {
+		errno := UnwrapOSError(tf.Truncate(size))
+		if errno == 0 {
+			return 0
+		}
+
+		// Operating systems return different syscall.Errno instead of EISDIR
+		// double-check on any err until we can assure this per OS.
+		if isOpenDir(f) {
+			return syscall.EISDIR
+		}
+		return errno
+	}
+	return syscall.ENOSYS
+}
+
+// Sync implements File.Sync
+func (f *fsFile) Sync() syscall.Errno {
+	return sync(f.file)
+}
+
+// Datasync implements File.Datasync
+func (f *fsFile) Datasync() syscall.Errno {
+	return datasync(f.file)
+}
+
+// Chmod implements File.Chmod
+func (f *fsFile) Chmod(mode fs.FileMode) syscall.Errno {
+	if f, ok := f.file.(chmodFile); ok {
+		return UnwrapOSError(f.Chmod(mode))
+	}
+	return syscall.ENOSYS
+}
+
+// Chown implements File.Chown
+func (f *fsFile) Chown(uid, gid int) syscall.Errno {
+	if f, ok := f.file.(fdFile); ok {
+		return fchown(f.Fd(), uid, gid)
+	}
+	return syscall.ENOSYS
 }
 
 // Close implements File.Close
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 4d6aff16..a58f4590 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -179,22 +179,29 @@ func (r *lazyDir) Stat() (platform.Stat_t, syscall.Errno) {
 	}
 }
 
-// Chmod implements the same method as documented on platform.File
-func (r *lazyDir) Chmod(mode fs.FileMode) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chmod(mode)
-	}
+// Read implements the same method as documented on platform.File
+func (r *lazyDir) Read([]byte) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
 }
 
-// Chown implements the same method as documented on platform.File
-func (r *lazyDir) Chown(uid, gid int) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chown(uid, gid)
-	}
+// Pread implements the same method as documented on platform.File
+func (r *lazyDir) Pread([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// Write implements the same method as documented on platform.File
+func (r *lazyDir) Write([]byte) (int, syscall.Errno) {
+	return 0, syscall.EBADF
+}
+
+// Pwrite implements the same method as documented on platform.File
+func (r *lazyDir) Pwrite([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.EBADF
+}
+
+// Truncate implements the same method as documented on platform.File
+func (r *lazyDir) Truncate(int64) syscall.Errno {
+	return syscall.EISDIR
 }
 
 // Sync implements the same method as documented on platform.File
@@ -215,19 +222,22 @@ func (r *lazyDir) Datasync() syscall.Errno {
 	}
 }
 
-// Truncate implements the same method as documented on platform.File
-func (r *lazyDir) Truncate(int64) syscall.Errno {
-	return syscall.EISDIR
-}
-
-// Write implements the same method as documented on platform.File
-func (r *lazyDir) Write([]byte) (int, syscall.Errno) {
-	return 0, syscall.EBADF
+// Chmod implements the same method as documented on platform.File
+func (r *lazyDir) Chmod(mode fs.FileMode) syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Chmod(mode)
+	}
 }
 
-// Pwrite implements the same method as documented on platform.File
-func (r *lazyDir) Pwrite([]byte, int64) (int, syscall.Errno) {
-	return 0, syscall.EBADF
+// Chown implements the same method as documented on platform.File
+func (r *lazyDir) Chown(uid, gid int) syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Chown(uid, gid)
+	}
 }
 
 // File implements the same method as documented on platform.File
@@ -256,15 +266,6 @@ func (r *lazyDir) file() (platform.File, bool) {
 	}
 }
 
-// Read implements fs.File
-func (r *lazyDir) Read(p []byte) (n int, err error) {
-	if f, ok := r.file(); !ok {
-		return 0, syscall.EBADF
-	} else {
-		return f.File().Read(p)
-	}
-}
-
 // Close implements fs.File
 func (r *lazyDir) Close() syscall.Errno {
 	f := r.f
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index e3af0a10..0d2e0d1a 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -67,6 +67,7 @@ func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.Fil
 	if errno != 0 {
 		return nil, errno
 	}
+	// TODO: make a read-only FsFile when all methods complete in #1417
 	return platform.NewFsFile(path, flag, maskForReads(f.File())), 0
 }
 
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index 95d73a5f..fbad8565 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -6,7 +6,6 @@
 package sysfs
 
 import (
-	"io"
 	"io/fs"
 	"syscall"
 
@@ -372,81 +371,3 @@ type FS interface {
 	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
 	Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno
 }
-
-// ReaderAtOffset gets an io.Reader from a fs.File that reads from an offset,
-// yet doesn't affect the underlying position. This is used to implement
-// syscall.Pread.
-//
-// Note: The file accessed shouldn't be used concurrently, but wasm isn't safe
-// to use concurrently anyway. Hence, we don't do any locking against parallel
-// reads.
-func ReaderAtOffset(f fs.File, offset int64) io.Reader {
-	if ret, ok := f.(io.ReaderAt); ok {
-		return &readerAtOffset{ret, offset}
-	} else if ret, ok := f.(io.ReadSeeker); ok {
-		return &seekToOffsetReader{ret, offset}
-	} else {
-		return enosysReader{}
-	}
-}
-
-type enosysReader struct{}
-
-// enosysReader implements io.Reader
-func (rs enosysReader) Read([]byte) (n int, err error) {
-	return 0, syscall.ENOSYS
-}
-
-type readerAtOffset struct {
-	r      io.ReaderAt
-	offset int64
-}
-
-// Read implements io.Reader
-func (r *readerAtOffset) Read(p []byte) (int, error) {
-	if len(p) == 0 {
-		return 0, nil // less overhead on zero-length reads.
-	}
-
-	n, err := r.r.ReadAt(p, r.offset)
-	r.offset += int64(n)
-	return n, err
-}
-
-// seekToOffsetReader implements io.Reader that seeks to an offset and reverts
-// to its initial offset after each call to Read.
-//
-// See /RATIONALE.md "fd_pread: io.Seeker fallback when io.ReaderAt is not supported"
-type seekToOffsetReader struct {
-	s      io.ReadSeeker
-	offset int64
-}
-
-// Read implements io.Reader
-func (rs *seekToOffsetReader) Read(p []byte) (int, error) {
-	if len(p) == 0 {
-		return 0, nil // less overhead on zero-length reads.
-	}
-
-	// Determine the current position in the file, as we need to revert it.
-	currentOffset, err := rs.s.Seek(0, io.SeekCurrent)
-	if err != nil {
-		return 0, err
-	}
-
-	// Put the read position back when complete.
-	defer func() { _, _ = rs.s.Seek(currentOffset, io.SeekStart) }()
-
-	// If the current offset isn't in sync with this reader, move it.
-	if rs.offset != currentOffset {
-		_, err := rs.s.Seek(rs.offset, io.SeekStart)
-		if err != nil {
-			return 0, err
-		}
-	}
-
-	// Perform the read, updating the offset.
-	n, err := rs.s.Read(p)
-	rs.offset += int64(n)
-	return n, err
-}


Test patch:
diff --git a/experimental/logging/log_listener_test.go b/experimental/logging/log_listener_test.go
index 8ed79258..7aa684c8 100644
--- a/experimental/logging/log_listener_test.go
+++ b/experimental/logging/log_listener_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental/logging"
+	"github.com/tetratelabs/wazero/experimental/wazerotest"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	wasi "github.com/tetratelabs/wazero/internal/wasip1"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -347,3 +348,25 @@ func Test_loggingListener_indentation(t *testing.T) {
 <--
 `, out.String())
 }
+
+func BenchmarkLoggingListener(b *testing.B) {
+	module := wazerotest.NewModule(
+		wazerotest.NewMemory(0),
+		wazerotest.NewFunction(func(ctx context.Context, mod api.Module) {}),
+	)
+
+	function := module.Function(0)
+	factory := logging.NewLoggingListenerFactory(discard{})
+	listener := factory.NewListener(function.Definition())
+
+	stack := []wazerotest.StackFrame{
+		{Function: function},
+	}
+
+	wazerotest.BenchmarkFunctionListener(b, module, stack, listener)
+}
+
+type discard struct{}
+
+func (discard) Write(b []byte) (int, error)       { return len(b), nil }
+func (discard) WriteString(s string) (int, error) { return len(s), nil }
diff --git a/experimental/wazerotest/wazerotest.go b/experimental/wazerotest/wazerotest.go
new file mode 100644
index 00000000..953a31b4
--- /dev/null
+++ b/experimental/wazerotest/wazerotest.go
@@ -0,0 +1,727 @@
+package wazerotest
+
+import (
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"math"
+	"reflect"
+	"strconv"
+	"sync"
+	"sync/atomic"
+	"testing"
+
+	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/experimental"
+	"github.com/tetratelabs/wazero/internal/internalapi"
+	"github.com/tetratelabs/wazero/sys"
+)
+
+const (
+	exitStatusMarker = 1 << 63
+)
+
+// Module is an implementation of the api.Module interface, it represents a
+// WebAssembly module.
+type Module struct {
+	internalapi.WazeroOnlyType
+	exitStatus uint64
+
+	// The module name that will be returned by calling the Name method.
+	ModuleName string
+
+	// The list of functions of the module. Functions with a non-empty export
+	// names will be exported by the module.
+	Functions []*Function
+
+	// The list of globals of the module. Global
+	Globals []*Global
+
+	// The program memory. If non-nil, the memory is automatically exported as
+	// "memory".
+	ExportMemory *Memory
+
+	once                        sync.Once
+	exportedFunctions           map[string]api.Function
+	exportedFunctionDefinitions map[string]api.FunctionDefinition
+	exportedGlobals             map[string]api.Global
+	exportedMemoryDefinitions   map[string]api.MemoryDefinition
+}
+
+// NewModule constructs a Module object with the given memory and function list.
+func NewModule(memory *Memory, functions ...*Function) *Module {
+	return &Module{Functions: functions, ExportMemory: memory}
+}
+
+func (m *Module) String() string {
+	return "module[" + m.ModuleName + "]"
+}
+
+func (m *Module) Name() string {
+	return m.ModuleName
+}
+
+func (m *Module) Memory() api.Memory {
+	if m.ExportMemory != nil {
+		m.once.Do(m.initialize)
+		return m.ExportMemory
+	}
+	return nil
+}
+
+func (m *Module) ExportedFunction(name string) api.Function {
+	m.once.Do(m.initialize)
+	return m.exportedFunctions[name]
+}
+
+func (m *Module) ExportedFunctionDefinitions() map[string]api.FunctionDefinition {
+	m.once.Do(m.initialize)
+	return m.exportedFunctionDefinitions
+}
+
+func (m *Module) ExportedMemory(name string) api.Memory {
+	if m.ExportMemory != nil && name == "memory" {
+		m.once.Do(m.initialize)
+		return m.ExportMemory
+	}
+	return nil
+}
+
+func (m *Module) ExportedMemoryDefinitions() map[string]api.MemoryDefinition {
+	m.once.Do(m.initialize)
+	return m.exportedMemoryDefinitions
+}
+
+func (m *Module) ExportedGlobal(name string) api.Global {
+	m.once.Do(m.initialize)
+	return m.exportedGlobals[name]
+}
+
+func (m *Module) NumFunction() int {
+	return len(m.Functions)
+}
+
+func (m *Module) Function(i int) api.Function {
+	m.once.Do(m.initialize)
+	return m.Functions[i]
+}
+
+func (m *Module) NumGlobal() int {
+	return len(m.Globals)
+}
+
+func (m *Module) Global(i int) api.Global {
+	m.once.Do(m.initialize)
+	return m.Globals[i]
+}
+
+func (m *Module) Close(ctx context.Context) error {
+	return m.CloseWithExitCode(ctx, 0)
+}
+
+func (m *Module) CloseWithExitCode(ctx context.Context, exitCode uint32) error {
+	atomic.CompareAndSwapUint64(&m.exitStatus, 0, exitStatusMarker|uint64(exitCode))
+	return nil
+}
+
+func (m *Module) ExitStatus() (exitCode uint32, exited bool) {
+	exitStatus := atomic.LoadUint64(&m.exitStatus)
+	return uint32(exitStatus), exitStatus != 0
+}
+
+func (m *Module) initialize() {
+	m.exportedFunctions = make(map[string]api.Function)
+	m.exportedFunctionDefinitions = make(map[string]api.FunctionDefinition)
+	m.exportedGlobals = make(map[string]api.Global)
+	m.exportedMemoryDefinitions = make(map[string]api.MemoryDefinition)
+
+	for index, function := range m.Functions {
+		for _, exportName := range function.ExportNames {
+			m.exportedFunctions[exportName] = function
+			m.exportedFunctionDefinitions[exportName] = function.Definition()
+		}
+		function.module = m
+		function.index = index
+	}
+
+	for _, global := range m.Globals {
+		for _, exportName := range global.ExportNames {
+			m.exportedGlobals[exportName] = global
+		}
+	}
+
+	if m.ExportMemory != nil {
+		m.ExportMemory.module = m
+		m.exportedMemoryDefinitions["memory"] = m.ExportMemory.Definition()
+	}
+}
+
+// Global is an implementation of the api.Global interface, it represents a
+// global in a WebAssembly module.
+type Global struct {
+	internalapi.WazeroOnlyType
+
+	// Type of the global value, used to interpret bits of the Value field.
+	ValueType api.ValueType
+
+	// Value of the global packed in a 64 bits field.
+	Value uint64
+
+	// List of names that the globla is exported as.
+	ExportNames []string
+}
+
+func (g *Global) String() string {
+	switch g.ValueType {
+	case api.ValueTypeI32:
+		return strconv.FormatInt(int64(api.DecodeI32(g.Value)), 10)
+	case api.ValueTypeI64:
+		return strconv.FormatInt(int64(g.Value), 10)
+	case api.ValueTypeF32:
+		return strconv.FormatFloat(float64(api.DecodeF32(g.Value)), 'g', -1, 32)
+	case api.ValueTypeF64:
+		return strconv.FormatFloat(api.DecodeF64(g.Value), 'g', -1, 64)
+	default:
+		return "0x" + strconv.FormatUint(g.Value, 16)
+	}
+}
+
+func (g *Global) Type() api.ValueType {
+	return g.ValueType
+}
+
+func (g *Global) Get() uint64 {
+	return g.Value
+}
+
+func GlobalI32(value int32, export ...string) *Global {
+	return &Global{ValueType: api.ValueTypeI32, Value: api.EncodeI32(value), ExportNames: export}
+}
+
+func GlobalI64(value int64, export ...string) *Global {
+	return &Global{ValueType: api.ValueTypeI64, Value: api.EncodeI64(value), ExportNames: export}
+}
+
+func GlobalF32(value float32, export ...string) *Global {
+	return &Global{ValueType: api.ValueTypeF32, Value: api.EncodeF32(value), ExportNames: export}
+}
+
+func GlobalF64(value float64, export ...string) *Global {
+	return &Global{ValueType: api.ValueTypeF64, Value: api.EncodeF64(value), ExportNames: export}
+}
+
+// Function is an implementation of the api.Function interface, it represents
+// a function in a WebAssembly module.
+//
+// Until accessed through a Module's method, the function definition's
+// ModuleName method returns an empty string and its Index method returns 0.
+type Function struct {
+	internalapi.WazeroOnlyType
+
+	// GoModuleFunction may be set to a non-nil value to allow calling of the
+	// function via Call or CallWithStack.
+	//
+	// It is the user's responsibility to ensure that the signature of this
+	// implementation matches the ParamTypes and ResultTypes fields.
+	GoModuleFunction api.GoModuleFunction
+
+	// Type lists representing the function signature. Those fields should be
+	// set for the function to be properly constructed. The Function's Call
+	// and CallWithStack methods will error if those fields are nil.
+	ParamTypes  []api.ValueType
+	ResultTypes []api.ValueType
+
+	// Sets of names associated with the function. It is valid to leave those
+	// names empty, they are only used for debugging purposes.
+	FunctionName string
+	DebugName    string
+	ParamNames   []string
+	ResultNames  []string
+	ExportNames  []string
+
+	// Lazily initialized when accessed through the module.
+	module *Module
+	index  int
+}
+
+// NewFunction constructs a Function object from a Go function.
+//
+// The function fn must accept at least two arguments of type context.Context
+// and api.Module. Any other arguments and return values must be of type uint32,
+// uint64, int32, int64, float32, or float64. The call panics if fn is not a Go
+// functionn or has an unsupported signature.
+func NewFunction(fn any) *Function {
+	functionType := reflect.TypeOf(fn)
+	functionValue := reflect.ValueOf(fn)
+
+	paramTypes := make([]api.ValueType, functionType.NumIn()-2)
+	paramFuncs := make([]func(uint64) reflect.Value, len(paramTypes))
+
+	resultTypes := make([]api.ValueType, functionType.NumOut())
+	resultFuncs := make([]func(reflect.Value) uint64, len(resultTypes))
+
+	for i := range paramTypes {
+		var paramType api.ValueType
+		var paramFunc func(uint64) reflect.Value
+
+		switch functionType.In(i + 2).Kind() {
+		case reflect.Uint32:
+			paramType = api.ValueTypeI32
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(api.DecodeU32(v)) }
+		case reflect.Uint64:
+			paramType = api.ValueTypeI64
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(v) }
+		case reflect.Int32:
+			paramType = api.ValueTypeI32
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(api.DecodeI32(v)) }
+		case reflect.Int64:
+			paramType = api.ValueTypeI64
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(int64(v)) }
+		case reflect.Float32:
+			paramType = api.ValueTypeF32
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(api.DecodeF32(v)) }
+		case reflect.Float64:
+			paramType = api.ValueTypeF64
+			paramFunc = func(v uint64) reflect.Value { return reflect.ValueOf(api.DecodeF64(v)) }
+		default:
+			panic("cannot construct wasm function from go function of type " + functionType.String())
+		}
+
+		paramTypes[i] = paramType
+		paramFuncs[i] = paramFunc
+	}
+
+	for i := range resultTypes {
+		var resultType api.ValueType
+		var resultFunc func(reflect.Value) uint64
+
+		switch functionType.Out(i).Kind() {
+		case reflect.Uint32:
+			resultType = api.ValueTypeI32
+			resultFunc = func(v reflect.Value) uint64 { return v.Uint() }
+		case reflect.Uint64:
+			resultType = api.ValueTypeI64
+			resultFunc = func(v reflect.Value) uint64 { return v.Uint() }
+		case reflect.Int32:
+			resultType = api.ValueTypeI32
+			resultFunc = func(v reflect.Value) uint64 { return api.EncodeI32(int32(v.Int())) }
+		case reflect.Int64:
+			resultType = api.ValueTypeI64
+			resultFunc = func(v reflect.Value) uint64 { return api.EncodeI64(v.Int()) }
+		case reflect.Float32:
+			resultType = api.ValueTypeF32
+			resultFunc = func(v reflect.Value) uint64 { return api.EncodeF32(float32(v.Float())) }
+		case reflect.Float64:
+			resultType = api.ValueTypeF64
+			resultFunc = func(v reflect.Value) uint64 { return api.EncodeF64(v.Float()) }
+		default:
+			panic("cannot construct wasm function from go function of type " + functionType.String())
+		}
+
+		resultTypes[i] = resultType
+		resultFuncs[i] = resultFunc
+	}
+
+	return &Function{
+		GoModuleFunction: api.GoModuleFunc(func(ctx context.Context, mod api.Module, stack []uint64) {
+			in := make([]reflect.Value, 2+len(paramFuncs))
+			in[0] = reflect.ValueOf(ctx)
+			in[1] = reflect.ValueOf(mod)
+			for i, param := range paramFuncs {
+				in[i+2] = param(stack[i])
+			}
+			out := functionValue.Call(in)
+			for i, result := range resultFuncs {
+				stack[i] = result(out[i])
+			}
+		}),
+		ParamTypes:  paramTypes,
+		ResultTypes: resultTypes,
+	}
+}
+
+var (
+	errMissingFunctionSignature      = errors.New("missing function signature")
+	errMissingFunctionModule         = errors.New("missing function module")
+	errMissingFunctionImplementation = errors.New("missing function implementation")
+)
+
+func (f *Function) Definition() api.FunctionDefinition {
+	return functionDefinition{function: f}
+}
+
+func (f *Function) Call(ctx context.Context, params ...uint64) ([]uint64, error) {
+	stackLen := len(f.ParamTypes)
+	if stackLen < len(f.ResultTypes) {
+		stackLen = len(f.ResultTypes)
+	}
+	stack := make([]uint64, stackLen)
+	copy(stack, params)
+	err := f.CallWithStack(ctx, stack)
+	if err != nil {
+		for i := range stack {
+			stack[i] = 0
+		}
+	}
+	return stack[:len(f.ResultTypes)], err
+}
+
+func (f *Function) CallWithStack(ctx context.Context, stack []uint64) error {
+	if f.ParamTypes == nil || f.ResultTypes == nil {
+		return errMissingFunctionSignature
+	}
+	if f.GoModuleFunction == nil {
+		return errMissingFunctionImplementation
+	}
+	if f.module == nil {
+		return errMissingFunctionModule
+	}
+	if exitCode, exited := f.module.ExitStatus(); exited {
+		return sys.NewExitError(exitCode)
+	}
+	f.GoModuleFunction.Call(ctx, f.module, stack)
+	return nil
+}
+
+type functionDefinition struct {
+	internalapi.WazeroOnlyType
+	function *Function
+}
+
+func (def functionDefinition) Name() string {
+	return def.function.FunctionName
+}
+
+func (def functionDefinition) DebugName() string {
+	if def.function.DebugName != "" {
+		return def.function.DebugName
+	}
+	return fmt.Sprintf("%s.$%d", def.ModuleName(), def.Index())
+}
+
+func (def functionDefinition) GoFunction() any {
+	return def.function.GoModuleFunction
+}
+
+func (def functionDefinition) ParamTypes() []api.ValueType {
+	return def.function.ParamTypes
+}
+
+func (def functionDefinition) ParamNames() []string {
+	return def.function.ParamNames
+}
+
+func (def functionDefinition) ResultTypes() []api.ValueType {
+	return def.function.ResultTypes
+}
+
+func (def functionDefinition) ResultNames() []string {
+	return def.function.ResultNames
+}
+
+func (def functionDefinition) ModuleName() string {
+	if def.function.module != nil {
+		return def.function.module.ModuleName
+	}
+	return ""
+}
+
+func (def functionDefinition) Index() uint32 {
+	return uint32(def.function.index)
+}
+
+func (def functionDefinition) Import() (moduleName, name string, isImport bool) {
+	return
+}
+
+func (def functionDefinition) ExportNames() []string {
+	return def.function.ExportNames
+}
+
+// Memory is an implementation of the api.Memory interface, representing the
+// memory of a WebAssembly module.
+type Memory struct {
+	internalapi.WazeroOnlyType
+
+	// Byte slices holding the memory pages.
+	//
+	// It is the user's repsonsibility to ensure that the length of this byte
+	// slice is a multiple of the page size.
+	Bytes []byte
+
+	// Min and max number of memory pages which may be held in this memory.
+	//
+	// Leaving Max to zero means no upper bound.
+	Min uint32
+	Max uint32
+
+	// Lazily initialized when accessed through the module.
+	module *Module
+}
+
+// NewMemory constructs a Memory object with a buffer of the given size, aligned
+// to the closest multiple of the page size.
+func NewMemory(size int) *Memory {
+	numPages := (size + (PageSize - 1)) / PageSize
+	return &Memory{
+		Bytes: make([]byte, numPages*PageSize),
+		Min:   uint32(numPages),
+	}
+}
+
+// NewFixedMemory constructs a Memory object of the given size. The returned
+// memory is configured with a max limit to prevent growing beyond its initial
+// size.
+func NewFixedMemory(size int) *Memory {
+	memory := NewMemory(size)
+	memory.Max = memory.Min
+	return memory
+}
+
+// The PageSize constant defines the size of WebAssembly memory pages in bytes.
+//
+// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#page-size
+const PageSize = 65536
+
+func (m *Memory) Definition() api.MemoryDefinition {
+	return memoryDefinition{memory: m}
+}
+
+func (m *Memory) Size() uint32 {
+	return uint32(len(m.Bytes))
+}
+
+func (m *Memory) Grow(deltaPages uint32) (previousPages uint32, ok bool) {
+	previousPages = uint32(len(m.Bytes) / PageSize)
+	numPages := previousPages + deltaPages
+	if m.Max != 0 && numPages > m.Max {
+		return previousPages, false
+	}
+	bytes := make([]byte, PageSize*numPages)
+	copy(bytes, m.Bytes)
+	m.Bytes = bytes
+	return previousPages, true
+}
+
+func (m *Memory) ReadByte(offset uint32) (byte, bool) {
+	if m.isOutOfRange(offset, 1) {
+		return 0, false
+	}
+	return m.Bytes[offset], true
+}
+
+func (m *Memory) ReadUint16Le(offset uint32) (uint16, bool) {
+	if m.isOutOfRange(offset, 2) {
+		return 0, false
+	}
+	return binary.LittleEndian.Uint16(m.Bytes[offset:]), true
+}
+
+func (m *Memory) ReadUint32Le(offset uint32) (uint32, bool) {
+	if m.isOutOfRange(offset, 4) {
+		return 0, false
+	}
+	return binary.LittleEndian.Uint32(m.Bytes[offset:]), true
+}
+
+func (m *Memory) ReadUint64Le(offset uint32) (uint64, bool) {
+	if m.isOutOfRange(offset, 8) {
+		return 0, false
+	}
+	return binary.LittleEndian.Uint64(m.Bytes[offset:]), true
+}
+
+func (m *Memory) ReadFloat32Le(offset uint32) (float32, bool) {
+	v, ok := m.ReadUint32Le(offset)
+	return math.Float32frombits(v), ok
+}
+
+func (m *Memory) ReadFloat64Le(offset uint32) (float64, bool) {
+	v, ok := m.ReadUint64Le(offset)
+	return math.Float64frombits(v), ok
+}
+
+func (m *Memory) Read(offset, length uint32) ([]byte, bool) {
+	if m.isOutOfRange(offset, length) {
+		return nil, false
+	}
+	return m.Bytes[offset : offset+length : offset+length], true
+}
+
+func (m *Memory) WriteByte(offset uint32, value byte) bool {
+	if m.isOutOfRange(offset, 1) {
+		return false
+	}
+	m.Bytes[offset] = value
+	return true
+}
+
+func (m *Memory) WriteUint16Le(offset uint32, value uint16) bool {
+	if m.isOutOfRange(offset, 2) {
+		return false
+	}
+	binary.LittleEndian.PutUint16(m.Bytes[offset:], value)
+	return true
+}
+
+func (m *Memory) WriteUint32Le(offset uint32, value uint32) bool {
+	if m.isOutOfRange(offset, 4) {
+		return false
+	}
+	binary.LittleEndian.PutUint32(m.Bytes[offset:], value)
+	return true
+}
+
+func (m *Memory) WriteUint64Le(offset uint32, value uint64) bool {
+	if m.isOutOfRange(offset, 4) {
+		return false
+	}
+	binary.LittleEndian.PutUint64(m.Bytes[offset:], value)
+	return true
+}
+
+func (m *Memory) WriteFloat32Le(offset uint32, value float32) bool {
+	return m.WriteUint32Le(offset, math.Float32bits(value))
+}
+
+func (m *Memory) WriteFloat64Le(offset uint32, value float64) bool {
+	return m.WriteUint64Le(offset, math.Float64bits(value))
+}
+
+func (m *Memory) Write(offset uint32, value []byte) bool {
+	if m.isOutOfRange(offset, uint32(len(value))) {
+		return false
+	}
+	copy(m.Bytes[offset:], value)
+	return true
+}
+
+func (m *Memory) WriteString(offset uint32, value string) bool {
+	if m.isOutOfRange(offset, uint32(len(value))) {
+		return false
+	}
+	copy(m.Bytes[offset:], value)
+	return true
+}
+
+func (m *Memory) isOutOfRange(offset, length uint32) bool {
+	size := m.Size()
+	return offset >= size || length > size || offset > (size-length)
+}
+
+type memoryDefinition struct {
+	internalapi.WazeroOnlyType
+	memory *Memory
+}
+
+func (def memoryDefinition) ModuleName() string {
+	if def.memory.module != nil {
+		return def.memory.module.ModuleName
+	}
+	return ""
+}
+
+func (def memoryDefinition) Index() uint32 {
+	return 0
+}
+
+func (def memoryDefinition) Import() (moduleName, name string, isImport bool) {
+	return
+}
+
+func (def memoryDefinition) ExportNames() []string {
+	if def.memory.module != nil {
+		return []string{"memory"}
+	}
+	return nil
+}
+
+func (def memoryDefinition) Min() uint32 {
+	return def.memory.Min
+}
+
+func (def memoryDefinition) Max() (uint32, bool) {
+	return def.memory.Max, def.memory.Max != 0
+}
+
+// StackFrame represents a frame on the call stack.
+type StackFrame struct {
+	Function api.Function
+	Params   []uint64
+	Results  []uint64
+}
+
+type stackIterator struct {
+	stack []StackFrame
+	fndef []api.FunctionDefinition
+	index int
+}
+
+func (si *stackIterator) Next() bool {
+	si.index++
+	return si.index < len(si.stack)
+}
+
+func (si *stackIterator) FunctionDefinition() api.FunctionDefinition {
+	return si.fndef[si.index]
+}
+
+func (si *stackIterator) Parameters() []uint64 {
+	return si.stack[si.index].Params
+}
+
+func (si *stackIterator) reset() {
+	si.index = 0
+}
+
+func newStackIterator(stack []StackFrame) *stackIterator {
+	si := &stackIterator{
+		stack: stack,
+		fndef: make([]api.FunctionDefinition, len(stack)),
+	}
+	// The size of functionDefinition is only one pointer which should allow
+	// the compiler to optimize the conversion to api.FunctionDefinition; but
+	// the presence of internal.WazeroOnlyType, despite being defined as an
+	// empty struct, forces a heap allocation that we amortize by caching the
+	// result.
+	for i, frame := range stack {
+		si.fndef[i] = frame.Function.Definition()
+	}
+	return si
+}
+
+var (
+	_ experimental.InternalModule = (*Module)(nil)
+
+	_ api.Module   = (*Module)(nil)
+	_ api.Function = (*Function)(nil)
+	_ api.Global   = (*Global)(nil)
+)
+
+// BenchmarkFunctionListener implements a benchmark for function listeners.
+//
+// The benchmark calls Before and After methods repeatedly using the provided
+// module an stack frames to invoke the methods.
+//
+// The stack frame is a representation of the call stack that the Before method
+// will be invoked with. The top of the stack is stored at index zero. The stack
+// must contain at least one frame or the benchmark will fail.
+func BenchmarkFunctionListener(b *testing.B, module *Module, stack []StackFrame, listener experimental.FunctionListener) {
+	if len(stack) == 0 {
+		b.Error("cannot benchmark function listener with an empty stack")
+		return
+	}
+
+	functionDefinition := stack[0].Function.Definition()
+	functionParams := stack[0].Params
+	functionResults := stack[0].Results
+	stackIterator := newStackIterator(stack)
+	ctx := context.Background()
+
+	for i := 0; i < b.N; i++ {
+		stackIterator.reset()
+		callContext := listener.Before(ctx, module, functionDefinition, functionParams, stackIterator)
+		listener.After(callContext, module, functionDefinition, nil, functionResults)
+	}
+}
diff --git a/experimental/wazerotest/wazerotest_test.go b/experimental/wazerotest/wazerotest_test.go
new file mode 100644
index 00000000..39f280aa
--- /dev/null
+++ b/experimental/wazerotest/wazerotest_test.go
@@ -0,0 +1,167 @@
+package wazerotest
+
+import (
+	"bytes"
+	"context"
+	"reflect"
+	"testing"
+
+	"github.com/tetratelabs/wazero/api"
+)
+
+func TestNewFunction(t *testing.T) {
+	tests := []struct {
+		in  any
+		out *Function
+	}{
+		{
+			in: func(ctx context.Context, mod api.Module) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v uint32) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeI32},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v uint64) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeI64},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v int32) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeI32},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v int64) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeI64},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v float32) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeF32},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, v float64) {},
+			out: &Function{
+				ParamTypes:  []api.ValueType{api.ValueTypeF64},
+				ResultTypes: []api.ValueType{},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) uint32 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeI32},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) uint64 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeI64},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) int32 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeI32},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) int64 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeI64},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) float32 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeF32},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module) float64 { return 0 },
+			out: &Function{
+				ParamTypes:  []api.ValueType{},
+				ResultTypes: []api.ValueType{api.ValueTypeF64},
+			},
+		},
+
+		{
+			in: func(ctx context.Context, mod api.Module, _ uint32, _ uint64, _ int32, _ int64, _ float32, _ float64) (_ uint32, _ uint64, _ int32, _ int64, _ float32, _ float64) {
+				return
+			},
+			out: &Function{
+				ParamTypes: []api.ValueType{
+					api.ValueTypeI32, api.ValueTypeI64,
+					api.ValueTypeI32, api.ValueTypeI64,
+					api.ValueTypeF32, api.ValueTypeF64,
+				},
+				ResultTypes: []api.ValueType{
+					api.ValueTypeI32, api.ValueTypeI64,
+					api.ValueTypeI32, api.ValueTypeI64,
+					api.ValueTypeF32, api.ValueTypeF64,
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(reflect.TypeOf(test.in).String(), func(t *testing.T) {
+			f := NewFunction(test.in)
+			if !bytes.Equal(test.out.ParamTypes, f.ParamTypes) {
+				t.Errorf("invalid parameter types: want=%v got=%v", test.out.ParamTypes, f.ParamTypes)
+			}
+			if !bytes.Equal(test.out.ResultTypes, f.ResultTypes) {
+				t.Errorf("invalid result types: want=%v got=%v", test.out.ResultTypes, f.ResultTypes)
+			}
+		})
+	}
+}
+
+func TestNewMemory(t *testing.T) {
+	if memory := NewMemory(1); len(memory.Bytes) != PageSize {
+		t.Error("memory buffer not aligned on page size")
+	}
+}
+
+func TestNewFixedMemory(t *testing.T) {
+	if memory := NewFixedMemory(1); len(memory.Bytes) != PageSize {
+		t.Error("memory buffer not aligned on page size")
+	} else if memory.Min != 1 {
+		t.Error("invalid min memory size:", memory.Min)
+	} else if memory.Max != 1 {
+		t.Error("invalid max memory size:", memory.Max)
+	}
+}
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 6f22aa68..02b49ad2 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -2838,49 +2838,6 @@ func Test_fdWrite(t *testing.T) {
 	require.Equal(t, []byte("wazero"), buf) // verify the file was actually written
 }
 
-// Test_fdWrite_discard ensures default configuration doesn't add needless
-// overhead, but still returns valid data. For example, writing to STDOUT when
-// it is io.Discard.
-func Test_fdWrite_discard(t *testing.T) {
-	// Default has io.Discard as stdout/stderr
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
-	defer r.Close(testCtx)
-
-	iovs := uint32(1) // arbitrary offset
-	initialMemory := []byte{
-		'?',         // `iovs` is after this
-		18, 0, 0, 0, // = iovs[0].offset
-		4, 0, 0, 0, // = iovs[0].length
-		23, 0, 0, 0, // = iovs[1].offset
-		2, 0, 0, 0, // = iovs[1].length
-		'?',                // iovs[0].offset is after this
-		'w', 'a', 'z', 'e', // iovs[0].length bytes
-		'?',      // iovs[1].offset is after this
-		'r', 'o', // iovs[1].length bytes
-		'?',
-	}
-	iovsCount := uint32(2)       // The count of iovs
-	resultNwritten := uint32(26) // arbitrary offset
-	expectedMemory := append(
-		initialMemory,
-		6, 0, 0, 0, // sum(iovs[...].length) == length of "wazero"
-		'?',
-	)
-
-	maskMemory(t, mod, len(expectedMemory))
-	ok := mod.Memory().Write(0, initialMemory)
-	require.True(t, ok)
-
-	fd := sys.FdStdout
-	requireErrnoResult(t, wasip1.ErrnoSuccess, mod, wasip1.FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
-	// Should not amplify logging
-	require.Zero(t, len(log.Bytes()))
-
-	actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
-	require.True(t, ok)
-	require.Equal(t, expectedMemory, actual)
-}
-
 func Test_fdWrite_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
 	pathName := "test_path"
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index d2ddef96..bf892a31 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -1,7 +1,6 @@
 package wasi_snapshot_preview1
 
 import (
-	"io"
 	"os"
 	"syscall"
 	"testing"
@@ -13,91 +12,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/wasip1"
 )
 
-func Test_fdRead_shouldContinueRead(t *testing.T) {
-	tests := []struct {
-		name          string
-		n, l          uint32
-		err           error
-		expectedOk    bool
-		expectedErrno syscall.Errno
-	}{
-		{
-			name: "break when nothing to read",
-			n:    0,
-			l:    0,
-		},
-		{
-			name: "break when nothing read",
-			n:    0,
-			l:    4,
-		},
-		{
-			name: "break on partial read",
-			n:    3,
-			l:    4,
-		},
-		{
-			name:       "continue on full read",
-			n:          4,
-			l:          4,
-			expectedOk: true,
-		},
-		{
-			name: "break on EOF on nothing to read",
-			err:  io.EOF,
-		},
-		{
-			name: "break on EOF on nothing read",
-			l:    4,
-			err:  io.EOF,
-		},
-		{
-			name: "break on EOF on partial read",
-			n:    3,
-			l:    4,
-			err:  io.EOF,
-		},
-		{
-			name: "break on EOF on full read",
-			n:    4,
-			l:    4,
-			err:  io.EOF,
-		},
-		{
-			name:          "return ErrnoIo on error on nothing to read",
-			err:           io.ErrClosedPipe,
-			expectedErrno: syscall.EIO,
-		},
-		{
-			name:          "return ErrnoIo on error on nothing read",
-			l:             4,
-			err:           io.ErrClosedPipe,
-			expectedErrno: syscall.EIO,
-		},
-		{ // Special case, allows processing data before err
-			name: "break on error on partial read",
-			n:    3,
-			l:    4,
-			err:  io.ErrClosedPipe,
-		},
-		{ // Special case, allows processing data before err
-			name: "break on error on full read",
-			n:    4,
-			l:    4,
-			err:  io.ErrClosedPipe,
-		},
-	}
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			ok, errno := fdRead_shouldContinueRead(tc.n, tc.l, tc.err)
-			require.Equal(t, tc.expectedOk, ok)
-			require.EqualErrno(t, tc.expectedErrno, errno)
-		})
-	}
-}
-
 func Test_lastDirents(t *testing.T) {
 	tests := []struct {
 		name            string
diff --git a/internal/platform/bench_test.go b/internal/platform/bench_test.go
index fe04098b..ac1cc8f6 100644
--- a/internal/platform/bench_test.go
+++ b/internal/platform/bench_test.go
@@ -1,6 +1,8 @@
 package platform
 
 import (
+	"io"
+	"io/fs"
 	"os"
 	"path"
 	"syscall"
@@ -27,3 +29,65 @@ func Benchmark_UtimensFile(b *testing.B) {
 		}
 	}
 }
+
+func BenchmarkFsFileRead(b *testing.B) {
+	dirFS := os.DirFS("testdata")
+	embedFS, err := fs.Sub(testdata, "testdata")
+	if err != nil {
+		b.Fatal(err)
+	}
+
+	benches := []struct {
+		name  string
+		fs    fs.FS
+		pread bool
+	}{
+		{name: "os.DirFS Read", fs: dirFS, pread: false},
+		{name: "os.DirFS Pread", fs: dirFS, pread: true},
+		{name: "embed.FS Read", fs: embedFS, pread: false},
+		{name: "embed.FS Pread", fs: embedFS, pread: true},
+	}
+
+	buf := make([]byte, 3)
+
+	for _, bc := range benches {
+		bc := bc
+
+		b.Run(bc.name, func(b *testing.B) {
+			name := "wazero.txt"
+			f, err := bc.fs.Open(name)
+			if err != nil {
+				b.Fatal(err)
+			}
+			defer f.Close()
+
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				b.StopTimer()
+
+				// Reset the read position back to the beginning of the file.
+				if _, err = f.(io.Seeker).Seek(0, io.SeekStart); err != nil {
+					b.Fatal(err)
+				}
+
+				fs := NewFsFile(name, syscall.O_RDONLY, f)
+				var n int
+				var errno syscall.Errno
+
+				b.StartTimer()
+				if bc.pread {
+					n, errno = fs.Pread(buf, 3)
+				} else {
+					n, errno = fs.Read(buf)
+				}
+				b.StopTimer()
+
+				if errno != 0 {
+					b.Fatal(errno)
+				} else if bufLen := len(buf); n < bufLen {
+					b.Fatalf("nread %d less than capacity %d", n, bufLen)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/platform/file_test.go b/internal/platform/file_test.go
index 0f02b983..5fff4e5b 100644
--- a/internal/platform/file_test.go
+++ b/internal/platform/file_test.go
@@ -1,7 +1,6 @@
 package platform
 
 import (
-	"bytes"
 	"embed"
 	"io"
 	"io/fs"
@@ -10,6 +9,7 @@ import (
 	"runtime"
 	"syscall"
 	"testing"
+	gofstest "testing/fstest"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -42,11 +42,328 @@ func (NoopFile) File() fs.File { panic("noop") }
 //go:embed file_test.go
 var embedFS embed.FS
 
-func TestFileSync_NoError(t *testing.T) {
+var (
+	//go:embed testdata
+	testdata  embed.FS
+	preadFile = "wazero.txt"
+	emptyFile = "empty.txt"
+)
+
+func TestFsFileReadAndPread(t *testing.T) {
+	embedFS, err := fs.Sub(testdata, "testdata")
+	require.NoError(t, err)
+
+	f, err := embedFS.Open(preadFile)
+	require.NoError(t, err)
+	defer f.Close()
+
+	bytes, err := io.ReadAll(f)
+	require.NoError(t, err)
+
+	mapFS := gofstest.MapFS{preadFile: &gofstest.MapFile{Data: bytes}}
+
+	// Write a file as can't open "testdata" in scratch tests because they
+	// can't read the original filesystem.
+	tmpDir := t.TempDir()
+	require.NoError(t, os.WriteFile(path.Join(tmpDir, preadFile), bytes, 0o600))
+	dirFS := os.DirFS(tmpDir)
+
+	tests := []struct {
+		name string
+		fs   fs.FS
+	}{
+		{name: "os.DirFS", fs: dirFS},
+		{name: "embed.FS", fs: embedFS},
+		{name: "fstest.MapFS", fs: mapFS},
+	}
+
+	buf := make([]byte, 3)
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			f, err := tc.fs.Open(preadFile)
+			require.NoError(t, err)
+			defer f.Close()
+
+			fs := NewFsFile(preadFile, syscall.O_RDONLY, f)
+
+			// The file should be readable (base case)
+			requireRead(t, fs, buf)
+			require.Equal(t, "waz", string(buf))
+			buf = buf[:]
+
+			// We should be able to pread from zero also
+			requirePread(t, fs, buf, 0)
+			require.Equal(t, "waz", string(buf))
+			buf = buf[:]
+
+			// If the offset didn't change, read should expect the next three chars.
+			requireRead(t, fs, buf)
+			require.Equal(t, "ero", string(buf))
+			buf = buf[:]
+
+			// We should also be able pread from any offset
+			requirePread(t, fs, buf, 2)
+			require.Equal(t, "zer", string(buf))
+		})
+	}
+}
+
+func requireRead(t *testing.T, f File, buf []byte) {
+	n, errno := f.Read(buf)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(buf), n)
+}
+
+func requirePread(t *testing.T, f File, buf []byte, off int64) {
+	n, errno := f.Pread(buf, off)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(buf), n)
+}
+
+func TestFsFileRead_empty(t *testing.T) {
+	embedFS, err := fs.Sub(testdata, "testdata")
+	require.NoError(t, err)
+
+	f, err := embedFS.Open(preadFile)
+	require.NoError(t, err)
+	defer f.Close()
+
+	mapFS := gofstest.MapFS{emptyFile: &gofstest.MapFile{}}
+
+	// Write a file as can't open "testdata" in scratch tests because they
+	// can't read the original filesystem.
+	tmpDir := t.TempDir()
+	require.NoError(t, os.WriteFile(path.Join(tmpDir, emptyFile), []byte{}, 0o600))
+	dirFS := os.DirFS(tmpDir)
+
+	tests := []struct {
+		name string
+		fs   fs.FS
+	}{
+		{name: "os.DirFS", fs: dirFS},
+		{name: "embed.FS", fs: embedFS},
+		{name: "fstest.MapFS", fs: mapFS},
+	}
+
+	buf := make([]byte, 3)
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			f, err := tc.fs.Open(emptyFile)
+			require.NoError(t, err)
+			defer f.Close()
+
+			fs := NewFsFile(preadFile, syscall.O_RDONLY, f)
+
+			t.Run("Read", func(t *testing.T) {
+				// We should be able to read an empty file
+				n, errno := fs.Read(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Zero(t, n)
+			})
+
+			t.Run("Pread", func(t *testing.T) {
+				n, errno := fs.Pread(buf, 0)
+				require.EqualErrno(t, 0, errno)
+				require.Zero(t, n)
+			})
+		})
+	}
+}
+
+func TestFsFilePread_Unsupported(t *testing.T) {
+	embedFS, err := fs.Sub(testdata, "testdata")
+	require.NoError(t, err)
+
+	f, err := embedFS.Open(emptyFile)
+	require.NoError(t, err)
+	defer f.Close()
+
+	// mask both io.ReaderAt and io.Seeker
+	f = struct{ fs.File }{f}
+
+	fs := NewFsFile(preadFile, syscall.O_RDONLY, f)
+
+	buf := make([]byte, 3)
+	_, errno := fs.Pread(buf, 0)
+	require.EqualErrno(t, syscall.ENOSYS, errno)
+}
+
+func TestFsFileWriteAndPwrite(t *testing.T) {
+	// fs.FS doesn't support writes, and there is no other built-in
+	// implementation except os.File.
+	path := path.Join(t.TempDir(), preadFile)
+	f := openFsFile(t, path, syscall.O_RDWR|os.O_CREATE, 0o600)
+	defer f.Close()
+
+	text := "wazero"
+	buf := make([]byte, 3)
+	copy(buf, text[:3])
+
+	// The file should be writeable
+	requireWrite(t, f, buf)
+
+	// We should be able to pwrite at gap
+	requirePwrite(t, f, buf, 6)
+
+	copy(buf, text[3:])
+
+	// If the offset didn't change, the next chars will write after the
+	// first
+	requireWrite(t, f, buf)
+
+	// We should be able to pwrite the same bytes as above
+	requirePwrite(t, f, buf, 9)
+
+	// We should also be able to pwrite past the above.
+	requirePwrite(t, f, buf, 12)
+
+	b, err := os.ReadFile(path)
+	require.NoError(t, err)
+
+	// We expect to have written the text two and a half times:
+	//  1. Write: (file offset 0) "waz"
+	//  2. Pwrite: offset 6 "waz"
+	//  3. Write: (file offset 3) "ero"
+	//  4. Pwrite: offset 9 "ero"
+	//  4. Pwrite: offset 12 "ero"
+	require.Equal(t, "wazerowazeroero", string(b))
+}
+
+func requireWrite(t *testing.T, f File, buf []byte) {
+	n, errno := f.Write(buf)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(buf), n)
+}
+
+func requirePwrite(t *testing.T, f File, buf []byte, off int64) {
+	n, errno := f.Pwrite(buf, off)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(buf), n)
+}
+
+func TestFsFileWrite_empty(t *testing.T) {
+	// fs.FS doesn't support writes, and there is no other built-in
+	// implementation except os.File.
+	path := path.Join(t.TempDir(), emptyFile)
+	f := openFsFile(t, path, syscall.O_RDWR|os.O_CREATE, 0o600)
+	defer f.Close()
+
+	tests := []struct {
+		name string
+		fn   func(File, []byte) (int, syscall.Errno)
+	}{
+		{name: "Write", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Write(buf)
+		}},
+		{name: "Pwrite from zero", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Pwrite(buf, 0)
+		}},
+		{name: "Pwrite from 3", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Pwrite(buf, 3)
+		}},
+	}
+
+	var emptyBuf []byte
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			n, errno := tc.fn(f, emptyBuf)
+			require.EqualErrno(t, 0, errno)
+			require.Zero(t, n)
+
+			// The file should be empty
+			b, err := os.ReadFile(path)
+			require.NoError(t, err)
+			require.Zero(t, len(b))
+		})
+	}
+}
+
+func TestFsFileWrite_Unsupported(t *testing.T) {
+	embedFS, err := fs.Sub(testdata, "testdata")
+	require.NoError(t, err)
+
+	f, err := embedFS.Open(preadFile)
+	require.NoError(t, err)
+	defer f.Close()
+
+	tests := []struct {
+		name string
+		fn   func(File, []byte) (int, syscall.Errno)
+	}{
+		{name: "Write", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Write(buf)
+		}},
+		{name: "Pwrite", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Pwrite(buf, 0)
+		}},
+	}
+
+	buf := []byte("wazero")
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			// Use syscall.O_RDWR so that it fails due to type not flags
+			f := NewFsFile(preadFile, syscall.O_RDWR, f)
+			_, errno := tc.fn(f, buf)
+			require.EqualErrno(t, syscall.ENOSYS, errno)
+		})
+	}
+}
+
+func TestFsFileWrite_BadFile(t *testing.T) {
+	// Create the file
+	path := path.Join(t.TempDir(), emptyFile)
+	of, err := os.Create(path)
+	require.NoError(t, err)
+	require.NoError(t, of.Close())
+
+	// Open the file read-only
+	flag := syscall.O_RDONLY
+	f := openFsFile(t, path, flag, 0o600)
+	defer f.Close()
+
+	tests := []struct {
+		name string
+		fn   func(File, []byte) (int, syscall.Errno)
+	}{
+		{name: "Write", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Write(buf)
+		}},
+		{name: "Pwrite", fn: func(f File, buf []byte) (int, syscall.Errno) {
+			return f.Pwrite(buf, 0)
+		}},
+	}
+
+	buf := []byte("wazero")
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			_, errno := tc.fn(f, buf)
+
+			// The descriptor exists, but not open for writing
+			require.EqualErrno(t, syscall.EBADF, errno)
+		})
+	}
+}
+
+func TestFsFileSync_NoError(t *testing.T) {
 	testSync_NoError(t, File.Sync)
 }
 
-func TestFileDatasync_NoError(t *testing.T) {
+func TestFsFileDatasync_NoError(t *testing.T) {
 	testSync_NoError(t, File.Datasync)
 }
 
@@ -111,7 +428,7 @@ func testSync(t *testing.T, sync func(File) syscall.Errno) {
 
 	fPath := path.Join(dPath, t.Name())
 
-	f := openFsFile(t, fPath, os.O_RDWR|os.O_CREATE, 0o600)
+	f := openFsFile(t, fPath, syscall.O_RDWR|os.O_CREATE, 0o600)
 	defer f.Close()
 
 	expected := "hello world!"
@@ -129,12 +446,12 @@ func testSync(t *testing.T, sync func(File) syscall.Errno) {
 	require.NoError(t, err)
 
 	// Read data from the file
-	var buf bytes.Buffer
-	_, err = io.Copy(&buf, f.File().(io.Reader))
-	require.NoError(t, err)
+	buf := make([]byte, 50)
+	n, errno := f.Read(buf)
+	require.EqualErrno(t, 0, errno)
 
 	// It may be the case that sync worked.
-	require.Equal(t, expected, buf.String())
+	require.Equal(t, expected, string(buf[:n]))
 
 	// Windows allows you to sync a closed file
 	if runtime.GOOS != "windows" {
@@ -228,7 +545,7 @@ func testEBADFIfFileClosed(t *testing.T, fn func(File) syscall.Errno) bool {
 
 func testEISDIR(t *testing.T, fn func(File) syscall.Errno) bool {
 	return t.Run("EISDIR if directory", func(t *testing.T) {
-		f := openFsFile(t, os.TempDir(), os.O_RDONLY|O_DIRECTORY, 0o666)
+		f := openFsFile(t, os.TempDir(), syscall.O_RDONLY|O_DIRECTORY, 0o666)
 		defer f.Close()
 
 		require.EqualErrno(t, syscall.EISDIR, fn(f))
@@ -237,7 +554,7 @@ func testEISDIR(t *testing.T, fn func(File) syscall.Errno) bool {
 
 func openForWrite(t *testing.T, path string, content []byte) File {
 	require.NoError(t, os.WriteFile(path, content, 0o0600))
-	return openFsFile(t, path, os.O_RDWR, 0o666)
+	return openFsFile(t, path, syscall.O_RDWR, 0o666)
 }
 
 func openFsFile(t *testing.T, path string, flag int, perm fs.FileMode) File {
diff --git a/internal/platform/testdata/empty.txt b/internal/platform/testdata/empty.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/internal/platform/testdata/wazero.txt b/internal/platform/testdata/wazero.txt
new file mode 100644
index 00000000..3058e805
--- /dev/null
+++ b/internal/platform/testdata/wazero.txt
@@ -0,0 +1,1 @@
+wazero
diff --git a/internal/sysfs/sysfs_bench_test.go b/internal/sysfs/sysfs_bench_test.go
deleted file mode 100644
index 8d8a4155..00000000
--- a/internal/sysfs/sysfs_bench_test.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package sysfs
-
-import (
-	"io"
-	"io/fs"
-	"os"
-	"testing"
-)
-
-func BenchmarkReaderAtOffset(b *testing.B) {
-	dirFS := os.DirFS("testdata")
-	embedFS, err := fs.Sub(testdata, "testdata")
-	if err != nil {
-		b.Fatal(err)
-	}
-
-	benches := []struct {
-		name string
-		fs   fs.FS
-		ra   bool
-	}{
-		{name: "os.DirFS io.File", fs: dirFS, ra: false},
-		{name: "os.DirFS readerAtOffset", fs: dirFS, ra: true},
-		{name: "embed.FS embed.file", fs: embedFS, ra: false},
-		{name: "embed.FS seekToOffsetReader", fs: embedFS, ra: true},
-	}
-
-	buf := make([]byte, 3)
-
-	for _, bc := range benches {
-		bc := bc
-
-		b.Run(bc.name, func(b *testing.B) {
-			f, err := bc.fs.Open("wazero.txt")
-			if err != nil {
-				b.Fatal(err)
-			}
-			defer f.Close()
-
-			b.ResetTimer()
-			for i := 0; i < b.N; i++ {
-				b.StopTimer()
-
-				// Reset the read position back to the beginning of the file.
-				if _, err = f.(io.Seeker).Seek(0, io.SeekStart); err != nil {
-					b.Fatal(err)
-				}
-
-				// Get the reader we are benchmarking
-				var r io.Reader = f
-				if bc.ra {
-					r = ReaderAtOffset(f, 3)
-				}
-
-				b.StartTimer()
-				if _, err := r.Read(buf); err != nil {
-					b.Fatal(err)
-				}
-				b.StopTimer()
-			}
-		})
-	}
-}
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 6755c1f7..da889feb 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -1,7 +1,6 @@
 package sysfs
 
 import (
-	"embed"
 	_ "embed"
 	"io"
 	"io/fs"
@@ -11,7 +10,6 @@ import (
 	"sort"
 	"syscall"
 	"testing"
-	gofstest "testing/fstest"
 
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -397,157 +395,6 @@ func testReadlink(t *testing.T, readFS, writeFS FS) {
 	})
 }
 
-var (
-	//go:embed testdata
-	testdata     embed.FS
-	readerAtFile = "wazero.txt"
-	emptyFile    = "empty.txt"
-)
-
-func TestReaderAtOffset(t *testing.T) {
-	embedFS, err := fs.Sub(testdata, "testdata")
-	require.NoError(t, err)
-
-	d, err := embedFS.Open(readerAtFile)
-	require.NoError(t, err)
-	defer d.Close()
-
-	bytes, err := io.ReadAll(d)
-	require.NoError(t, err)
-
-	mapFS := gofstest.MapFS{readerAtFile: &gofstest.MapFile{Data: bytes}}
-
-	// Write a file as can't open "testdata" in scratch tests because they
-	// can't read the original filesystem.
-	tmpDir := t.TempDir()
-	require.NoError(t, os.WriteFile(path.Join(tmpDir, readerAtFile), bytes, 0o600))
-	dirFS := os.DirFS(tmpDir)
-
-	tests := []struct {
-		name string
-		fs   fs.FS
-	}{
-		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
-		{name: "fstest.MapFS", fs: mapFS},
-	}
-
-	buf := make([]byte, 3)
-
-	for _, tc := range tests {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			f, err := tc.fs.Open(readerAtFile)
-			require.NoError(t, err)
-			defer f.Close()
-
-			var r io.Reader = f
-			ra := ReaderAtOffset(f, 0)
-
-			requireRead3 := func(r io.Reader, buf []byte) {
-				n, err := r.Read(buf)
-				require.NoError(t, err)
-				require.Equal(t, 3, n)
-			}
-
-			// The file should work as a reader (base case)
-			requireRead3(r, buf)
-			require.Equal(t, "waz", string(buf))
-			buf = buf[:]
-
-			// The readerAt impl should be able to start from zero also
-			requireRead3(ra, buf)
-			require.Equal(t, "waz", string(buf))
-			buf = buf[:]
-
-			// If the offset didn't change, we expect the next three chars.
-			requireRead3(r, buf)
-			require.Equal(t, "ero", string(buf))
-			buf = buf[:]
-
-			// If state was held between reader-at, we expect the same
-			requireRead3(ra, buf)
-			require.Equal(t, "ero", string(buf))
-			buf = buf[:]
-
-			// We should also be able to make another reader-at
-			ra = ReaderAtOffset(f, 3)
-			requireRead3(ra, buf)
-			require.Equal(t, "ero", string(buf))
-		})
-	}
-}
-
-func TestReaderAtOffset_empty(t *testing.T) {
-	embedFS, err := fs.Sub(testdata, "testdata")
-	require.NoError(t, err)
-
-	d, err := embedFS.Open(readerAtFile)
-	require.NoError(t, err)
-	defer d.Close()
-
-	mapFS := gofstest.MapFS{emptyFile: &gofstest.MapFile{}}
-
-	// Write a file as can't open "testdata" in scratch tests because they
-	// can't read the original filesystem.
-	tmpDir := t.TempDir()
-	require.NoError(t, os.WriteFile(path.Join(tmpDir, emptyFile), []byte{}, 0o600))
-	dirFS := os.DirFS(tmpDir)
-
-	tests := []struct {
-		name string
-		fs   fs.FS
-	}{
-		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
-		{name: "fstest.MapFS", fs: mapFS},
-	}
-
-	buf := make([]byte, 3)
-
-	for _, tc := range tests {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			f, err := tc.fs.Open(emptyFile)
-			require.NoError(t, err)
-			defer f.Close()
-
-			var r io.Reader = f
-			ra := ReaderAtOffset(f, 0)
-
-			requireRead3 := func(r io.Reader, buf []byte) {
-				n, err := r.Read(buf)
-				require.Equal(t, err, io.EOF)
-				require.Equal(t, 0, n) // file is empty
-			}
-
-			// The file should work as a reader (base case)
-			requireRead3(r, buf)
-
-			// The readerAt impl should be able to start from zero also
-			requireRead3(ra, buf)
-		})
-	}
-}
-
-func TestReaderAtOffset_Unsupported(t *testing.T) {
-	embedFS, err := fs.Sub(testdata, "testdata")
-	require.NoError(t, err)
-
-	f, err := embedFS.Open(emptyFile)
-	require.NoError(t, err)
-	defer f.Close()
-
-	// mask both io.ReaderAt and io.Seeker
-	ra := ReaderAtOffset(struct{ fs.File }{f}, 0)
-
-	buf := make([]byte, 3)
-	_, err = ra.Read(buf)
-	require.Equal(t, syscall.ENOSYS, err)
-}
-
 func requireIno(t *testing.T, dirents []*platform.Dirent, expectIno bool) {
 	for _, e := range dirents {
 		if expectIno {