Repository: tetratelabs/wazero
Pull request #1425 (2023-05-02 04:28:27)

Bulk Lazy initialization of FunctionDefinitions 
`BuildFunctionDefinitions` is one of the most heavy allocation source during compilation,
which should be deferred when it's actually required (e.g. facing runtime errors).

This refactors the usage of FunctionDefinition, and defer the initialization of `[]FunctionDefinition`
to the time when `Module.FunctionDefinition` is called.

```
goos: darwin
goarch: arm64
pkg: github.com/tetratelabs/wazero/internal/integration_test/bench
                                   │   old.txt   │              new.txt              │
                                   │   sec/op    │   sec/op     vs base              │
Compilation_sqlite3/compiler-10      134.4m ± 1%   134.3m ± 5%       ~ (p=0.620 n=7)
Compilation_sqlite3/interpreter-10   45.75m ± 0%   45.67m ± 1%       ~ (p=0.456 n=7)
geomean                              78.42m        78.30m       -0.16%

                                   │   old.txt    │              new.txt               │
                                   │     B/op     │     B/op      vs base              │
Compilation_sqlite3/compiler-10      54.32Mi ± 0%   54.04Mi ± 0%  -0.51% (p=0.001 n=7)
Compilation_sqlite3/interpreter-10   51.75Mi ± 0%   51.47Mi ± 0%  -0.54% (p=0.001 n=7)
geomean                              53.02Mi        52.74Mi       -0.52%

                                   │   old.txt   │              new.txt               │
                                   │  allocs/op  │  allocs/op   vs base               │
Compilation_sqlite3/compiler-10      26.06k ± 0%   23.00k ± 0%  -11.72% (p=0.001 n=7)
Compilation_sqlite3/interpreter-10   13.90k ± 0%   10.84k ± 0%  -22.00% (p=0.001 n=7)
geomean                              19.03k        15.79k       -17.02%

```

mathetake: decided to go ahead and do the minimal lazy initializtion. will push the commit in a bit
mathetake: ok ready
mathetake: oh no wait, I haven't taken into account the concurrent access. Making this as draft.
mathetake: maybe not worth a try considering the fact that there's no op/sec improvement
mathetake: those interested in this - feel free to take over 
evacchi: I pushed a possible change (feel free to revert). If I am running the sqlite3 bench right, these are the results:

```
wazero on  lazyfunctiondefinition [$?⇡] via 🐹 v1.20.4
❯ go test -run='^$' -bench '^BenchmarkCompilation.*' ./internal/integration_test/bench -count=6 > new.txt

wazero on  lazyfunctiondefinition [$?⇡] via 🐹 v1.20.4 took 41s
❯ benchstat old.txt new.txt
goos: darwin
goarch: arm64
pkg: github.com/tetratelabs/wazero/internal/integration_test/bench
                                    │    old.txt    │               new.txt               │
                                    │    sec/op     │    sec/op      vs base              │
Compilation/with_extern_cache-10        81.90µ ± 2%     80.57µ ± 0%  -1.62% (p=0.002 n=6)
Compilation/without_extern_cache-10     991.4µ ± 0%     964.7µ ± 0%  -2.69% (p=0.002 n=6)
Compilation/interpreter-10              333.2µ ± 0%     327.0µ ± 1%  -1.86% (p=0.002 n=6)
Compilation_Sqlite3/interpreter-10    0.04533n ± 0%   0.04456n ± 0%  -1.70% (p=0.002 n=6)
Compilation_Sqlite3/compiler-10        0.1465n ± 1%    0.1427n ± 0%  -2.63% (p=0.002 n=6)
geomean                                 709.4n          694.5n       -2.10%
```
mathetake: > I pushed a possible change (feel free to revert). If I am running the sqlite3 bench right, these are the results:
> 
> ```
> wazero on  lazyfunctiondefinition [$?⇡] via 🐹 v1.20.4
> ❯ go test -run='^$' -bench '^BenchmarkCompilation.*' ./internal/integration_test/bench -count=6 > new.txt
> 
> wazero on  lazyfunctiondefinition [$?⇡] via 🐹 v1.20.4 took 41s
> ❯ benchstat old.txt new.txt
> goos: darwin
> goarch: arm64
> pkg: github.com/tetratelabs/wazero/internal/integration_test/bench
>                                     │    old.txt    │               new.txt               │
>                                     │    sec/op     │    sec/op      vs base              │
> Compilation/with_extern_cache-10        81.90µ ± 2%     80.57µ ± 0%  -1.62% (p=0.002 n=6)
> Compilation/without_extern_cache-10     991.4µ ± 0%     964.7µ ± 0%  -2.69% (p=0.002 n=6)
> Compilation/interpreter-10              333.2µ ± 0%     327.0µ ± 1%  -1.86% (p=0.002 n=6)
> Compilation_Sqlite3/interpreter-10    0.04533n ± 0%   0.04456n ± 0%  -1.70% (p=0.002 n=6)
> Compilation_Sqlite3/compiler-10        0.1465n ± 1%    0.1427n ± 0%  -2.63% (p=0.002 n=6)
> geomean                                 709.4n          694.5n       -2.10%
> ```

@evacchi I recommend you always add `-benchmem` always to see the memory impacts
evacchi: ugh needs rebase 😅 
evacchi: apologies for the force-push

Patch:
diff --git a/experimental/experimental.go b/experimental/experimental.go
index 47022f20..63fd564d 100644
--- a/experimental/experimental.go
+++ b/experimental/experimental.go
@@ -20,3 +20,22 @@ type InternalModule interface {
 	// The methods panics if i is out of bounds.
 	Global(i int) api.Global
 }
+
+// ProgramCounter is an opaque value representing a specific execution point in
+// a module. It is meant to be used with Function.SourceOffsetForPC and
+// StackIterator.
+type ProgramCounter uint64
+
+// InternalFunction exposes some information about a function instance.
+type InternalFunction interface {
+	// Definition provides introspection into the function's names and
+	// signature.
+	Definition() api.FunctionDefinition
+
+	// SourceOffsetForPC resolves a program counter into its corresponding
+	// offset in the Code section of the module this function belongs to.
+	// The source offset is meant to help map the function calls to their
+	// location in the original source files. Returns 0 if the offset cannot
+	// be calculated.
+	SourceOffsetForPC(pc ProgramCounter) uint64
+}
diff --git a/experimental/gojs/README.md b/experimental/gojs/README.md
index d28debf3..b608b576 100644
--- a/experimental/gojs/README.md
+++ b/experimental/gojs/README.md
@@ -15,8 +15,8 @@ wazero includes an [example](example) that makes HTTP client requests.
 
 Go defines js "EXPERIMENTAL... exempt from the Go compatibility promise."
 Accordingly, wazero cannot guarantee this will work from release to release,
-or that usage will be relatively free of bugs. Moreover, [`GOOS=wasi`] will
-happen, and once that's available in two releases wazero will remove this
+or that usage will be relatively free of bugs. Moreover, [`GOOS=wasip1`][2] will be shipped
+in Go 1.21, and once that's available in two releases wazero will remove this
 package.
 
 Due to these concerns and the relatively high implementation overhead, most
diff --git a/experimental/listener.go b/experimental/listener.go
index 2dd8f940..7c1ce6fb 100644
--- a/experimental/listener.go
+++ b/experimental/listener.go
@@ -16,16 +16,11 @@ type StackIterator interface {
 	// Next moves the iterator to the next function in the stack. Returns
 	// false if it reached the bottom of the stack.
 	Next() bool
-	// FunctionDefinition returns the function type of the current function.
-	FunctionDefinition() api.FunctionDefinition
-	// SourceOffset computes the offset in the module Code section where the
-	// call occured (translated for native functions), or the beginning of
-	// function for the top of the stack. Returns 0 if the source offset
-	// cannot be calculated.
-	//
-	// The source offset is meant to help map the function calls to their
-	// location in the original source files.
-	SourceOffset() uint64
+	// Function describes the function called by the current frame.
+	Function() InternalFunction
+	// ProgramCounter returns the program counter associated with the
+	// function call.
+	ProgramCounter() ProgramCounter
 	// Parameters returns api.ValueType-encoded parameters of the current
 	// function. Do not modify the content of the slice, and copy out any
 	// value you need.
@@ -51,8 +46,11 @@ type FunctionListenerFactory interface {
 // FunctionListener can be registered for any function via
 // FunctionListenerFactory to be notified when the function is called.
 type FunctionListener interface {
-	// Before is invoked before a function is called. The returned context will
-	// be used as the context of this function call.
+	// Before is invoked before a function is called.
+	//
+	// There is always one corresponding call to After or Abort for each call to
+	// Before. This guarantee allows the listener to maintain an internal stack
+	// to perform correlations between the entry and exit of functions.
 	//
 	// # Params
 	//
@@ -60,27 +58,44 @@ type FunctionListener interface {
 	//	   instance or parent of the result.
 	//   - mod: the calling module.
 	//   - def: the function definition.
-	//   - paramValues:  api.ValueType encoded parameters.
+	//   - params:  api.ValueType encoded parameters.
 	//   - stackIterator: iterator on the call stack. At least one entry is
 	//     guaranteed (the called function), whose Args() will be equal to
-	//     paramValues. The iterator will be reused between calls to Before.
+	//     params. The iterator will be reused between calls to Before.
 	//
 	// Note: api.Memory is meant for inspection, not modification.
 	// mod can be cast to InternalModule to read non-exported globals.
-	Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator StackIterator) context.Context
+	Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator StackIterator)
 
 	// After is invoked after a function is called.
 	//
 	// # Params
 	//
-	//   - ctx: the context returned by Before.
+	//   - ctx: the context of the caller function.
 	//   - mod: the calling module.
 	//   - def: the function definition.
-	//   - err: nil if the function didn't err
-	//   - resultValues: api.ValueType encoded results.
+	//   - results: api.ValueType encoded results.
 	//
-	// Note: api.Memory is meant for inspection, not modification.
-	After(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64)
+	// # Notes
+	//
+	//   - api.Memory is meant for inspection, not modification.
+	//   - This is not called when a host function panics, or a guest function traps.
+	//      See Abort for more details.
+	After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64)
+
+	// Abort is invoked when a function does not return due to a trap or panic.
+	//
+	// # Params
+	//
+	//   - ctx: the context of the caller function.
+	//   - mod: the calling module.
+	//   - def: the function definition.
+	//   - err: the error value representing the reason why the function aborted.
+	//
+	// # Notes
+	//
+	//   - api.Memory is meant for inspection, not modification.
+	Abort(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error)
 }
 
 // FunctionListenerFunc is a function type implementing the FunctionListener
@@ -94,14 +109,18 @@ type FunctionListener interface {
 type FunctionListenerFunc func(context.Context, api.Module, api.FunctionDefinition, []uint64, StackIterator)
 
 // Before satisfies the FunctionListener interface, calls f.
-func (f FunctionListenerFunc) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator StackIterator) context.Context {
-	f(ctx, mod, def, paramValues, stackIterator)
-	return ctx
+func (f FunctionListenerFunc) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator StackIterator) {
+	f(ctx, mod, def, params, stackIterator)
 }
 
 // After is declared to satisfy the FunctionListener interface, but it does
 // nothing.
-func (f FunctionListenerFunc) After(context.Context, api.Module, api.FunctionDefinition, error, []uint64) {
+func (f FunctionListenerFunc) After(context.Context, api.Module, api.FunctionDefinition, []uint64) {
+}
+
+// Abort is declared to satisfy the FunctionListener interface, but it does
+// nothing.
+func (f FunctionListenerFunc) Abort(context.Context, api.Module, api.FunctionDefinition, error) {
 }
 
 // FunctionListenerFactoryFunc is a function type implementing the
@@ -155,18 +174,23 @@ type multiFunctionListener struct {
 	stack stackIterator
 }
 
-func (multi *multiFunctionListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, si StackIterator) context.Context {
+func (multi *multiFunctionListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, si StackIterator) {
 	multi.stack.base = si
 	for _, lstn := range multi.lstns {
 		multi.stack.index = -1
-		ctx = lstn.Before(ctx, mod, def, params, &multi.stack)
+		lstn.Before(ctx, mod, def, params, &multi.stack)
 	}
-	return ctx
 }
 
-func (multi *multiFunctionListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, results []uint64) {
+func (multi *multiFunctionListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
 	for _, lstn := range multi.lstns {
-		lstn.After(ctx, mod, def, err, results)
+		lstn.After(ctx, mod, def, results)
+	}
+}
+
+func (multi *multiFunctionListener) Abort(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error) {
+	for _, lstn := range multi.lstns {
+		lstn.Abort(ctx, mod, def, err)
 	}
 }
 
@@ -198,7 +222,7 @@ type stackIterator struct {
 	base   StackIterator
 	index  int
 	pcs    []uint64
-	fns    []api.FunctionDefinition
+	fns    []InternalFunction
 	params parameters
 }
 
@@ -209,8 +233,8 @@ func (si *stackIterator) Next() bool {
 		si.params.clear()
 
 		for si.base.Next() {
-			si.pcs = append(si.pcs, si.base.SourceOffset())
-			si.fns = append(si.fns, si.base.FunctionDefinition())
+			si.pcs = append(si.pcs, uint64(si.base.ProgramCounter()))
+			si.fns = append(si.fns, si.base.Function())
 			si.params.append(si.base.Parameters())
 		}
 
@@ -220,11 +244,11 @@ func (si *stackIterator) Next() bool {
 	return si.index < len(si.pcs)
 }
 
-func (si *stackIterator) SourceOffset() uint64 {
-	return si.pcs[si.index]
+func (si *stackIterator) ProgramCounter() ProgramCounter {
+	return ProgramCounter(si.pcs[si.index])
 }
 
-func (si *stackIterator) FunctionDefinition() api.FunctionDefinition {
+func (si *stackIterator) Function() InternalFunction {
 	return si.fns[si.index]
 }
 
@@ -237,9 +261,23 @@ type StackFrame struct {
 	Function     api.Function
 	Params       []uint64
 	Results      []uint64
+	PC           uint64
 	SourceOffset uint64
 }
 
+type internalFunction struct {
+	definition   api.FunctionDefinition
+	sourceOffset uint64
+}
+
+func (f internalFunction) Definition() api.FunctionDefinition {
+	return f.definition
+}
+
+func (f internalFunction) SourceOffsetForPC(pc ProgramCounter) uint64 {
+	return f.sourceOffset
+}
+
 // stackFrameIterator is an implementation of the experimental.stackFrameIterator
 // interface.
 type stackFrameIterator struct {
@@ -253,12 +291,15 @@ func (si *stackFrameIterator) Next() bool {
 	return si.index < len(si.stack)
 }
 
-func (si *stackFrameIterator) FunctionDefinition() api.FunctionDefinition {
-	return si.fndef[si.index]
+func (si *stackFrameIterator) Function() InternalFunction {
+	return internalFunction{
+		definition:   si.fndef[si.index],
+		sourceOffset: si.stack[si.index].SourceOffset,
+	}
 }
 
-func (si *stackFrameIterator) SourceOffset() uint64 {
-	return si.stack[si.index].SourceOffset
+func (si *stackFrameIterator) ProgramCounter() ProgramCounter {
+	return ProgramCounter(si.stack[si.index].PC)
 }
 
 func (si *stackFrameIterator) Parameters() []uint64 {
@@ -300,26 +341,21 @@ func BenchmarkFunctionListener(n int, module api.Module, stack []StackFrame, lis
 		panic("cannot benchmark function listener with an empty stack")
 	}
 
-	functionDefinition := stack[0].Function.Definition()
-	functionParams := stack[0].Params
-	functionResults := stack[0].Results
-	stackIterator := &stackIterator{base: NewStackIterator(stack...)}
 	ctx := context.Background()
+	def := stack[0].Function.Definition()
+	params := stack[0].Params
+	results := stack[0].Results
+	stackIterator := &stackIterator{base: NewStackIterator(stack...)}
 
 	for i := 0; i < n; i++ {
 		stackIterator.index = -1
-		callContext := listener.Before(ctx, module, functionDefinition, functionParams, stackIterator)
-		listener.After(callContext, module, functionDefinition, nil, functionResults)
+		listener.Before(ctx, module, def, params, stackIterator)
+		listener.After(ctx, module, def, results)
 	}
 }
 
-// TODO: We need to add tests to enginetest to ensure contexts nest. A good test can use a combination of call and call
-// indirect in terms of depth and breadth. The test could show a tree 3 calls deep where the there are a couple calls at
-// each depth under the root. The main thing this can help prevent is accidentally swapping the context internally.
-
-// TODO: Errors aren't handled, and the After hook should accept one along with the result values.
-
-// TODO: The context parameter of the After hook is not the same as the Before hook. This means interceptor patterns
-// are awkward. e.g. something like timing is difficult as it requires propagating a stack. Otherwise, nested calls will
-// overwrite each other's "since" time. Propagating a stack is further awkward as the After hook needs to know the
-// position to read from which might be subtle.
+// TODO: the calls to Abort are not yet tested in internal/testing/enginetest,
+// but they are validated indirectly in tests which exercise host logging,
+// like Test_procExit in imports/wasi_snapshot_preview1. Eventually we should
+// add dedicated tests to validate the behavior of the interpreter and compiler
+// engines independently.
diff --git a/experimental/logging/log_listener.go b/experimental/logging/log_listener.go
index 51452f86..89839f68 100644
--- a/experimental/logging/log_listener.go
+++ b/experimental/logging/log_listener.go
@@ -87,6 +87,7 @@ type loggingListenerFactory struct {
 	w        logging.Writer
 	hostOnly bool
 	scopes   logging.LogScopes
+	stack    logStack
 }
 
 type flusher interface {
@@ -154,19 +155,34 @@ func (f *loggingListenerFactory) NewFunctionListener(fnd api.FunctionDefinition)
 		pLoggers:     pLoggers,
 		pSampler:     pSampler,
 		rLoggers:     rLoggers,
+		stack:        &f.stack,
 	}
 }
 
-// logState saves a copy of params between calls as the slice underlying them
-// is a stack reused for results.
-type logState struct {
-	w         logging.Writer
-	nestLevel int
-	unsampled bool
-	params    []uint64
+type logStack struct {
+	params [][]uint64
 }
 
-var unsampledLogState = &logState{unsampled: true}
+func (s *logStack) push(params []uint64) {
+	s.params = append(s.params, params)
+}
+
+func (s *logStack) pop() []uint64 {
+	i := len(s.params) - 1
+	params := s.params[i]
+	s.params[i] = nil
+	s.params = s.params[:i]
+	return params
+}
+
+func (s *logStack) count() (n int) {
+	for _, p := range s.params {
+		if p != nil {
+			n++
+		}
+	}
+	return n
+}
 
 // loggingListener implements experimental.FunctionListener to log entrance and after
 // of each function call.
@@ -176,78 +192,52 @@ type loggingListener struct {
 	pLoggers                  []logging.ParamLogger
 	pSampler                  logging.ParamSampler
 	rLoggers                  []logging.ResultLogger
+	stack                     *logStack
 }
 
 // Before logs to stdout the module and function name, prefixed with '-->' and
 // indented based on the call nesting level.
-func (l *loggingListener) Before(ctx context.Context, mod api.Module, _ api.FunctionDefinition, params []uint64, si experimental.StackIterator) context.Context {
+func (l *loggingListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, _ experimental.StackIterator) {
 	// First, see if this invocation is sampled.
 	sampled := true
 	if s := l.pSampler; s != nil {
 		sampled = s(ctx, mod, params)
 	}
 
-	// Check to see if the calling function was logging.
-	var state *logState
-	var nestLevel int
-	if v := ctx.Value(logging.LoggerKey{}); v != nil {
-		if !sampled { // override to mute this invocation.
-			return context.WithValue(ctx, logging.LoggerKey{}, unsampledLogState)
-		}
-		state = v.(*logState)
-		nestLevel = state.nestLevel
-	} else if !sampled {
-		return ctx // lack of LoggerKey == not sampled.
-	}
-
-	// We're starting to log: increase the indentation level.
-	nestLevel++
-
-	l.logIndented(ctx, mod, nestLevel, true, params, nil, nil)
-
-	// We need to propagate this invocation's parameters to the after callback.
-	state = &logState{w: l.w, nestLevel: nestLevel}
-	if pLen := len(params); pLen > 0 {
-		state.params = make([]uint64, pLen)
-		copy(state.params, params) // safe copy
-	} else { // empty
-		state.params = params
+	if sampled {
+		l.logIndented(l.stack.count(), l.beforePrefix, func() {
+			l.logParams(ctx, mod, params)
+		})
+		params = append([]uint64{}, params...)
+	} else {
+		params = nil
 	}
 
-	// Overwrite the logging key with this invocation's state.
-	return context.WithValue(ctx, logging.LoggerKey{}, state)
+	l.stack.push(params)
 }
 
 // After logs to stdout the module and function name, prefixed with '<--' and
 // indented based on the call nesting level.
-func (l *loggingListener) After(ctx context.Context, mod api.Module, _ api.FunctionDefinition, err error, results []uint64) {
-	// Note: We use the nest level directly even though it is the "next" nesting level.
-	// This works because our indent of zero nesting is one tab.
-	if state, ok := ctx.Value(logging.LoggerKey{}).(*logState); ok {
-		if state == unsampledLogState {
-			return
-		}
-		l.logIndented(ctx, mod, state.nestLevel, false, state.params, err, results)
+func (l *loggingListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
+	if params := l.stack.pop(); params != nil {
+		l.logIndented(l.stack.count(), l.afterPrefix, func() {
+			l.logResults(ctx, mod, params, results)
+		})
 	}
 }
 
-// logIndented logs an indented l.w like this: "-->\t\t\t$nestLevel$funcName\n"
-func (l *loggingListener) logIndented(ctx context.Context, mod api.Module, nestLevel int, isBefore bool, params []uint64, err error, results []uint64) {
-	for i := 1; i < nestLevel; i++ {
+func (l *loggingListener) Abort(ctx context.Context, mod api.Module, def api.FunctionDefinition, _ error) {
+	l.stack.pop()
+}
+
+// logIndented writes an indentation level and prefix prior to calling log to
+// output the log line.
+func (l *loggingListener) logIndented(nestLevel int, prefix string, log func()) {
+	for i := 0; i < nestLevel; i++ {
 		l.w.WriteByte('\t') //nolint
 	}
-	if isBefore { // before
-		l.w.WriteString(l.beforePrefix) //nolint
-		l.logParams(ctx, mod, params)
-	} else { // after
-		l.w.WriteString(l.afterPrefix) //nolint
-		if err != nil {
-			l.w.WriteString(" error: ")  //nolint
-			l.w.WriteString(err.Error()) //nolint
-		} else {
-			l.logResults(ctx, mod, params, results)
-		}
-	}
+	l.w.WriteString(prefix) //nolint
+	log()
 	l.w.WriteByte('\n') //nolint
 
 	if f, ok := l.w.(flusher); ok {
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 9226298f..ef68e373 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -201,21 +201,12 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) syscall.E
 		return syscall.EBADF
 	} else if st, errno = f.Stat(); errno != 0 {
 		return errno
-	} else {
-		var nonblock bool
-		switch file := f.File.File().(type) {
-		case *sys.StdioFileReader:
-			nonblock = file.IsNonblock()
-		case *sys.StdioFileWriter:
-			nonblock = file.IsNonblock()
-		default:
-			if f.File.AccessMode() != syscall.O_RDONLY {
-				fdflags |= wasip1.FD_APPEND
-			}
-		}
-		if nonblock {
+	} else if isPreopenedStdio(fd, f) {
+		if f.File.IsNonblock() {
 			fdflags |= wasip1.FD_NONBLOCK
 		}
+	} else if f.File.AccessMode() != syscall.O_RDONLY {
+		fdflags |= wasip1.FD_APPEND
 	}
 
 	var fsRightsBase uint32
@@ -236,6 +227,23 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) syscall.E
 	return 0
 }
 
+// isPreopenedStdio returns true if the FD is sys.FdStdin, sys.FdStdout or
+// sys.FdStderr and pre-opened. This double check is needed in case the guest
+// closes stdin and re-opens it with a random alternative file.
+//
+// Currently, we only support non-blocking mode for standard I/O streams.
+// Non-blocking mode is rarely supported for regular files, and we don't
+// yet have support for sockets, so we make a special case.
+//
+// Note: this to get or set FD_NONBLOCK, but skip FD_APPEND. Our current
+// implementation can't set FD_APPEND, without re-opening files. As stdio are
+// pre-opened, we don't know how to re-open them, neither should we close the
+// underlying file. Later, we could add support for setting FD_APPEND, similar
+// to SetNonblock.
+func isPreopenedStdio(fd int32, f *sys.FileEntry) bool {
+	return fd <= sys.FdStderr && f.IsPreopen
+}
+
 const fileRightsBase = wasip1.RIGHT_FD_DATASYNC |
 	wasip1.RIGHT_FD_READ |
 	wasip1.RIGHT_FD_SEEK |
@@ -293,20 +301,14 @@ func fdFdstatSetFlagsFn(_ context.Context, mod api.Module, params []uint64) sysc
 		return syscall.EINVAL
 	}
 
-	// Currently we only support non-blocking mode for standard I/O streams.
-	// Non-blocking mode is rarely supported for regular files, and we don't
-	// yet have support for sockets so we make a special case.
-	f, ok := fsc.LookupFile(fd)
-	if ok {
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return syscall.EBADF
+	} else if isPreopenedStdio(fd, f) {
 		nonblock := wasip1.FD_NONBLOCK&wasiFlag != 0
-		switch file := f.File.File().(type) {
-		case *sys.StdioFileReader:
-			return platform.UnwrapOSError(file.SetNonblock(nonblock))
-		case *sys.StdioFileWriter:
-			return platform.UnwrapOSError(file.SetNonblock(nonblock))
-		}
+		return f.File.SetNonblock(nonblock)
 	}
 
+	// For normal files, proceed to apply an append flag.
 	var flag int
 	if wasip1.FD_APPEND&wasiFlag != 0 {
 		flag = syscall.O_APPEND
@@ -842,7 +844,7 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 		if errno != 0 {
 			return errno
 		}
-		rd, dir = f.File.File(), f.ReadDir
+		rd, dir = f.File, f.ReadDir
 	}
 
 	// First, determine the maximum directory entries that can be encoded as
@@ -880,7 +882,7 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 	if entryCount := len(dirents); entryCount < maxDirEntries {
 		// Note: platform.Readdir does not return io.EOF as it is
 		// inconsistently returned (e.g. darwin does, but linux doesn't).
-		l, errno := platform.Readdir(rd, maxDirEntries-entryCount)
+		l, errno := rd.Readdir(maxDirEntries - entryCount)
 		if errno != 0 {
 			return errno
 		}
@@ -923,7 +925,7 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 
 // dotDirents returns "." and "..", where "." because wasi-testsuite does inode
 // validation.
-func dotDirents(f *sys.FileEntry) ([]*platform.Dirent, syscall.Errno) {
+func dotDirents(f *sys.FileEntry) ([]platform.Dirent, syscall.Errno) {
 	if isDir, errno := f.File.IsDir(); errno != 0 {
 		return nil, errno
 	} else if !isDir {
@@ -941,7 +943,7 @@ func dotDirents(f *sys.FileEntry) ([]*platform.Dirent, syscall.Errno) {
 			dotDotIno = st.Ino
 		}
 	}
-	return []*platform.Dirent{
+	return []platform.Dirent{
 		{Name: ".", Ino: dotIno, Type: fs.ModeDir},
 		{Name: "..", Ino: dotDotIno, Type: fs.ModeDir},
 	}, 0
@@ -950,7 +952,7 @@ func dotDirents(f *sys.FileEntry) ([]*platform.Dirent, syscall.Errno) {
 const largestDirent = int64(math.MaxUint32 - wasip1.DirentSize)
 
 // lastDirents is broken out from fdReaddirFn for testability.
-func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []*platform.Dirent, errno syscall.Errno) {
+func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []platform.Dirent, errno syscall.Errno) {
 	if cookie < 0 {
 		errno = syscall.EINVAL // invalid as we will never send a negative cookie.
 		return
@@ -993,21 +995,22 @@ func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []*platform.Dirent, er
 //
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_readdir
 // See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/cloudlibc/src/libc/dirent/readdir.c#L44
-func maxDirents(entries []*platform.Dirent, bufLen uint32) (bufused, direntCount uint32, writeTruncatedEntry bool) {
+func maxDirents(dirents []platform.Dirent, bufLen uint32) (bufused, direntCount uint32, writeTruncatedEntry bool) {
 	lenRemaining := bufLen
-	for _, e := range entries {
+	for i := range dirents {
+		d := dirents[i]
 		if lenRemaining < wasip1.DirentSize {
 			// We don't have enough space in bufLen for another struct,
 			// entry. A caller who wants more will retry.
 
-			// bufused == bufLen means more entries exist, which is the case
+			// bufused == bufLen means more dirents exist, which is the case
 			// when the dirent is larger than bytes remaining.
 			bufused = bufLen
 			break
 		}
 
 		// use int64 to guard against huge filenames
-		nameLen := int64(len(e.Name))
+		nameLen := int64(len(d.Name))
 		var entryLen uint32
 
 		// Check to see if DirentSize + nameLen overflows, or if it would be
@@ -1028,7 +1031,7 @@ func maxDirents(entries []*platform.Dirent, bufLen uint32) (bufused, direntCount
 			// In this case, we only write up to DirentSize(24) to allow the
 			// caller to resize.
 
-			// bufused == bufLen means more entries exist, which is the case
+			// bufused == bufLen means more dirents exist, which is the case
 			// when the next entry is larger than bytes remaining.
 			bufused = bufLen
 
@@ -1050,7 +1053,7 @@ func maxDirents(entries []*platform.Dirent, bufLen uint32) (bufused, direntCount
 // based on maxDirents.	truncatedEntryLen means write one past entryCount,
 // without its name. See maxDirents for why
 func writeDirents(
-	dirents []*platform.Dirent,
+	dirents []platform.Dirent,
 	direntCount uint32,
 	writeTruncatedEntry bool,
 	buf []byte,
@@ -1092,7 +1095,7 @@ func writeDirent(buf []byte, dNext uint64, ino uint64, dNamlen uint32, dType fs.
 }
 
 // openedDir returns the directory and 0 if the fd points to a readable directory.
-func openedDir(fsc *sys.FSContext, fd int32) (fs.File, *sys.ReadDir, syscall.Errno) {
+func openedDir(fsc *sys.FSContext, fd int32) (platform.File, *sys.ReadDir, syscall.Errno) {
 	if f, ok := fsc.LookupFile(fd); !ok {
 		return nil, nil, syscall.EBADF
 	} else if isDir, errno := f.File.IsDir(); errno != 0 {
@@ -1109,7 +1112,7 @@ func openedDir(fsc *sys.FSContext, fd int32) (fs.File, *sys.ReadDir, syscall.Err
 		if f.ReadDir == nil {
 			f.ReadDir = &sys.ReadDir{}
 		}
-		return f.File.File(), f.ReadDir, 0
+		return f.File, f.ReadDir, 0
 	}
 }
 
@@ -1738,6 +1741,10 @@ func preopenPath(fsc *sys.FSContext, fd int32) (string, syscall.Errno) {
 		return "", syscall.EBADF // closed
 	} else if !f.IsPreopen {
 		return "", syscall.EBADF
+	} else if isDir, errno := f.File.IsDir(); errno != 0 || !isDir {
+		// In wasip1, only directories can be returned by fd_prestat_get as
+		// there are no prestat types defined for files or sockets.
+		return "", errno
 	} else {
 		return f.Name, 0
 	}
diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go
index 37e6a776..c3097277 100644
--- a/imports/wasi_snapshot_preview1/poll.go
+++ b/imports/wasi_snapshot_preview1/poll.go
@@ -6,7 +6,6 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/api"
-	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasip1"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -50,7 +49,7 @@ type event struct {
 	outOffset uint32
 }
 
-func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall.Errno {
+func pollOneoffFn(_ context.Context, mod api.Module, params []uint64) syscall.Errno {
 	in := uint32(params[0])
 	out := uint32(params[1])
 	nsubscriptions := uint32(params[2])
@@ -162,11 +161,14 @@ func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall.
 
 	// If there are stdin subscribers, check for data with given timeout.
 	if len(stdinSubs) > 0 {
-		reader := getStdioFileReader(mod)
+		stdin, ok := fsc.LookupFile(internalsys.FdStdin)
+		if !ok {
+			return syscall.EBADF
+		}
 		// Wait for the timeout to expire, or for some data to become available on Stdin.
-		stdinReady, err := reader.Poll(timeout)
-		if err != nil {
-			return platform.UnwrapOSError(err)
+		stdinReady, errno := stdin.File.PollRead(&timeout)
+		if errno != 0 {
+			return errno
 		}
 		if stdinReady {
 			// stdin has data ready to for reading, write back all the events
@@ -249,18 +251,3 @@ func writeEvent(outBuf []byte, evt *event) {
 	le.PutUint32(outBuf[evt.outOffset+10:], uint32(evt.eventType))
 	// TODO: When FD events are supported, write outOffset+16
 }
-
-// getStdioFileReader extracts a StdioFileReader for FdStdin from the given api.Module instance.
-// and panics if this is not possible.
-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
-		}
-	}
-	panic("unexpected error: Stdin must always be a StdioFileReader")
-}
diff --git a/internal/engine/compiler/engine.go b/internal/engine/compiler/engine.go
index 55c13e57..a4907856 100644
--- a/internal/engine/compiler/engine.go
+++ b/internal/engine/compiler/engine.go
@@ -133,9 +133,6 @@ type (
 		// This is modified when there's a function listener call, otherwise it's always the context.Context
 		// passed to the Call API.
 		ctx context.Context
-		// contextStack is a stack of contexts which is pushed and popped by function listeners.
-		// This is used and modified when there are function listeners.
-		contextStack []context.Context
 
 		// stackIterator provides a way to iterate over the stack for Listeners.
 		// It is setup and valid only during a call to a Listener hook.
@@ -305,6 +302,13 @@ type (
 		// See wazeroir.CompilationResult irOperationOffsetsInNativeBinary.
 		irOperationSourceOffsetsInWasmBinary []uint64
 	}
+
+	// functionListenerInvocation captures arguments needed to perform function
+	// listener invocations when unwinding the call stack.
+	functionListenerInvocation struct {
+		experimental.FunctionListener
+		def api.FunctionDefinition
+	}
 )
 
 // Native code reads/writes Go's structs with the following constants.
@@ -539,7 +543,7 @@ func (e *engine) CompileModule(_ context.Context, module *wasm.Module, listeners
 			cmp.Init(typ, nil, lsn != nil)
 			withGoFunc = true
 			if body, err = compileGoDefinedHostFunction(cmp); err != nil {
-				def := module.FunctionDefinitionSection[funcIndex+importedFuncs]
+				def := module.FunctionDefinition(funcIndex + importedFuncs)
 				return fmt.Errorf("error compiling host go func[%s]: %w", def.DebugName(), err)
 			}
 			compiledFn.goFunc = codeSeg.GoFunc
@@ -552,7 +556,7 @@ func (e *engine) CompileModule(_ context.Context, module *wasm.Module, listeners
 
 			body, compiledFn.stackPointerCeil, compiledFn.sourceOffsetMap, err = compileWasmFunction(cmp, ir)
 			if err != nil {
-				def := module.FunctionDefinitionSection[funcIndex+importedFuncs]
+				def := module.FunctionDefinition(funcIndex + importedFuncs)
 				return fmt.Errorf("error compiling wasm func[%s]: %w", def.DebugName(), err)
 			}
 		}
@@ -691,7 +695,7 @@ func (ce *callEngine) Definition() api.FunctionDefinition {
 
 func (f *function) definition() api.FunctionDefinition {
 	compiled := f.parent
-	return &compiled.parent.source.FunctionDefinitionSection[compiled.index]
+	return compiled.parent.source.FunctionDefinition(compiled.index)
 }
 
 // Call implements the same method as documented on wasm.ModuleEngine.
@@ -731,7 +735,7 @@ func (ce *callEngine) call(ctx context.Context, params, results []uint64) (_ []u
 	// and we have to make sure that all the runtime errors, including the one happening inside
 	// host functions, will be captured as errors, not panics.
 	defer func() {
-		err = ce.deferredOnCall(recover())
+		err = ce.deferredOnCall(ctx, m, recover())
 		if err == nil {
 			// If the module closed during the call, and the call didn't err for another reason, set an ExitError.
 			err = m.FailIfClosed()
@@ -811,7 +815,7 @@ func callFrameOffset(funcType *wasm.FunctionType) (ret int) {
 // the state of callEngine so that it can be used for the subsequent calls.
 //
 // This is defined for testability.
-func (ce *callEngine) deferredOnCall(recovered interface{}) (err error) {
+func (ce *callEngine) deferredOnCall(ctx context.Context, m *wasm.ModuleInstance, recovered interface{}) (err error) {
 	if recovered != nil {
 		builder := wasmdebug.NewErrorBuilder()
 
@@ -820,6 +824,8 @@ func (ce *callEngine) deferredOnCall(recovered interface{}) (err error) {
 		fn := ce.fn
 		pc := uint64(ce.returnAddress)
 		stackBasePointer := int(ce.stackBasePointerInBytes >> 3)
+		functionListeners := make([]functionListenerInvocation, 0, 16)
+
 		for {
 			def := fn.definition()
 
@@ -834,6 +840,13 @@ func (ce *callEngine) deferredOnCall(recovered interface{}) (err error) {
 			}
 			builder.AddFrame(def.DebugName(), def.ParamTypes(), def.ResultTypes(), sources)
 
+			if fn.parent.listener != nil {
+				functionListeners = append(functionListeners, functionListenerInvocation{
+					FunctionListener: fn.parent.listener,
+					def:              fn.definition(),
+				})
+			}
+
 			callFrameOffset := callFrameOffset(fn.funcType)
 			if stackBasePointer != 0 {
 				frame := *(*callFrame)(unsafe.Pointer(&ce.stack[stackBasePointer+callFrameOffset]))
@@ -844,7 +857,13 @@ func (ce *callEngine) deferredOnCall(recovered interface{}) (err error) {
 				break
 			}
 		}
+
 		err = builder.FromRecovered(recovered)
+		// Call listeners in revers order since they were captured while
+		// rewinding the call stack.
+		for i := len(functionListeners) - 1; i >= 0; i-- {
+			functionListeners[i].Abort(ctx, m, functionListeners[i].def, err)
+		}
 	}
 
 	// Allows the reuse of CallEngine.
@@ -975,7 +994,6 @@ const (
 func (ce *callEngine) execWasmFunction(ctx context.Context, m *wasm.ModuleInstance) {
 	codeAddr := ce.initialFn.codeInitialAddress
 	modAddr := ce.initialFn.moduleInstance
-	ce.ctx = ctx
 
 entry:
 	{
@@ -1001,9 +1019,9 @@ entry:
 			fn := calleeHostFunction.parent.goFunc
 			switch fn := fn.(type) {
 			case api.GoModuleFunction:
-				fn.Call(ce.ctx, ce.callerModuleInstance, stack)
+				fn.Call(ctx, ce.callerModuleInstance, stack)
 			case api.GoFunction:
-				fn.Call(ce.ctx, stack)
+				fn.Call(ctx, stack)
 			}
 
 			codeAddr, modAddr = ce.returnAddress, ce.moduleInstance
@@ -1018,9 +1036,9 @@ entry:
 			case builtinFunctionIndexTableGrow:
 				ce.builtinFunctionTableGrow(caller.moduleInstance.Tables)
 			case builtinFunctionIndexFunctionListenerBefore:
-				ce.builtinFunctionFunctionListenerBefore(ce.ctx, m, caller)
+				ce.builtinFunctionFunctionListenerBefore(ctx, m, caller)
 			case builtinFunctionIndexFunctionListenerAfter:
-				ce.builtinFunctionFunctionListenerAfter(ce.ctx, m, caller)
+				ce.builtinFunctionFunctionListenerAfter(ctx, m, caller)
 			case builtinFunctionIndexCheckExitCode:
 				// Note: this operation must be done in Go, not native code. The reason is that
 				// native code cannot be preempted and that means it can block forever if there are not
@@ -1116,7 +1134,7 @@ func (si *stackIterator) clear() {
 	si.started = false
 }
 
-// Next implements the same method as documtend on experimental.StackIterator.
+// Next implements the same method as documented on experimental.StackIterator.
 func (si *stackIterator) Next() bool {
 	if !si.started {
 		si.started = true
@@ -1136,51 +1154,52 @@ func (si *stackIterator) Next() bool {
 	return si.fn != nil
 }
 
-// SourceOffset implements the same method as documented on
-// experimental.StackIterator.
-func (si *stackIterator) SourceOffset() uint64 {
-	p := si.fn.parent
-
-	if len(p.sourceOffsetMap.irOperationSourceOffsetsInWasmBinary) == 0 {
-		return 0 // source not available
-	}
-
-	return si.fn.getSourceOffsetInWasmBinary(si.pc)
+// ProgramCounter implements the same method as documented on experimental.StackIterator.
+func (si *stackIterator) ProgramCounter() experimental.ProgramCounter {
+	return experimental.ProgramCounter(si.pc)
 }
 
-// FunctionDefinition implements the same method as documented on
-// experimental.StackIterator.
-func (si *stackIterator) FunctionDefinition() api.FunctionDefinition {
-	return si.fn.definition()
+// Function implements the same method as documented on experimental.StackIterator.
+func (si *stackIterator) Function() experimental.InternalFunction {
+	return internalFunction{si.fn}
 }
 
-// Parameters implements the same method as documented on
-// experimental.StackIterator.
+// Parameters implements the same method as documented on experimental.StackIterator.
 func (si *stackIterator) Parameters() []uint64 {
 	return si.stack[si.base : si.base+si.fn.funcType.ParamNumInUint64]
 }
 
+// internalFunction implements experimental.InternalFunction.
+type internalFunction struct{ *function }
+
+// Definition implements the same method as documented on experimental.InternalFunction.
+func (f internalFunction) Definition() api.FunctionDefinition {
+	return f.definition()
+}
+
+// SourceOffsetForPC implements the same method as documented on experimental.InternalFunction.
+func (f internalFunction) SourceOffsetForPC(pc experimental.ProgramCounter) uint64 {
+	p := f.parent
+	if len(p.sourceOffsetMap.irOperationSourceOffsetsInWasmBinary) == 0 {
+		return 0 // source not available
+	}
+	return f.getSourceOffsetInWasmBinary(uint64(pc))
+}
+
 func (ce *callEngine) builtinFunctionFunctionListenerBefore(ctx context.Context, mod api.Module, fn *function) {
 	base := int(ce.stackBasePointerInBytes >> 3)
 	pc := uint64(ce.returnAddress)
 	ce.stackIterator.reset(ce.stack, fn, base, pc)
 
 	params := ce.stack[base : base+fn.funcType.ParamNumInUint64]
-	listerCtx := fn.parent.listener.Before(ctx, mod, fn.definition(), params, &ce.stackIterator)
+	fn.parent.listener.Before(ctx, mod, fn.definition(), params, &ce.stackIterator)
 
-	ce.contextStack = append(ce.contextStack, ctx)
-	ce.ctx = listerCtx
 	ce.stackIterator.clear()
 }
 
 func (ce *callEngine) builtinFunctionFunctionListenerAfter(ctx context.Context, mod api.Module, fn *function) {
 	base := int(ce.stackBasePointerInBytes >> 3)
-	fn.parent.listener.After(ctx, mod, fn.definition(), nil, ce.stack[base:base+fn.funcType.ResultNumInUint64])
-
-	i := len(ce.contextStack) - 1
-	ce.ctx = ce.contextStack[i]
-	ce.contextStack[i] = nil
-	ce.contextStack = ce.contextStack[:i]
+	fn.parent.listener.After(ctx, mod, fn.definition(), ce.stack[base:base+fn.funcType.ResultNumInUint64])
 }
 
 func compileGoDefinedHostFunction(cmp compiler) (body []byte, err error) {
diff --git a/internal/engine/interpreter/interpreter.go b/internal/engine/interpreter/interpreter.go
index b2586716..1cf33b0b 100644
--- a/internal/engine/interpreter/interpreter.go
+++ b/internal/engine/interpreter/interpreter.go
@@ -259,21 +259,16 @@ func (si *stackIterator) Next() bool {
 	return true
 }
 
-// FunctionDefinition implements the same method as documented on
+// Function implements the same method as documented on
 // experimental.StackIterator.
-func (si *stackIterator) FunctionDefinition() api.FunctionDefinition {
-	return si.fn.definition()
+func (si *stackIterator) Function() experimental.InternalFunction {
+	return internalFunction{si.fn}
 }
 
-// SourceOffset implements the same method as documented on
+// ProgramCounter implements the same method as documented on
 // experimental.StackIterator.
-func (si *stackIterator) SourceOffset() uint64 {
-	offsetsMap := si.fn.parent.offsetsInWasmBinary
-	pc := si.pc
-	if pc < uint64(len(offsetsMap)) {
-		return offsetsMap[pc]
-	}
-	return 0
+func (si *stackIterator) ProgramCounter() experimental.ProgramCounter {
+	return experimental.ProgramCounter(si.pc)
 }
 
 // Parameters implements the same method as documented on
@@ -284,6 +279,25 @@ func (si *stackIterator) Parameters() []uint64 {
 	return si.stack[top-paramsCount:]
 }
 
+// internalFunction implements experimental.InternalFunction.
+type internalFunction struct{ *function }
+
+// Definition implements the same method as documented on
+// experimental.InternalFunction.
+func (f internalFunction) Definition() api.FunctionDefinition {
+	return f.definition()
+}
+
+// SourceOffsetForPC implements the same method as documented on
+// experimental.InternalFunction.
+func (f internalFunction) SourceOffsetForPC(pc experimental.ProgramCounter) uint64 {
+	offsetsMap := f.parent.offsetsInWasmBinary
+	if uint64(pc) < uint64(len(offsetsMap)) {
+		return offsetsMap[pc]
+	}
+	return 0
+}
+
 // interpreter mode doesn't maintain call frames in the stack, so pass the zero size to the IR.
 const callFrameStackSize = 0
 
@@ -318,7 +332,7 @@ func (e *engine) CompileModule(_ context.Context, module *wasm.Module, listeners
 			}
 			err = e.lowerIR(ir, compiled)
 			if err != nil {
-				def := module.FunctionDefinitionSection[uint32(i)+module.ImportFunctionCount]
+				def := module.FunctionDefinition(uint32(i) + module.ImportFunctionCount)
 				return fmt.Errorf("failed to lower func[%s] to wazeroir: %w", def.DebugName(), err)
 			}
 		}
@@ -470,7 +484,7 @@ func (ce *callEngine) Definition() api.FunctionDefinition {
 
 func (f *function) definition() api.FunctionDefinition {
 	compiled := f.parent
-	return &compiled.source.FunctionDefinitionSection[compiled.index]
+	return compiled.source.FunctionDefinition(compiled.index)
 }
 
 // Call implements the same method as documented on api.Function.
@@ -513,7 +527,7 @@ func (ce *callEngine) call(ctx context.Context, params, results []uint64) (_ []u
 		// TODO: ^^ Will not fail if the function was imported from a closed module.
 
 		if v := recover(); v != nil {
-			err = ce.recoverOnCall(v)
+			err = ce.recoverOnCall(ctx, m, v)
 		}
 	}()
 
@@ -537,12 +551,21 @@ func (ce *callEngine) call(ctx context.Context, params, results []uint64) (_ []u
 	return results, nil
 }
 
+// functionListenerInvocation captures arguments needed to perform function
+// listener invocations when unwinding the call stack.
+type functionListenerInvocation struct {
+	experimental.FunctionListener
+	def api.FunctionDefinition
+}
+
 // recoverOnCall takes the recovered value `recoverOnCall`, and wraps it
 // with the call frame stack traces. Also, reset the state of callEngine
 // so that it can be used for the subsequent calls.
-func (ce *callEngine) recoverOnCall(v interface{}) (err error) {
+func (ce *callEngine) recoverOnCall(ctx context.Context, m *wasm.ModuleInstance, v interface{}) (err error) {
 	builder := wasmdebug.NewErrorBuilder()
 	frameCount := len(ce.frames)
+	functionListeners := make([]functionListenerInvocation, 0, 16)
+
 	for i := 0; i < frameCount; i++ {
 		frame := ce.popFrame()
 		f := frame.f
@@ -552,8 +575,18 @@ func (ce *callEngine) recoverOnCall(v interface{}) (err error) {
 			sources = parent.source.DWARFLines.Line(parent.offsetsInWasmBinary[frame.pc])
 		}
 		builder.AddFrame(def.DebugName(), def.ParamTypes(), def.ResultTypes(), sources)
+		if f.parent.listener != nil {
+			functionListeners = append(functionListeners, functionListenerInvocation{
+				FunctionListener: f.parent.listener,
+				def:              f.definition(),
+			})
+		}
 	}
+
 	err = builder.FromRecovered(v)
+	for i := len(functionListeners) - 1; i >= 0; i-- {
+		functionListeners[i].Abort(ctx, m, functionListeners[i].def, err)
+	}
 
 	// Allows the reuse of CallEngine.
 	ce.stack, ce.frames = ce.stack[:0], ce.frames[:0]
@@ -576,7 +609,7 @@ func (ce *callEngine) callGoFunc(ctx context.Context, m *wasm.ModuleInstance, f
 	if lsn != nil {
 		params := stack[:typ.ParamNumInUint64]
 		ce.stackIterator.reset(ce.stack, ce.frames, f)
-		ctx = lsn.Before(ctx, m, f.definition(), params, &ce.stackIterator)
+		lsn.Before(ctx, m, f.definition(), params, &ce.stackIterator)
 		ce.stackIterator.clear()
 	}
 	frame := &callFrame{f: f, base: len(ce.stack)}
@@ -594,7 +627,7 @@ func (ce *callEngine) callGoFunc(ctx context.Context, m *wasm.ModuleInstance, f
 	if lsn != nil {
 		// TODO: This doesn't get the error due to use of panic to propagate them.
 		results := stack[:typ.ResultNumInUint64]
-		lsn.After(ctx, m, f.definition(), nil, results)
+		lsn.After(ctx, m, f.definition(), results)
 	}
 }
 
@@ -4070,10 +4103,10 @@ func (ce *callEngine) callNativeFuncWithListener(ctx context.Context, m *wasm.Mo
 	def, typ := f.definition(), f.funcType
 
 	ce.stackIterator.reset(ce.stack, ce.frames, f)
-	ctx = fnl.Before(ctx, m, def, ce.peekValues(len(typ.Params)), &ce.stackIterator)
+	fnl.Before(ctx, m, def, ce.peekValues(len(typ.Params)), &ce.stackIterator)
 	ce.stackIterator.clear()
 	ce.callNativeFunc(ctx, m, f)
-	fnl.After(ctx, m, def, nil, ce.peekValues(len(typ.Results)))
+	fnl.After(ctx, m, def, ce.peekValues(len(typ.Results)))
 	return ctx
 }
 
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index ccab42d6..eee324d7 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -324,12 +324,12 @@ func syscallReaddir(_ context.Context, mod api.Module, name string) (*objectArra
 	}
 	defer f.Close() //nolint
 
-	if names, errno := platform.Readdirnames(f.File(), -1); errno != 0 {
+	if dirents, errno := f.Readdir(-1); errno != 0 {
 		return nil, errno
 	} else {
-		entries := make([]interface{}, 0, len(names))
-		for _, e := range names {
-			entries = append(entries, e)
+		entries := make([]interface{}, 0, len(dirents))
+		for _, e := range dirents {
+			entries = append(entries, e.Name)
 		}
 		return &objectArray{entries}, nil
 	}
diff --git a/internal/platform/dir.go b/internal/platform/dir.go
index b58386df..9f2e183b 100644
--- a/internal/platform/dir.go
+++ b/internal/platform/dir.go
@@ -5,45 +5,9 @@ import (
 	"io"
 	"io/fs"
 	"syscall"
+	"time"
 )
 
-// Readdirnames reads the names of the directory associated with file and
-// returns a slice of up to n strings in an arbitrary order. This is a stateful
-// function, so subsequent calls return any next values.
-//
-// If n > 0, Readdirnames returns at most n entries or an error.
-// If n <= 0, Readdirnames returns all remaining entries or an error.
-//
-// # Errors
-//
-// A zero syscall.Errno is success.
-//
-// For portability reasons, no error is returned on io.EOF, when the file is
-// closed or removed while open.
-// See https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs.zig#L635-L637
-func Readdirnames(f fs.File, n int) (names []string, errno syscall.Errno) {
-	switch f := f.(type) {
-	case readdirnamesFile:
-		var err error
-		names, err = f.Readdirnames(n)
-		if errno = adjustReaddirErr(err); errno != 0 {
-			return
-		}
-	case fs.ReadDirFile:
-		entries, err := f.ReadDir(n)
-		if errno = adjustReaddirErr(err); errno != 0 {
-			return
-		}
-		names = make([]string, 0, len(entries))
-		for _, e := range entries {
-			names = append(names, e.Name())
-		}
-	default:
-		errno = syscall.ENOTDIR
-	}
-	return
-}
-
 // Dirent is an entry read from a directory.
 //
 // This is a portable variant of syscall.Dirent containing fields needed for
@@ -72,21 +36,7 @@ func (d *Dirent) IsDir() bool {
 	return d.Type == fs.ModeDir
 }
 
-// Readdir reads the contents of the directory associated with file and returns
-// a slice of up to n Dirent values in an arbitrary order. This is a stateful
-// function, so subsequent calls return any next values.
-//
-// If n > 0, Readdir returns at most n entries or an error.
-// If n <= 0, Readdir returns all remaining entries or an error.
-//
-// # Errors
-//
-// A zero syscall.Errno is success.
-//
-// For portability reasons, no error is returned on io.EOF, when the file is
-// closed or removed while open.
-// See https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs.zig#L635-L637
-func Readdir(f fs.File, n int) (dirents []*Dirent, errno syscall.Errno) {
+func readdir(f fs.File, n int) (dirents []Dirent, errno syscall.Errno) {
 	// ^^ case format is to match POSIX and similar to os.File.Readdir
 
 	switch f := f.(type) {
@@ -95,7 +45,7 @@ func Readdir(f fs.File, n int) (dirents []*Dirent, errno syscall.Errno) {
 		if errno = adjustReaddirErr(e); errno != 0 {
 			return
 		}
-		dirents = make([]*Dirent, 0, len(fis))
+		dirents = make([]Dirent, 0, len(fis))
 
 		// linux/darwin won't have to fan out to lstat, but windows will.
 		var ino uint64
@@ -103,17 +53,17 @@ func Readdir(f fs.File, n int) (dirents []*Dirent, errno syscall.Errno) {
 			if ino, errno = inoFromFileInfo(f, t); errno != 0 {
 				return
 			}
-			dirents = append(dirents, &Dirent{Name: t.Name(), Ino: ino, Type: t.Mode().Type()})
+			dirents = append(dirents, Dirent{Name: t.Name(), Ino: ino, Type: t.Mode().Type()})
 		}
 	case fs.ReadDirFile:
 		entries, e := f.ReadDir(n)
 		if errno = adjustReaddirErr(e); errno != 0 {
 			return
 		}
-		dirents = make([]*Dirent, 0, len(entries))
+		dirents = make([]Dirent, 0, len(entries))
 		for _, e := range entries {
 			// By default, we don't attempt to read inode data
-			dirents = append(dirents, &Dirent{Name: e.Name(), Type: e.Type()})
+			dirents = append(dirents, Dirent{Name: e.Name(), Type: e.Type()})
 		}
 	default:
 		errno = syscall.ENOTDIR
@@ -136,3 +86,61 @@ func adjustReaddirErr(err error) syscall.Errno {
 	}
 	return 0
 }
+
+// DirFile is embeddable to reduce the amount of functions to implement a file.
+type DirFile struct{}
+
+// AccessMode implements File.AccessMode
+func (DirFile) AccessMode() int {
+	return syscall.O_RDONLY
+}
+
+// IsNonblock implements File.IsNonblock
+func (DirFile) IsNonblock() bool {
+	return false
+}
+
+// SetNonblock implements File.SetNonblock
+func (DirFile) SetNonblock(bool) syscall.Errno {
+	return syscall.EISDIR
+}
+
+// IsDir implements File.IsDir
+func (DirFile) IsDir() (bool, syscall.Errno) {
+	return true, 0
+}
+
+// Read implements File.Read
+func (DirFile) Read([]byte) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// Pread implements File.Pread
+func (DirFile) Pread([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// Seek implements File.Seek
+func (DirFile) Seek(int64, int) (int64, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// PollRead implements File.PollRead
+func (DirFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return false, syscall.ENOSYS
+}
+
+// Write implements File.Write
+func (DirFile) Write([]byte) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// Pwrite implements File.Pwrite
+func (DirFile) Pwrite([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.EISDIR
+}
+
+// Truncate implements File.Truncate
+func (DirFile) Truncate(int64) syscall.Errno {
+	return syscall.EISDIR
+}
diff --git a/internal/platform/file.go b/internal/platform/file.go
index 2dee892b..6488f58c 100644
--- a/internal/platform/file.go
+++ b/internal/platform/file.go
@@ -4,6 +4,7 @@ import (
 	"io"
 	"io/fs"
 	"syscall"
+	"time"
 )
 
 // File is a writeable fs.File bridge backed by syscall functions needed for ABI
@@ -41,6 +42,30 @@ type File interface {
 	//   - syscall.O_RDWR: read-write, e.g. os.CreateTemp
 	AccessMode() int
 
+	// IsNonblock returns true if SetNonblock was successfully enabled on this
+	// file.
+	//
+	// # Notes
+	//
+	//   - This may not match the underlying state of the file descriptor if it
+	//     was opened (OpenFile) in non-blocking mode.
+	IsNonblock() bool
+	// ^-- TODO: We should be able to cache the open flag and remove this note.
+
+	// SetNonblock toggles the non-blocking mode of this 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.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.SetNonblock and `fcntl` with `O_NONBLOCK` in
+	//     POSIX. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/fcntl.html
+	SetNonblock(enable bool) syscall.Errno
+
 	// Stat is similar to syscall.Fstat.
 	//
 	// # Errors
@@ -71,7 +96,7 @@ type File interface {
 	//   - Some implementations implement this with a cached call to Stat.
 	IsDir() (bool, syscall.Errno)
 
-	// Read attempts to read all bytes in the file into `p`, and returns the
+	// Read attempts to read all bytes in the file into `buf`, and returns the
 	// count read even on error.
 	//
 	// # Errors
@@ -87,7 +112,7 @@ type File interface {
 	//     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)
+	Read(buf []byte) (n int, errno syscall.Errno)
 
 	// Pread attempts to read all bytes in the file into `p`, starting at the
 	// offset `off`, and returns the count read even on error.
@@ -135,6 +160,53 @@ type File interface {
 	//     of io.Seeker. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/fseek.html
 	Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno)
 
+	// PollRead returns if the file has data ready to be read or an error.
+	//
+	// # Parameters
+	//
+	// The `timeout` parameter when nil blocks up to forever.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//
+	// # Notes
+	//
+	//   - This is like `poll` in POSIX, for a single file.
+	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/poll.html
+	//   - No-op files, such as those which read from /dev/null, should return
+	//     immediately true to avoid hangs (because data will never become
+	//     available).
+	PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno)
+
+	// Readdir reads the contents of the directory associated with file and
+	// returns a slice of up to n Dirent values in an arbitrary order. This is
+	// a stateful function, so subsequent calls return any next values.
+	//
+	// If n > 0, Readdir returns at most n entries or an error.
+	// If n <= 0, Readdir returns all remaining entries or an error.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.ENOTDIR: the file was not a directory
+	//
+	// # Notes
+	//
+	//   - This is like `Readdir` on os.File, but unlike `readdir` in POSIX.
+	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/readdir.html
+	//   - For portability reasons, no error is returned at the end of the
+	//     directory, when the file is closed or removed while open.
+	//     See https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs.zig#L635-L637
+	Readdir(n int) (dirents []Dirent, errno syscall.Errno)
+	// ^-- TODO: consider being more like POSIX, for example, returning a
+	// closeable Dirent object that can iterate on demand. This would
+	// centralize sizing logic needed by wasi, particularly extra dirents
+	// stored in the sys.FileEntry type. It could possibly reduce the need to
+	// reopen the whole file.
+
 	// Write attempts to write all bytes in `p` to the file, and returns the
 	// count written even on error.
 	//
@@ -283,15 +355,22 @@ type File interface {
 	//   - This is like syscall.Close and `close` in POSIX. See
 	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/close.html
 	Close() syscall.Errno
-
-	// File is temporary until we port other methods.
-	File() fs.File
 }
 
 // UnimplementedFile is a File that returns syscall.ENOSYS for all functions,
 // This should be embedded to have forward compatible implementations.
 type UnimplementedFile struct{}
 
+// IsNonblock implements File.IsNonblock
+func (UnimplementedFile) IsNonblock() bool {
+	return false
+}
+
+// SetNonblock implements File.SetNonblock
+func (UnimplementedFile) SetNonblock(bool) syscall.Errno {
+	return syscall.ENOSYS
+}
+
 // Stat implements File.Stat
 func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
 	return Stat_t{}, syscall.ENOSYS
@@ -317,6 +396,16 @@ func (UnimplementedFile) Seek(int64, int) (int64, syscall.Errno) {
 	return 0, syscall.ENOSYS
 }
 
+// Readdir implements File.Readdir
+func (UnimplementedFile) Readdir(int) (dirents []Dirent, errno syscall.Errno) {
+	return nil, syscall.ENOSYS
+}
+
+// PollRead implements File.PollRead
+func (UnimplementedFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return false, syscall.ENOSYS
+}
+
 // Write implements File.Write
 func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
 	return 0, syscall.ENOSYS
@@ -357,6 +446,28 @@ func (UnimplementedFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.ENOSYS
 }
 
+func NewStdioFile(stdin bool, f fs.File) (File, error) {
+	// Return constant stat, which has fake times, but keep the underlying
+	// file mode. Fake times are needed to pass wasi-testsuite.
+	// https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+	var mode fs.FileMode
+	if st, err := f.Stat(); err != nil {
+		return nil, err
+	} else {
+		mode = st.Mode()
+	}
+	var accessMode int
+	if stdin {
+		accessMode = syscall.O_RDONLY
+	} else {
+		accessMode = syscall.O_WRONLY
+	}
+	return &stdioFile{
+		fsFile: fsFile{accessMode: accessMode, file: f},
+		st:     Stat_t{Mode: mode, Nlink: 1},
+	}, nil
+}
+
 func NewFsFile(openPath string, openFlag int, f fs.File) File {
 	return &fsFile{
 		path:       openPath,
@@ -365,11 +476,33 @@ func NewFsFile(openPath string, openFlag int, f fs.File) File {
 	}
 }
 
+type stdioFile struct {
+	fsFile
+	st Stat_t
+}
+
+// IsDir implements File.IsDir
+func (f *stdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
+
+// Stat implements File.Stat
+func (f *stdioFile) Stat() (Stat_t, syscall.Errno) {
+	return f.st, 0
+}
+
+// Close implements File.Close
+func (f *stdioFile) Close() syscall.Errno {
+	return 0
+}
+
 type fsFile struct {
 	path       string
 	accessMode int
 	file       fs.File
 
+	nonblock bool
+
 	// cachedStat includes fields that won't change while a file is open.
 	cachedSt *cachedStat
 }
@@ -400,6 +533,23 @@ func (f *fsFile) AccessMode() int {
 	return f.accessMode
 }
 
+// IsNonblock implements File.IsNonblock
+func (f *fsFile) IsNonblock() bool {
+	return f.nonblock
+}
+
+// SetNonblock implements File.SetNonblock
+func (f *fsFile) SetNonblock(enable bool) syscall.Errno {
+	if fd, ok := f.file.(fdFile); ok {
+		if err := setNonblock(fd.Fd(), enable); err != nil {
+			return UnwrapOSError(err)
+		}
+		f.nonblock = enable
+		return 0
+	}
+	return syscall.ENOSYS
+}
+
 // IsDir implements File.IsDir
 func (f *fsFile) IsDir() (bool, syscall.Errno) {
 	if ft, errno := f.cachedStat(); errno != 0 {
@@ -434,7 +584,7 @@ func (f *fsFile) Read(p []byte) (n int, errno syscall.Errno) {
 		return 0, syscall.EBADF
 	}
 
-	if w, ok := f.File().(io.Reader); ok {
+	if w, ok := f.file.(io.Reader); ok {
 		n, err := w.Read(p)
 		return n, UnwrapOSError(err)
 	}
@@ -454,13 +604,13 @@ func (f *fsFile) Pread(p []byte, off int64) (n int, errno syscall.Errno) {
 	}
 
 	// Simple case, handle with io.ReaderAt.
-	if w, ok := f.File().(io.ReaderAt); ok {
+	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 {
+	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 {
@@ -499,6 +649,29 @@ func (f *fsFile) Seek(offset int64, whence int) (int64, syscall.Errno) {
 	return 0, syscall.ENOSYS
 }
 
+// PollRead implements File.PollRead
+func (f *fsFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
+	if f, ok := f.file.(fdFile); ok {
+		fdSet := FdSet{}
+		fd := int(f.Fd())
+		fdSet.Set(fd)
+		nfds := fd + 1 // See https://man7.org/linux/man-pages/man2/select.2.html#:~:text=condition%20has%20occurred.-,nfds,-This%20argument%20should
+		count, err := _select(nfds, &fdSet, nil, nil, timeout)
+		return count > 0, UnwrapOSError(err)
+	}
+	return false, syscall.ENOSYS
+}
+
+// Readdir implements File.Readdir
+func (f *fsFile) Readdir(n int) ([]Dirent, syscall.Errno) {
+	if isDir, errno := f.IsDir(); errno != 0 {
+		return nil, errno
+	} else if !isDir {
+		return nil, syscall.ENOTDIR
+	}
+	return readdir(f.file, n)
+}
+
 // Write implements File.Write
 func (f *fsFile) Write(p []byte) (n int, errno syscall.Errno) {
 	if errno = f.isDirErrno(); errno != 0 {
@@ -510,7 +683,7 @@ func (f *fsFile) Write(p []byte) (n int, errno syscall.Errno) {
 	if len(p) == 0 {
 		return 0, 0 // less overhead on zero-length writes.
 	}
-	if w, ok := f.File().(io.Writer); ok {
+	if w, ok := f.file.(io.Writer); ok {
 		n, err := w.Write(p)
 		return n, UnwrapOSError(err)
 	}
@@ -529,7 +702,7 @@ func (f *fsFile) Pwrite(p []byte, off int64) (n int, errno syscall.Errno) {
 		return 0, 0 // less overhead on zero-length writes.
 	}
 
-	if w, ok := f.File().(io.WriterAt); ok {
+	if w, ok := f.file.(io.WriterAt); ok {
 		n, err := w.WriteAt(p, off)
 		return n, UnwrapOSError(err)
 	}
@@ -601,31 +774,6 @@ func (f *fsFile) Close() syscall.Errno {
 	return UnwrapOSError(f.file.Close())
 }
 
-// File implements File.File
-func (f *fsFile) File() fs.File {
-	return f.file
-}
-
-// ReadFile declares all read interfaces defined on os.File used by wazero.
-type ReadFile interface {
-	fdFile // for the number of links.
-	readdirnamesFile
-	readdirFile
-	fs.ReadDirFile
-	io.ReaderAt // for pread
-	io.Seeker   // fallback for ReaderAt for embed:fs
-}
-
-// WriteFile declares all interfaces defined on os.File used by wazero.
-type WriteFile interface {
-	ReadFile
-	io.Writer
-	io.WriterAt // for pwrite
-	chmodFile
-	syncFile
-	truncateFile
-}
-
 // The following interfaces are used until we finalize our own FD-scoped file.
 type (
 	// PathFile is implemented on files that retain the path to their pre-open.
@@ -634,10 +782,6 @@ type (
 	}
 	// fdFile is implemented by os.File in file_unix.go and file_windows.go
 	fdFile interface{ Fd() (fd uintptr) }
-	// readdirnamesFile is implemented by os.File in dir.go
-	readdirnamesFile interface {
-		Readdirnames(n int) (names []string, err error)
-	}
 	// readdirFile is implemented by os.File in dir.go
 	readdirFile interface {
 		Readdir(n int) ([]fs.FileInfo, error)
diff --git a/internal/platform/nonblock_unix.go b/internal/platform/nonblock_unix.go
index d022f025..0af1471b 100644
--- a/internal/platform/nonblock_unix.go
+++ b/internal/platform/nonblock_unix.go
@@ -4,6 +4,6 @@ package platform
 
 import "syscall"
 
-func SetNonblock(fd uintptr, enable bool) error {
+func setNonblock(fd uintptr, enable bool) error {
 	return syscall.SetNonblock(int(fd), enable)
 }
diff --git a/internal/platform/nonblock_windows.go b/internal/platform/nonblock_windows.go
index 8d0b669b..0bac6918 100644
--- a/internal/platform/nonblock_windows.go
+++ b/internal/platform/nonblock_windows.go
@@ -4,6 +4,6 @@ package platform
 
 import "syscall"
 
-func SetNonblock(fd uintptr, enable bool) error {
+func setNonblock(fd uintptr, enable bool) error {
 	return syscall.SetNonblock(syscall.Handle(fd), enable)
 }
diff --git a/internal/platform/open_file_windows.go b/internal/platform/open_file_windows.go
index 2589647a..16d60979 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/platform/open_file_windows.go
@@ -32,7 +32,7 @@ func OpenFile(path string, flag int, perm fs.FileMode) (fs.File, syscall.Errno)
 	if f, errno := openFile(path, flag, perm); errno != 0 {
 		return nil, errno
 	} else { // TODO: revisit windowsWrappedFile once fsFile is complete
-		f := &windowsWrappedFile{WriteFile: f, path: path, flag: flag, perm: perm}
+		f := &windowsWrappedFile{osFile: f, path: path, flag: flag, perm: perm}
 		return f, 0
 	}
 }
diff --git a/internal/platform/select.go b/internal/platform/select.go
index 752f4eaa..7325b4b0 100644
--- a/internal/platform/select.go
+++ b/internal/platform/select.go
@@ -2,7 +2,8 @@ package platform
 
 import "time"
 
-// Select exposes the select(2) syscall.
+// _select exposes the select(2) syscall. This is named as such to avoid
+// colliding with they keyword select while not exporting the function.
 //
 // # Notes on Parameters
 //
@@ -26,6 +27,6 @@ import "time"
 //	e.g. the read-end of a pipe or an eventfd on Linux.
 //	When the context is canceled, we may unblock a Select call by writing to the fd, causing it to return immediately.
 //	This however requires to do a bit of housekeeping to hide the "special" FD from the end-user.
-func Select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func _select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
 	return syscall_select(n, r, w, e, timeout)
 }
diff --git a/internal/platform/wrap_windows.go b/internal/platform/wrap_windows.go
index 82b3a054..8a49d46d 100644
--- a/internal/platform/wrap_windows.go
+++ b/internal/platform/wrap_windows.go
@@ -2,10 +2,27 @@ package platform
 
 import (
 	"errors"
+	"io"
 	"io/fs"
 	"syscall"
 )
 
+// osFile contains the functions on os.File needed to support fsFile. Since
+// we are embedding, we need to declare everything used.
+type osFile interface {
+	fdFile // for the number of links.
+	readdirFile
+	fs.ReadDirFile
+	io.ReaderAt // for pread
+	io.Seeker   // fallback for ReaderAt for embed:fs
+
+	io.Writer
+	io.WriterAt // for pwrite
+	chmodFile
+	syncFile
+	truncateFile
+}
+
 // windowsWrappedFile deals with errno portability issues in Windows. This code
 // is likely to change as we complete WASI and GOOS=js.
 //
@@ -16,7 +33,7 @@ import (
 //
 // Note: Don't test for this type as it is wrapped when using sysfs.NewReadFS.
 type windowsWrappedFile struct {
-	WriteFile
+	osFile
 	path           string
 	flag           int
 	perm           fs.FileMode
@@ -34,7 +51,7 @@ func (w *windowsWrappedFile) Path() string {
 	return w.path
 }
 
-// Readdir implements readdirFile.
+// Readdir implements the same method as documented on os.File.
 func (w *windowsWrappedFile) Readdir(n int) (fis []fs.FileInfo, err error) {
 	if err = w.requireFile("Readdir", false, true); err != nil {
 		return
@@ -42,7 +59,7 @@ func (w *windowsWrappedFile) Readdir(n int) (fis []fs.FileInfo, err error) {
 		return
 	}
 
-	return w.WriteFile.Readdir(n)
+	return w.osFile.Readdir(n)
 }
 
 // ReadDir implements fs.ReadDirFile.
@@ -53,7 +70,7 @@ func (w *windowsWrappedFile) ReadDir(n int) (dirents []fs.DirEntry, err error) {
 		return
 	}
 
-	return w.WriteFile.ReadDir(n)
+	return w.osFile.ReadDir(n)
 }
 
 // Write implements io.Writer
@@ -62,7 +79,7 @@ func (w *windowsWrappedFile) Write(p []byte) (n int, err error) {
 		return
 	}
 
-	n, err = w.WriteFile.Write(p)
+	n, err = w.osFile.Write(p)
 	// ERROR_ACCESS_DENIED is often returned instead of EBADF
 	// when a file is used after close.
 	if errors.Is(err, ERROR_ACCESS_DENIED) {
@@ -77,7 +94,7 @@ func (w *windowsWrappedFile) Close() (err error) {
 		return
 	}
 
-	if err = w.WriteFile.Close(); err != nil {
+	if err = w.osFile.Close(); err != nil {
 		w.closed = true
 	}
 	return
@@ -92,14 +109,14 @@ func (w *windowsWrappedFile) maybeInitDir() error {
 	// not visible on ReadDir on that already-opened file handle.
 	//
 	// To provide consistent behavior with other platforms, we re-open it.
-	if err := w.WriteFile.Close(); err != nil {
+	if err := w.osFile.Close(); err != nil {
 		return err
 	}
 	newW, errno := openFile(w.path, w.flag, w.perm)
 	if errno != 0 {
 		return &fs.PathError{Op: "OpenFile", Path: w.path, Err: errno}
 	}
-	w.WriteFile = newW
+	w.osFile = newW
 	w.dirInitialized = true
 	return nil
 }
@@ -127,7 +144,7 @@ func (w *windowsWrappedFile) requireFile(op string, readOnly, isDir bool) error
 // getFileType caches the file type as this cannot change on an open file.
 func (w *windowsWrappedFile) getFileType() (fs.FileMode, error) {
 	if w.fileType == nil {
-		st, errno := statFile(w.WriteFile)
+		st, errno := statFile(w.osFile)
 		if errno != 0 {
 			return 0, nil
 		}
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 69c69e3d..4f7c0c5c 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -1,7 +1,6 @@
 package sys
 
 import (
-	"fmt"
 	"io"
 	"io/fs"
 	"os"
@@ -31,175 +30,100 @@ const (
 	FdPreopen
 )
 
-const modeDevice = uint32(fs.ModeDevice | 0o640)
+const modeDevice = fs.ModeDevice | 0o640
 
-type StdioFileWriter struct {
-	w io.Writer
-	s fs.FileInfo
-	// Known state of the non-blocking mode. Defaults to false which may not
-	// match the underlying state of the file descriptor if it was opened in
-	// non-blocking mode whe instantiating the module.
-	nonblock bool
+// StdinFile is a fs.ModeDevice file for use implementing FdStdin.
+// This is safer than reading from os.DevNull as it can never overrun
+// operating system file descriptors.
+type StdinFile struct {
+	noopStdinFile
+	io.Reader
 }
 
-// Stat implements fs.File
-func (w *StdioFileWriter) Stat() (fs.FileInfo, error) { return w.s, nil }
-
-// Read implements fs.File
-func (w *StdioFileWriter) Read([]byte) (n int, err error) {
-	return // emulate os.Stdout which returns zero
+// Read implements the same method as documented on platform.File
+func (f *StdinFile) Read(buf []byte) (int, syscall.Errno) {
+	n, err := f.Reader.Read(buf)
+	return n, platform.UnwrapOSError(err)
 }
 
-// Write implements io.Writer
-func (w *StdioFileWriter) Write(p []byte) (n int, err error) {
-	return w.w.Write(p)
-}
+type writerFile struct {
+	noopStdoutFile
 
-// Close implements fs.File
-func (w *StdioFileWriter) Close() error {
-	// Don't actually close the underlying file, as we didn't open it!
-	return nil
+	w io.Writer
 }
 
-// IsNonblock returns the current known state of non-blocking mode on the
-// underlying file.
-func (w *StdioFileWriter) IsNonblock() bool {
-	return w.nonblock
+// Write implements the same method as documented on platform.File
+func (f *writerFile) Write(buf []byte) (int, syscall.Errno) {
+	n, err := f.w.Write(buf)
+	return n, platform.UnwrapOSError(err)
 }
 
-// SetNonblock sets the file to non-blocking mode if the reader has access to
-// the underlying file descriptor.
-func (w *StdioFileWriter) SetNonblock(enable bool) error {
-	if f, ok := w.w.(*os.File); ok {
-		if err := platform.SetNonblock(f.Fd(), enable); err != nil {
-			return err
-		}
-		w.nonblock = enable
-		return nil
-	}
-	return syscall.ENOSYS
+// noopStdinFile is a fs.ModeDevice file for use implementing FdStdin. This is
+// safer than reading from os.DevNull as it can never overrun operating system
+// file descriptors.
+type noopStdinFile struct {
+	noopStdioFile
 }
 
-// StdioFilePoller is a strategy for polling a StdioFileReader for a given duration.
-// It returns true if the reader has data ready to be read, false and/or an error otherwise.
-type StdioFilePoller interface {
-	Poll(duration time.Duration) (bool, error)
+// AccessMode implements the same method as documented on platform.File
+func (noopStdinFile) AccessMode() int {
+	return syscall.O_RDONLY
 }
 
-// PollerDefaultStdin is a poller that checks standard input.
-var PollerDefaultStdin = &pollerDefaultStdin{}
-
-type pollerDefaultStdin struct{}
-
-// Poll implements StdioFilePoller for pollerDefaultStdin.
-func (*pollerDefaultStdin) Poll(duration time.Duration) (bool, error) {
-	fdSet := platform.FdSet{}
-	fdSet.Set(int(FdStdin))
-	count, err := platform.Select(int(FdStdin+1), &fdSet, nil, nil, &duration)
-	return count > 0, err
+// Read implements the same method as documented on platform.File
+func (noopStdinFile) Read([]byte) (int, syscall.Errno) {
+	return 0, 0 // Always EOF
 }
 
-// PollerAlwaysReady is a poller that ignores the given timeout, and it returns true and no error.
-var PollerAlwaysReady = &pollerAlwaysReady{}
-
-type pollerAlwaysReady struct{}
-
-// Poll implements StdioFilePoller for pollerAlwaysReady.
-func (*pollerAlwaysReady) Poll(time.Duration) (bool, error) { return true, nil }
-
-// PollerNeverReady is a poller that waits for the given duration, and it always returns false and no error.
-var PollerNeverReady = &pollerNeverReady{}
-
-type pollerNeverReady struct{}
-
-// Poll implements StdioFilePoller for pollerNeverReady.
-func (*pollerNeverReady) Poll(d time.Duration) (bool, error) { time.Sleep(d); return false, nil }
-
-// StdioFileReader implements io.Reader for stdio files.
-type StdioFileReader struct {
-	r    io.Reader
-	s    fs.FileInfo
-	poll StdioFilePoller
-	// See StdioFileWriter.
-	nonblock bool
+// PollRead implements the same method as documented on platform.File
+func (noopStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return true, 0 // always ready to read nothing
 }
 
-// NewStdioFileReader is a constructor for StdioFileReader.
-func NewStdioFileReader(reader io.Reader, fileInfo fs.FileInfo, poll StdioFilePoller) *StdioFileReader {
-	return &StdioFileReader{
-		r:    reader,
-		s:    fileInfo,
-		poll: poll,
-	}
+// noopStdoutFile is a fs.ModeDevice file for use implementing FdStdout and
+// FdStderr.
+type noopStdoutFile struct {
+	noopStdioFile
 }
 
-// Poll invokes the StdioFilePoller that was given at the NewStdioFileReader constructor.
-func (r *StdioFileReader) Poll(duration time.Duration) (bool, error) {
-	return r.poll.Poll(duration)
+// AccessMode implements the same method as documented on platform.File
+func (noopStdoutFile) AccessMode() int {
+	return syscall.O_WRONLY
 }
 
-// Stat implements fs.File
-func (r *StdioFileReader) Stat() (fs.FileInfo, error) { return r.s, nil }
-
-// Read implements fs.File
-func (r *StdioFileReader) Read(p []byte) (n int, err error) {
-	return r.r.Read(p)
+// Write implements the same method as documented on platform.File
+func (noopStdoutFile) Write(p []byte) (int, syscall.Errno) {
+	return len(p), 0 // same as io.Discard
 }
 
-// Close implements fs.File
-func (r *StdioFileReader) Close() error {
-	// Don't actually close the underlying file, as we didn't open it!
-	return nil
+type noopStdioFile struct {
+	platform.UnimplementedFile
 }
 
-// IsNonblock returns the current known state of non-blocking mode on the
-// underlying file.
-func (r *StdioFileReader) IsNonblock() bool {
-	return r.nonblock
+// Path implements the same method as documented on platform.File
+func (noopStdioFile) Path() string {
+	return ""
 }
 
-// SetNonblock sets the file to non-blocking mode if the reader has access to
-// the underlying file descriptor.
-func (r *StdioFileReader) SetNonblock(enable bool) error {
-	if f, ok := r.r.(*os.File); ok {
-		if err := platform.SetNonblock(f.Fd(), enable); err != nil {
-			return err
-		}
-		r.nonblock = enable
-		return nil
-	}
-	return syscall.ENOSYS
+// Stat implements the same method as documented on platform.File
+func (noopStdioFile) Stat() (platform.Stat_t, syscall.Errno) {
+	return platform.Stat_t{Mode: modeDevice, Nlink: 1}, 0
 }
 
-var (
-	noopStdinStat  = stdioFileInfo{0, modeDevice}
-	noopStdoutStat = stdioFileInfo{1, modeDevice}
-	noopStderrStat = stdioFileInfo{2, modeDevice}
-)
-
-// stdioFileInfo implements fs.FileInfo where index zero is the FD and one is the mode.
-type stdioFileInfo [2]uint32
-
-func (s stdioFileInfo) Name() string {
-	switch s[0] {
-	case 0:
-		return "stdin"
-	case 1:
-		return "stdout"
-	case 2:
-		return "stderr"
-	default:
-		panic(fmt.Errorf("BUG: incorrect FD %d", s[0]))
-	}
+// IsDir implements the same method as documented on platform.File
+func (noopStdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
 }
 
-func (stdioFileInfo) Size() int64         { return 0 }
-func (s stdioFileInfo) Mode() fs.FileMode { return fs.FileMode(s[1]) }
-func (stdioFileInfo) ModTime() time.Time  { return time.Unix(0, 0) }
-func (stdioFileInfo) IsDir() bool         { return false }
-func (stdioFileInfo) Sys() interface{}    { return nil }
+// Close implements the same method as documented on platform.File
+func (noopStdioFile) Close() (errno syscall.Errno) { return }
+
+// compile-time check to ensure lazyDir implements platform.File.
+var _ platform.File = (*lazyDir)(nil)
 
 type lazyDir struct {
+	platform.DirFile
+
 	fs sysfs.FS
 	f  platform.File
 }
@@ -209,16 +133,6 @@ func (r *lazyDir) Path() string {
 	return "."
 }
 
-// AccessMode implements the same method as documented on platform.File
-func (r *lazyDir) AccessMode() int {
-	return syscall.O_RDONLY
-}
-
-// IsDir implements the same method as documented on platform.File
-func (r *lazyDir) IsDir() (bool, syscall.Errno) {
-	return true, 0
-}
-
 // Stat implements the same method as documented on platform.File
 func (r *lazyDir) Stat() (platform.Stat_t, syscall.Errno) {
 	if f, ok := r.file(); !ok {
@@ -228,34 +142,13 @@ func (r *lazyDir) Stat() (platform.Stat_t, syscall.Errno) {
 	}
 }
 
-// Read implements the same method as documented on platform.File
-func (r *lazyDir) Read([]byte) (int, syscall.Errno) {
-	return 0, syscall.EISDIR
-}
-
-// Pread implements the same method as documented on platform.File
-func (r *lazyDir) Pread([]byte, int64) (int, syscall.Errno) {
-	return 0, syscall.EISDIR
-}
-
-// Seek implements File.Seek
-func (r *lazyDir) Seek(int64, int) (int64, 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
+// Readdir implements the same method as documented on platform.File
+func (r *lazyDir) Readdir(n int) (dirents []platform.Dirent, errno syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return nil, syscall.EBADF
+	} else {
+		return f.Readdir(n)
+	}
 }
 
 // Sync implements the same method as documented on platform.File
@@ -303,15 +196,6 @@ func (r *lazyDir) Utimens(times *[2]syscall.Timespec) syscall.Errno {
 	}
 }
 
-// File implements the same method as documented on platform.File
-func (r *lazyDir) File() fs.File {
-	if f, ok := r.file(); !ok {
-		panic("path doesn't exist")
-	} else {
-		return f.File()
-	}
-}
-
 // file returns the underlying file or false if it doesn't exist.
 func (r *lazyDir) file() (platform.File, bool) {
 	if f := r.f; r.f != nil {
@@ -412,7 +296,7 @@ type ReadDir struct {
 	// In wasi preview1, dot and dot-dot entries are required to exist, but the
 	// reverse is true for preview2. More importantly, preview2 holds separate
 	// stateful dir-entry-streams per file.
-	Dirents []*platform.Dirent
+	Dirents []platform.Dirent
 }
 
 type FSContext struct {
@@ -436,17 +320,17 @@ type FileTable = descriptor.Table[int32, *FileEntry]
 // the file descriptor table as FdPreopen.
 func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS sysfs.FS) (err error) {
 	c.fsc.rootFS = rootFS
-	inReader, err := stdinReader(stdin)
+	inFile, err := stdinFile(stdin)
 	if err != nil {
 		return err
 	}
-	c.fsc.openedFiles.Insert(inReader)
-	outWriter, err := stdioWriter(stdout, noopStdoutStat)
+	c.fsc.openedFiles.Insert(inFile)
+	outWriter, err := stdioWriterFile("stdout", stdout)
 	if err != nil {
 		return err
 	}
 	c.fsc.openedFiles.Insert(outWriter)
-	errWriter, err := stdioWriter(stderr, noopStderrStat)
+	errWriter, err := stdioWriterFile("stderr", stderr)
 	if err != nil {
 		return err
 	}
@@ -478,48 +362,32 @@ func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS
 	return nil
 }
 
-func stdinReader(r io.Reader) (*FileEntry, error) {
+func stdinFile(r io.Reader) (*FileEntry, error) {
 	if r == nil {
-		r = eofReader{}
-	}
-	var freader *StdioFileReader
-	if stdioFileReader, ok := r.(*StdioFileReader); ok {
-		freader = stdioFileReader
-	} else {
-		s, err := stdioStat(r, noopStdinStat)
-		if err != nil {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &noopStdinFile{}}, nil
+	} else if f, ok := r.(*os.File); ok {
+		if f, err := platform.NewStdioFile(true, f); err != nil {
 			return nil, err
+		} else {
+			return &FileEntry{Name: "stdin", IsPreopen: true, File: f}, nil
 		}
-		freader = NewStdioFileReader(r, s, PollerDefaultStdin)
+	} else {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &StdinFile{Reader: r}}, nil
 	}
-	return &FileEntry{
-		Name: noopStdinStat.Name(), File: platform.NewFsFile("", syscall.O_RDONLY, freader),
-	}, nil
 }
 
-func stdioWriter(w io.Writer, defaultStat stdioFileInfo) (*FileEntry, error) {
+func stdioWriterFile(name string, w io.Writer) (*FileEntry, error) {
 	if w == nil {
-		w = io.Discard
-	}
-	s, err := stdioStat(w, defaultStat)
-	if err != nil {
-		return nil, err
-	}
-	return &FileEntry{
-		Name: s.Name(), File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: w, s: s}),
-	}, nil
-}
-
-func stdioStat(f interface{}, defaultStat stdioFileInfo) (fs.FileInfo, error) {
-	if f, ok := f.(*os.File); ok {
-		if st, err := f.Stat(); err == nil {
-			mode := uint32(st.Mode() & fs.ModeType)
-			return stdioFileInfo{defaultStat[0], mode}, nil
-		} else {
+		return &FileEntry{Name: name, IsPreopen: true, File: &noopStdoutFile{}}, nil
+	} else if f, ok := w.(*os.File); ok {
+		if f, err := platform.NewStdioFile(false, f); err != nil {
 			return nil, err
+		} else {
+			return &FileEntry{Name: name, IsPreopen: true, File: f}, nil
 		}
+	} else {
+		return &FileEntry{Name: name, IsPreopen: true, File: &writerFile{w: w}}, nil
 	}
-	return defaultStat, nil
 }
 
 // RootFS returns the underlying filesystem. Any files that should be added to
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index a0b5dbd5..cdf05802 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -108,15 +108,6 @@ func (c *Context) RandSource() io.Reader {
 	return c.randSource
 }
 
-// eofReader is safer than reading from os.DevNull as it can never overrun operating system file descriptors.
-type eofReader struct{}
-
-// Read implements io.Reader
-// Note: This doesn't use a pointer reference as it has no state and an empty struct doesn't allocate.
-func (eofReader) Read([]byte) (int, error) {
-	return 0, io.EOF
-}
-
 // DefaultContext returns Context with no values set except a possible nil fs.FS
 //
 // This is only used for testing.
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index 102a748f..18bad053 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -3,10 +3,7 @@ package sysfs
 import (
 	"fmt"
 	"io/fs"
-	"os"
 	"path"
-	"runtime"
-	"strings"
 	"syscall"
 
 	"github.com/tetratelabs/wazero/internal/platform"
@@ -39,11 +36,6 @@ func (a *adapter) String() string {
 	return fmt.Sprintf("%v", a.fs)
 }
 
-// Open implements the same method as documented on fs.FS
-func (a *adapter) Open(name string) (fs.File, error) {
-	return a.fs.Open(name)
-}
-
 // OpenFile implements FS.OpenFile
 func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
 	path = cleanPath(path)
@@ -87,26 +79,3 @@ func cleanPath(name string) string {
 	cleaned = path.Clean(cleaned) // e.g. "sub/." -> "sub"
 	return cleaned
 }
-
-// fsOpen implements the Open method as documented on fs.FS
-func fsOpen(f FS, name string) (fs.File, error) {
-	if !fs.ValidPath(name) { // FS.OpenFile has fewer constraints than fs.FS
-		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
-	}
-
-	// This isn't a production-grade fs.FS implementation. The only special
-	// cases we address here are to pass testfs.TestFS.
-
-	if runtime.GOOS == "windows" {
-		switch {
-		case strings.Contains(name, "\\"):
-			return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
-		}
-	}
-
-	if f, err := f.OpenFile(name, os.O_RDONLY, 0); err != 0 {
-		return nil, &fs.PathError{Op: "open", Path: name, Err: err}
-	} else {
-		return f.File(), nil
-	}
-}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 65c5f342..315115b0 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -35,11 +35,6 @@ func (d *dirFS) String() string {
 	return d.dir
 }
 
-// Open implements the same method as documented on fs.FS
-func (d *dirFS) Open(name string) (fs.File, error) {
-	return fsOpen(d, name)
-}
-
 // OpenFile implements FS.OpenFile
 func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
 	f, errno := platform.OpenFile(d.join(path), flag, perm)
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 0d2e0d1a..5a9faa7a 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -1,10 +1,10 @@
 package sysfs
 
 import (
-	"io"
 	"io/fs"
 	"os"
 	"syscall"
+	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
 )
@@ -31,11 +31,6 @@ func (r *readFS) String() string {
 	return r.fs.String()
 }
 
-// Open implements the same method as documented on fs.FS
-func (r *readFS) Open(name string) (fs.File, error) {
-	return fsOpen(r, name)
-}
-
 // OpenFile implements FS.OpenFile
 func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
 	// TODO: Once the real implementation is complete, move the below to
@@ -67,78 +62,123 @@ 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
-}
-
-// maskForReads masks the file with read-only interfaces used by wazero.
-//
-// This technique was adapted from similar code in zipkin-go.
-func maskForReads(f fs.File) fs.File {
-	// Handle the most common types
-	rf, ok := f.(platform.ReadFile)
-	pf, pok := f.(platform.PathFile)
-	switch {
-	case ok && !pok:
-		return struct {
-			platform.ReadFile
-		}{rf}
-	case ok && pok:
-		return struct {
-			platform.ReadFile
-			platform.PathFile
-		}{rf, pf}
-	}
+	return &readFile{f: f}, 0
+}
+
+// compile-time check to ensure readFile implements platform.File.
+var _ platform.File = (*readFile)(nil)
+
+type readFile struct {
+	f platform.File
+}
+
+// Path implements the same method as documented on platform.File.
+func (r *readFile) Path() string {
+	return r.f.Path()
+}
+
+// AccessMode implements the same method as documented on platform.File.
+func (r *readFile) AccessMode() int {
+	return r.f.AccessMode()
+}
+
+// IsNonblock implements the same method as documented on platform.File.
+func (r *readFile) IsNonblock() bool {
+	return r.f.IsNonblock()
+}
+
+// SetNonblock implements the same method as documented on platform.File.
+func (r *readFile) SetNonblock(enabled bool) syscall.Errno {
+	return r.f.SetNonblock(enabled)
+}
+
+// Stat implements the same method as documented on platform.File.
+func (r *readFile) Stat() (platform.Stat_t, syscall.Errno) {
+	return r.f.Stat()
+}
+
+// IsDir implements the same method as documented on platform.File.
+func (r *readFile) IsDir() (bool, syscall.Errno) {
+	return r.f.IsDir()
+}
+
+// Read implements the same method as documented on platform.File.
+func (r *readFile) Read(buf []byte) (int, syscall.Errno) {
+	return r.f.Read(buf)
+}
 
-	// The below are the types wazero casts into.
-	// Note: os.File implements this even for normal files.
-	d, i0 := f.(fs.ReadDirFile)
-	ra, i1 := f.(io.ReaderAt)
-	s, i2 := f.(io.Seeker)
-
-	// Wrap any combination of the types above.
-	switch {
-	case !i0 && !i1 && !i2: // 0, 0, 0
-		return struct{ fs.File }{f}
-	case !i0 && !i1 && i2: // 0, 0, 1
-		return struct {
-			fs.File
-			io.Seeker
-		}{f, s}
-	case !i0 && i1 && !i2: // 0, 1, 0
-		return struct {
-			fs.File
-			io.ReaderAt
-		}{f, ra}
-	case !i0 && i1 && i2: // 0, 1, 1
-		return struct {
-			fs.File
-			io.ReaderAt
-			io.Seeker
-		}{f, ra, s}
-	case i0 && !i1 && !i2: // 1, 0, 0
-		return struct {
-			fs.ReadDirFile
-		}{d}
-	case i0 && !i1 && i2: // 1, 0, 1
-		return struct {
-			fs.ReadDirFile
-			io.Seeker
-		}{d, s}
-	case i0 && i1 && !i2: // 1, 1, 0
-		return struct {
-			fs.ReadDirFile
-			io.ReaderAt
-		}{d, ra}
-	case i0 && i1 && i2: // 1, 1, 1
-		return struct {
-			fs.ReadDirFile
-			io.ReaderAt
-			io.Seeker
-		}{d, ra, s}
-	default:
-		panic("BUG: unhandled pattern")
+// Pread implements the same method as documented on platform.File.
+func (r *readFile) Pread(buf []byte, offset int64) (int, syscall.Errno) {
+	return r.f.Pread(buf, offset)
+}
+
+// Seek implements the same method as documented on platform.File.
+func (r *readFile) Seek(offset int64, whence int) (int64, syscall.Errno) {
+	return r.f.Seek(offset, whence)
+}
+
+// Readdir implements the same method as documented on platform.File.
+func (r *readFile) Readdir(n int) (dirents []platform.Dirent, errno syscall.Errno) {
+	return r.f.Readdir(n)
+}
+
+// Write implements the same method as documented on platform.File.
+func (r *readFile) Write([]byte) (int, syscall.Errno) {
+	return 0, r.writeErr()
+}
+
+// Pwrite implements the same method as documented on platform.File.
+func (r *readFile) Pwrite([]byte, int64) (n int, errno syscall.Errno) {
+	return 0, r.writeErr()
+}
+
+// Truncate implements the same method as documented on platform.File.
+func (r *readFile) Truncate(int64) syscall.Errno {
+	return r.writeErr()
+}
+
+// Sync implements the same method as documented on platform.File.
+func (r *readFile) Sync() syscall.Errno {
+	return syscall.EBADF
+}
+
+// Datasync implements the same method as documented on platform.File.
+func (r *readFile) Datasync() syscall.Errno {
+	return syscall.EBADF
+}
+
+// Chmod implements the same method as documented on platform.File.
+func (r *readFile) Chmod(fs.FileMode) syscall.Errno {
+	return syscall.EBADF
+}
+
+// Chown implements the same method as documented on platform.File.
+func (r *readFile) Chown(int, int) syscall.Errno {
+	return syscall.EBADF
+}
+
+// Utimens implements the same method as documented on platform.File.
+func (r *readFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
+	return syscall.EBADF
+}
+
+func (r *readFile) writeErr() syscall.Errno {
+	if isDir, errno := r.IsDir(); errno != 0 {
+		return errno
+	} else if isDir {
+		return syscall.EISDIR
 	}
+	return syscall.EBADF
+}
+
+// Close implements the same method as documented on platform.File.
+func (r *readFile) Close() syscall.Errno {
+	return r.f.Close()
+}
+
+// PollRead implements File.PollRead
+func (r *readFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
+	return r.f.PollRead(timeout)
 }
 
 // Lstat implements FS.Lstat
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index a3fcc2d5..e39eff8f 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -2,7 +2,6 @@ package sysfs
 
 import (
 	"fmt"
-	"io"
 	"io/fs"
 	"strings"
 	"syscall"
@@ -117,11 +116,6 @@ func (c *CompositeFS) FS() (fs []FS) {
 	return
 }
 
-// Open implements the same method as documented on fs.FS
-func (c *CompositeFS) Open(name string) (fs.File, error) {
-	return fsOpen(c, name)
-}
-
 // OpenFile implements FS.OpenFile
 func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f platform.File, err syscall.Errno) {
 	matchIndex, relativePath := c.chooseFS(path)
@@ -136,8 +130,7 @@ func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f platf
 		switch path {
 		case ".", "/", "":
 			if len(c.rootGuestPaths) > 0 {
-				dir := &openRootDir{c: c, f: f.File().(fs.ReadDirFile)}
-				f = platform.NewFsFile(path, syscall.O_RDONLY, dir)
+				f = &openRootDir{path: path, c: c, f: f}
 			}
 		}
 	}
@@ -147,24 +140,53 @@ func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f platf
 // An openRootDir is a root directory open for reading, which has mounts inside
 // of it.
 type openRootDir struct {
+	platform.DirFile
+
+	path     string
 	c        *CompositeFS
-	f        fs.ReadDirFile // the directory file itself
-	dirents  []fs.DirEntry  // the directory contents
-	direntsI int            // the read offset, an index into the files slice
+	f        platform.File     // the directory file itself
+	dirents  []platform.Dirent // the directory contents
+	direntsI int               // the read offset, an index into the files slice
 }
 
-func (d *openRootDir) Close() error { return d.f.Close() }
+// Path implements the same method as documented on platform.File
+func (d *openRootDir) Path() string {
+	return d.path
+}
 
-func (d *openRootDir) Stat() (fs.FileInfo, error) { return d.f.Stat() }
+// Stat implements the same method as documented on platform.File
+func (d *openRootDir) Stat() (platform.Stat_t, syscall.Errno) {
+	return d.f.Stat()
+}
 
-func (d *openRootDir) Read([]byte) (int, error) {
-	return 0, &fs.PathError{Op: "read", Path: "/", Err: syscall.EISDIR}
+// Readdir implements the same method as documented on platform.File
+func (d *openRootDir) Readdir(count int) (dirents []platform.Dirent, errno syscall.Errno) {
+	if d.dirents == nil {
+		if errno = d.readdir(); errno != 0 {
+			return
+		}
+	}
+
+	// logic similar to go:embed
+	n := len(d.dirents) - d.direntsI
+	if n == 0 {
+		return
+	}
+	if count > 0 && n > count {
+		n = count
+	}
+	dirents = make([]platform.Dirent, n)
+	for i := range dirents {
+		dirents[i] = d.dirents[d.direntsI+i]
+	}
+	d.direntsI += n
+	return
 }
 
-// readDir reads the directory fully into d.dirents, replacing any entries that
-// correspond to prefix matches or appending them to the end.
-func (d *openRootDir) readDir() (err error) {
-	if d.dirents, err = d.f.ReadDir(-1); err != nil {
+func (d *openRootDir) readdir() (errno syscall.Errno) {
+	// readDir reads the directory fully into d.dirents, replacing any entries that
+	// correspond to prefix matches or appending them to the end.
+	if d.dirents, errno = d.f.Readdir(-1); errno != 0 {
 		return
 	}
 
@@ -173,18 +195,19 @@ func (d *openRootDir) readDir() (err error) {
 		remaining[k] = v
 	}
 
-	for i, e := range d.dirents {
-		if fsI, ok := remaining[e.Name()]; ok {
-			if d.dirents[i], err = d.rootEntry(e.Name(), fsI); err != nil {
+	for i := range d.dirents {
+		e := d.dirents[i]
+		if fsI, ok := remaining[e.Name]; ok {
+			if d.dirents[i], errno = d.rootEntry(e.Name, fsI); errno != 0 {
 				return
 			}
-			delete(remaining, e.Name())
+			delete(remaining, e.Name)
 		}
 	}
 
-	var di fs.DirEntry
+	var di platform.Dirent
 	for n, fsI := range remaining {
-		if di, err = d.rootEntry(n, fsI); err != nil {
+		if di, errno = d.rootEntry(n, fsI); errno != 0 {
 			return
 		}
 		d.dirents = append(d.dirents, di)
@@ -192,58 +215,42 @@ func (d *openRootDir) readDir() (err error) {
 	return
 }
 
-func (d *openRootDir) rootEntry(name string, fsI int) (fs.DirEntry, error) {
-	if st, err := d.c.fs[fsI].Stat("."); err != 0 {
-		return nil, err
-	} else {
-		return &dirInfo{name, st}, nil
-	}
+// Sync implements the same method as documented on platform.File
+func (d *openRootDir) Sync() syscall.Errno {
+	return d.f.Sync()
 }
 
-// dirInfo is a DirEntry based on a FileInfo.
-type dirInfo struct {
-	// name is needed to retain the stat info for a mount, knowing the
-	// directory is masked. For example, we don't want to leak the underlying
-	// host directory name.
-	name string
-	stat platform.Stat_t
+// Datasync implements the same method as documented on platform.File
+func (d *openRootDir) Datasync() syscall.Errno {
+	return d.f.Datasync()
 }
 
-func (i *dirInfo) Name() string               { return i.name }
-func (i *dirInfo) Type() fs.FileMode          { return i.stat.Mode.Type() }
-func (i *dirInfo) Info() (fs.FileInfo, error) { return i, nil }
-func (i *dirInfo) Size() int64                { return i.stat.Size }
-func (i *dirInfo) Mode() fs.FileMode          { return i.stat.Mode }
-func (i *dirInfo) ModTime() time.Time {
-	return time.Unix(i.stat.Mtim/1e9, i.stat.Mtim%1e9)
+// Chmod implements the same method as documented on platform.File
+func (d *openRootDir) Chmod(fs.FileMode) syscall.Errno {
+	return syscall.ENOSYS
 }
-func (i *dirInfo) IsDir() bool      { return i.stat.Mode.IsDir() }
-func (i *dirInfo) Sys() interface{} { return nil }
 
-func (d *openRootDir) ReadDir(count int) ([]fs.DirEntry, error) {
-	if d.dirents == nil {
-		if err := d.readDir(); err != nil {
-			return nil, err
-		}
-	}
+// Chown implements the same method as documented on platform.File
+func (d *openRootDir) Chown(int, int) syscall.Errno {
+	return syscall.ENOSYS
+}
 
-	// logic similar to go:embed
-	n := len(d.dirents) - d.direntsI
-	if n == 0 {
-		if count <= 0 {
-			return nil, nil
-		}
-		return nil, io.EOF
-	}
-	if count > 0 && n > count {
-		n = count
-	}
-	list := make([]fs.DirEntry, n)
-	for i := range list {
-		list[i] = d.dirents[d.direntsI+i]
+// Utimens implements the same method as documented on platform.File
+func (d *openRootDir) Utimens(*[2]syscall.Timespec) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Close implements fs.File
+func (d *openRootDir) Close() syscall.Errno {
+	return d.f.Close()
+}
+
+func (d *openRootDir) rootEntry(name string, fsI int) (platform.Dirent, syscall.Errno) {
+	if st, errno := d.c.fs[fsI].Stat("."); errno != 0 {
+		return platform.Dirent{}, errno
+	} else {
+		return platform.Dirent{Name: name, Ino: st.Ino, Type: st.Mode.Type()}, 0
 	}
-	d.direntsI += n
-	return list, nil
 }
 
 // Lstat implements FS.Lstat
diff --git a/internal/wasm/function_definition.go b/internal/wasm/function_definition.go
index 51d530a7..c5f6e912 100644
--- a/internal/wasm/function_definition.go
+++ b/internal/wasm/function_definition.go
@@ -10,11 +10,8 @@ import (
 //
 // Note: Unlike ExportedFunctions, there is no unique constraint on imports.
 func (m *Module) ImportedFunctions() (ret []api.FunctionDefinition) {
-	for i := range m.FunctionDefinitionSection {
-		d := &m.FunctionDefinitionSection[i]
-		if d.importDesc != nil {
-			ret = append(ret, d)
-		}
+	for i := uint32(0); i < m.ImportFunctionCount; i++ {
+		ret = append(ret, m.FunctionDefinition(i))
 	}
 	return
 }
@@ -22,21 +19,30 @@ func (m *Module) ImportedFunctions() (ret []api.FunctionDefinition) {
 // ExportedFunctions returns the definitions of each exported function.
 func (m *Module) ExportedFunctions() map[string]api.FunctionDefinition {
 	ret := map[string]api.FunctionDefinition{}
-	for i := range m.FunctionDefinitionSection {
-		d := &m.FunctionDefinitionSection[i]
-		for _, e := range d.exportNames {
-			ret[e] = d
+	for i := range m.ExportSection {
+		exp := &m.ExportSection[i]
+		if exp.Type == ExternTypeFunc {
+			d := m.FunctionDefinition(exp.Index)
+			ret[exp.Name] = d
 		}
 	}
 	return ret
 }
 
-// BuildFunctionDefinitions generates function metadata that can be parsed from
+// FunctionDefinition returns the FunctionDefinition for the given `index`.
+func (m *Module) FunctionDefinition(index Index) *FunctionDefinition {
+	// TODO: function initialization is lazy, but bulk. Make it per function.
+	m.buildFunctionDefinitions()
+	return &m.FunctionDefinitionSection[index]
+}
+
+// buildFunctionDefinitions generates function metadata that can be parsed from
 // the module. This must be called after all validation.
-//
-// Note: This is exported for tests who don't use wazero.Runtime or
-// NewHostModule to compile the module.
-func (m *Module) BuildFunctionDefinitions() {
+func (m *Module) buildFunctionDefinitions() {
+	m.functionDefinitionSectionInitOnce.Do(m.buildFunctionDefinitionsOnce)
+}
+
+func (m *Module) buildFunctionDefinitionsOnce() {
 	var moduleName string
 	var functionNames NameMap
 	var localNames, resultNames IndirectNameMap
diff --git a/internal/wasm/host.go b/internal/wasm/host.go
index 400bcea3..bb9c40f5 100644
--- a/internal/wasm/host.go
+++ b/internal/wasm/host.go
@@ -73,7 +73,6 @@ func NewHostModule(
 	// TODO: refactor engines so that we can properly cache compiled machine codes for host modules.
 	m.AssignModuleID([]byte(fmt.Sprintf("@@@@@@@@%p", m)), // @@@@@@@@ = any 8 bytes different from Wasm header.
 		false, false)
-	m.BuildFunctionDefinitions()
 	return
 }
 
diff --git a/internal/wasm/module.go b/internal/wasm/module.go
index 57063bec..ba2c032a 100644
--- a/internal/wasm/module.go
+++ b/internal/wasm/module.go
@@ -8,6 +8,7 @@ import (
 	"io"
 	"sort"
 	"strings"
+	"sync"
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/ieee754"
@@ -169,10 +170,13 @@ type Module struct {
 	// IsHostModule true if this is the host module, false otherwise.
 	IsHostModule bool
 
-	// FunctionDefinitionSection is a wazero-specific section built on Validate.
+	// functionDefinitionSectionInitOnce guards FunctionDefinitionSection so that it is initialized exactly once.
+	functionDefinitionSectionInitOnce sync.Once
+
+	// FunctionDefinitionSection is a wazero-specific section.
 	FunctionDefinitionSection []FunctionDefinition
 
-	// MemoryDefinitionSection is a wazero-specific section built on Validate.
+	// MemoryDefinitionSection is a wazero-specific section.
 	MemoryDefinitionSection []MemoryDefinition
 
 	// DWARFLines is used to emit DWARF based stack trace. This is created from the multiple custom sections
@@ -212,28 +216,28 @@ func boolToByte(b bool) (ret byte) {
 	return
 }
 
-// TypeOfFunction returns the wasm.SectionIDType index for the given function space index or nil.
-// Note: The function index is preceded by imported functions.
-// TODO: Returning nil should be impossible when decode results are validated. Validate decode before back-filling tests.
-func (m *Module) TypeOfFunction(funcIdx Index) *FunctionType {
-	typeSectionLength := uint32(len(m.TypeSection))
-	if typeSectionLength == 0 {
-		return nil
-	}
-	funcImportCount := Index(0)
-	for i := range m.ImportSection {
-		imp := &m.ImportSection[i]
-		if imp.Type == ExternTypeFunc {
-			if funcIdx == funcImportCount {
+// typeOfFunction returns the wasm.FunctionType for the given function space index or nil.
+func (m *Module) typeOfFunction(funcIdx Index) *FunctionType {
+	typeSectionLength, importedFunctionCount := uint32(len(m.TypeSection)), m.ImportFunctionCount
+	if funcIdx < importedFunctionCount {
+		// Imports are not exclusively functions. This is the current function index in the loop.
+		cur := Index(0)
+		for i := range m.ImportSection {
+			imp := &m.ImportSection[i]
+			if imp.Type != ExternTypeFunc {
+				continue
+			}
+			if funcIdx == cur {
 				if imp.DescFunc >= typeSectionLength {
 					return nil
 				}
 				return &m.TypeSection[imp.DescFunc]
 			}
-			funcImportCount++
+			cur++
 		}
 	}
-	funcSectionIdx := funcIdx - funcImportCount
+
+	funcSectionIdx := funcIdx - m.ImportFunctionCount
 	if funcSectionIdx >= uint32(len(m.FunctionSection)) {
 		return nil
 	}
@@ -296,7 +300,7 @@ func (m *Module) validateStartSection() error {
 	// TODO: this should be verified during decode so that errors have the correct source positions
 	if m.StartSection != nil {
 		startIndex := *m.StartSection
-		ft := m.TypeOfFunction(startIndex)
+		ft := m.typeOfFunction(startIndex)
 		if ft == nil { // TODO: move this check to decoder so that a module can never be decoded invalidly
 			return fmt.Errorf("invalid start function: func[%d] has an invalid type", startIndex)
 		}
diff --git a/internal/wasm/module_instance.go b/internal/wasm/module_instance.go
index a11e9a83..20cdcd96 100644
--- a/internal/wasm/module_instance.go
+++ b/internal/wasm/module_instance.go
@@ -203,7 +203,7 @@ func (m *ModuleInstance) ExportedFunctionDefinitions() map[string]api.FunctionDe
 	result := map[string]api.FunctionDefinition{}
 	for name, exp := range m.Exports {
 		if exp.Type == ExternTypeFunc {
-			result[name] = &m.Definitions[exp.Index]
+			result[name] = m.Source.FunctionDefinition(exp.Index)
 		}
 	}
 	return result
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index 8134c50a..264242cc 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -127,8 +127,8 @@ type (
 		//
 		// Note: This is currently only used for spectests and will be nil in most cases.
 		aliases []string
-		// Definitions is derived from *Module, and is constructed during compilation phrase.
-		Definitions []FunctionDefinition
+		// Source is a pointer to the Module from which this ModuleInstance derives.
+		Source *Module
 	}
 
 	// DataInstance holds bytes corresponding to the data segment in a module.
@@ -321,7 +321,7 @@ func (s *Store) instantiate(
 	sysCtx *internalsys.Context,
 	typeIDs []FunctionTypeID,
 ) (m *ModuleInstance, err error) {
-	m = &ModuleInstance{ModuleName: name, TypeIDs: typeIDs, Sys: sysCtx, s: s, Definitions: module.FunctionDefinitionSection}
+	m = &ModuleInstance{ModuleName: name, TypeIDs: typeIDs, Sys: sysCtx, s: s, Source: module}
 
 	m.Tables = make([]*TableInstance, int(module.ImportTableCount)+len(module.TableSection))
 	m.Globals = make([]*GlobalInstance, int(module.ImportGlobalCount)+len(module.GlobalSection))
@@ -396,9 +396,10 @@ func (m *ModuleInstance) resolveImports(module *Module) (err error) {
 			switch i.Type {
 			case ExternTypeFunc:
 				expectedType := &module.TypeSection[i.DescFunc]
-				actual := &importedModule.Definitions[imported.Index]
-				if !actual.Functype.EqualsSignature(expectedType.Params, expectedType.Results) {
-					err = errorInvalidImport(i, fmt.Errorf("signature mismatch: %s != %s", expectedType, actual.Functype))
+				src := importedModule.Source
+				actual := src.typeOfFunction(imported.Index)
+				if !actual.EqualsSignature(expectedType.Params, expectedType.Results) {
+					err = errorInvalidImport(i, fmt.Errorf("signature mismatch: %s != %s", expectedType, actual))
 					return
 				}
 
diff --git a/runtime.go b/runtime.go
index b651861c..789069d8 100644
--- a/runtime.go
+++ b/runtime.go
@@ -215,8 +215,8 @@ func (r *runtime) CompileModule(ctx context.Context, binary []byte) (CompiledMod
 		return nil, err
 	}
 
-	// Now that the module is validated, cache the function and memory definitions.
-	internal.BuildFunctionDefinitions()
+	// Now that the module is validated, cache the memory definitions.
+	// TODO: lazy initialization of memory definition.
 	internal.BuildMemoryDefinitions()
 
 	c := &compiledModule{module: internal, compiledEngine: r.store.Engine}
@@ -249,7 +249,7 @@ func buildFunctionListeners(ctx context.Context, internal *wasm.Module) ([]exper
 	importCount := internal.ImportFunctionCount
 	listeners := make([]experimentalapi.FunctionListener, len(internal.FunctionSection))
 	for i := 0; i < len(listeners); i++ {
-		listeners[i] = factory.NewFunctionListener(&internal.FunctionDefinitionSection[uint32(i)+importCount])
+		listeners[i] = factory.NewFunctionListener(internal.FunctionDefinition(uint32(i) + importCount))
 	}
 	return listeners, nil
 }


Test patch:
diff --git a/experimental/listener_example_test.go b/experimental/listener_example_test.go
index 5e443896..8b7f37d5 100644
--- a/experimental/listener_example_test.go
+++ b/experimental/listener_example_test.go
@@ -44,13 +44,15 @@ func (u uniqGoFuncs) NewFunctionListener(def api.FunctionDefinition) experimenta
 }
 
 // Before implements FunctionListener.Before
-func (u uniqGoFuncs) Before(ctx context.Context, _ api.Module, def api.FunctionDefinition, _ []uint64, _ experimental.StackIterator) context.Context {
+func (u uniqGoFuncs) Before(ctx context.Context, _ api.Module, def api.FunctionDefinition, _ []uint64, _ experimental.StackIterator) {
 	u[def.DebugName()] = struct{}{}
-	return ctx
 }
 
 // After implements FunctionListener.After
-func (u uniqGoFuncs) After(context.Context, api.Module, api.FunctionDefinition, error, []uint64) {}
+func (u uniqGoFuncs) After(context.Context, api.Module, api.FunctionDefinition, []uint64) {}
+
+// Abort implements FunctionListener.Abort
+func (u uniqGoFuncs) Abort(context.Context, api.Module, api.FunctionDefinition, error) {}
 
 // This shows how to make a listener that counts go function calls.
 func Example_customListenerFactory() {
@@ -89,28 +91,35 @@ func Example_stackIterator() {
 	it := &fakeStackIterator{}
 
 	for it.Next() {
-		fmt.Println("function:", it.FunctionDefinition().DebugName())
+		fn := it.Function()
+		pc := it.ProgramCounter()
+		fmt.Println("function:", fn.Definition().DebugName())
 		fmt.Println("\tparameters:", it.Parameters())
-		fmt.Println("\tsource offset:", it.SourceOffset())
+		fmt.Println("\tprogram counter:", pc)
+		fmt.Println("\tsource offset:", fn.SourceOffsetForPC(pc))
 	}
 
 	// Output:
 	// function: fn0
 	// 	parameters: [1 2 3]
+	// 	program counter: 5890831
 	// 	source offset: 1234
 	// function: fn1
 	// 	parameters: []
+	// 	program counter: 5899822
 	// 	source offset: 7286
 	// function: fn2
 	// 	parameters: [4]
+	// 	program counter: 6820312
 	// 	source offset: 935891
 }
 
 type fakeStackIterator struct {
-	iteration int
-	def       api.FunctionDefinition
-	args      []uint64
-	offset    uint64
+	iteration    int
+	def          api.FunctionDefinition
+	args         []uint64
+	pc           uint64
+	sourceOffset uint64
 }
 
 func (s *fakeStackIterator) Next() bool {
@@ -118,15 +127,18 @@ func (s *fakeStackIterator) Next() bool {
 	case 0:
 		s.def = &mockFunctionDefinition{debugName: "fn0"}
 		s.args = []uint64{1, 2, 3}
-		s.offset = 1234
+		s.pc = 5890831
+		s.sourceOffset = 1234
 	case 1:
 		s.def = &mockFunctionDefinition{debugName: "fn1"}
 		s.args = []uint64{}
-		s.offset = 7286
+		s.pc = 5899822
+		s.sourceOffset = 7286
 	case 2:
 		s.def = &mockFunctionDefinition{debugName: "fn2"}
 		s.args = []uint64{4}
-		s.offset = 935891
+		s.pc = 6820312
+		s.sourceOffset = 935891
 	case 3:
 		return false
 	}
@@ -134,20 +146,36 @@ func (s *fakeStackIterator) Next() bool {
 	return true
 }
 
-func (s *fakeStackIterator) FunctionDefinition() api.FunctionDefinition {
-	return s.def
+func (s *fakeStackIterator) Function() experimental.InternalFunction {
+	return internalFunction{
+		definition:   s.def,
+		sourceOffset: s.sourceOffset,
+	}
 }
 
 func (s *fakeStackIterator) Parameters() []uint64 {
 	return s.args
 }
 
-func (s *fakeStackIterator) SourceOffset() uint64 {
-	return s.offset
+func (s *fakeStackIterator) ProgramCounter() experimental.ProgramCounter {
+	return experimental.ProgramCounter(s.pc)
 }
 
 var _ experimental.StackIterator = &fakeStackIterator{}
 
+type internalFunction struct {
+	definition   api.FunctionDefinition
+	sourceOffset uint64
+}
+
+func (f internalFunction) Definition() api.FunctionDefinition {
+	return f.definition
+}
+
+func (f internalFunction) SourceOffsetForPC(pc experimental.ProgramCounter) uint64 {
+	return f.sourceOffset
+}
+
 type mockFunctionDefinition struct {
 	debugName string
 	*wasm.FunctionDefinition
diff --git a/experimental/listener_test.go b/experimental/listener_test.go
index 8555f6f1..3db4863f 100644
--- a/experimental/listener_test.go
+++ b/experimental/listener_test.go
@@ -18,19 +18,24 @@ import (
 var _ experimental.FunctionListenerFactory = &recorder{}
 
 type recorder struct {
-	m                       map[string]struct{}
-	beforeNames, afterNames []string
+	m           map[string]struct{}
+	beforeNames []string
+	afterNames  []string
+	abortNames  []string
 }
 
-func (r *recorder) Before(ctx context.Context, _ api.Module, def api.FunctionDefinition, _ []uint64, _ experimental.StackIterator) context.Context {
+func (r *recorder) Before(ctx context.Context, _ api.Module, def api.FunctionDefinition, _ []uint64, _ experimental.StackIterator) {
 	r.beforeNames = append(r.beforeNames, def.DebugName())
-	return ctx
 }
 
-func (r *recorder) After(_ context.Context, _ api.Module, def api.FunctionDefinition, _ error, _ []uint64) {
+func (r *recorder) After(_ context.Context, _ api.Module, def api.FunctionDefinition, _ []uint64) {
 	r.afterNames = append(r.afterNames, def.DebugName())
 }
 
+func (r *recorder) Abort(_ context.Context, _ api.Module, def api.FunctionDefinition, _ error) {
+	r.abortNames = append(r.abortNames, def.DebugName())
+}
+
 func (r *recorder) NewFunctionListener(definition api.FunctionDefinition) experimental.FunctionListener {
 	r.m[definition.Name()] = struct{}{}
 	return r
@@ -116,7 +121,7 @@ func TestMultiFunctionListenerFactory(t *testing.T) {
 	}
 
 	n := 0
-	f := func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) {
+	f := func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
 		n++
 		i := 0
 		for stackIterator.Next() {
@@ -180,12 +185,12 @@ func BenchmarkMultiFunctionListener(b *testing.B) {
 	}{
 		{
 			scenario: "simple function listener",
-			function: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) {
+			function: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
 			},
 		},
 		{
 			scenario: "stack iterator",
-			function: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) {
+			function: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
 				for stackIterator.Next() {
 				}
 			},
diff --git a/experimental/logging/log_listener_test.go b/experimental/logging/log_listener_test.go
index 8417f56f..284cbb94 100644
--- a/experimental/logging/log_listener_test.go
+++ b/experimental/logging/log_listener_test.go
@@ -3,7 +3,6 @@ package logging_test
 import (
 	"bytes"
 	"context"
-	"io"
 	"math"
 	"testing"
 
@@ -36,7 +35,6 @@ func Test_loggingListener(t *testing.T) {
 		isHostFunc              bool
 		paramNames, resultNames []string
 		params, results         []uint64
-		err                     error
 		expected                string
 	}{
 		{
@@ -44,14 +42,6 @@ func Test_loggingListener(t *testing.T) {
 			functype: wasm.FunctionType{},
 			expected: `--> test.fn()
 <--
-`,
-		},
-		{
-			name:     "error",
-			functype: wasm.FunctionType{},
-			err:      io.EOF,
-			expected: `--> test.fn()
-<-- error: EOF
 `,
 		},
 		{
@@ -88,20 +78,6 @@ func Test_loggingListener(t *testing.T) {
 			results:     []uint64{uint64(wasi.ErrnoFault)},
 			expected: `==> wasi_snapshot_preview1.random_get(buf=0,buf_len=8)
 <== errno=EFAULT
-`,
-		},
-		{
-			name:        "wasi error",
-			functype:    wasiFuncType,
-			moduleName:  wasi.InternalModuleName,
-			funcName:    wasiFuncName,
-			paramNames:  wasiParamNames,
-			resultNames: wasiResultNames,
-			isHostFunc:  true,
-			params:      wasiParams,
-			err:         io.EOF, // not possible as we coerce errors to numbers, but test anyway!
-			expected: `==> wasi_snapshot_preview1.random_get(buf=0,buf_len=8)
-<== error: EOF
 `,
 		},
 		{
@@ -298,13 +274,12 @@ func Test_loggingListener(t *testing.T) {
 			} else {
 				m.CodeSection = []wasm.Code{{Body: []byte{wasm.OpcodeEnd}}}
 			}
-			m.BuildFunctionDefinitions()
-			def := &m.FunctionDefinitionSection[0]
+			def := m.FunctionDefinition(0)
 			l := lf.NewFunctionListener(def)
 
 			out.Reset()
-			ctx := l.Before(testCtx, nil, def, tc.params, nil)
-			l.After(ctx, nil, def, tc.err, tc.results)
+			l.Before(testCtx, nil, def, tc.params, nil)
+			l.After(testCtx, nil, def, tc.results)
 			require.Equal(t, tc.expected, out.String())
 		})
 	}
@@ -333,16 +308,15 @@ func Test_loggingListener_indentation(t *testing.T) {
 			FunctionNames: wasm.NameMap{{Index: 0, Name: "fn1"}, {Index: 1, Name: "fn2"}},
 		},
 	}
-	m.BuildFunctionDefinitions()
-	def1 := &m.FunctionDefinitionSection[0]
+	def1 := m.FunctionDefinition(0)
 	l1 := lf.NewFunctionListener(def1)
-	def2 := &m.FunctionDefinitionSection[1]
+	def2 := m.FunctionDefinition(1)
 	l2 := lf.NewFunctionListener(def2)
 
-	ctx := l1.Before(testCtx, nil, def1, []uint64{}, nil)
-	ctx1 := l2.Before(ctx, nil, def2, []uint64{}, nil)
-	l2.After(ctx1, nil, def2, nil, []uint64{})
-	l1.After(ctx, nil, def1, nil, []uint64{})
+	l1.Before(testCtx, nil, def1, []uint64{}, nil)
+	l2.Before(testCtx, nil, def2, []uint64{}, nil)
+	l2.After(testCtx, nil, def2, []uint64{})
+	l1.After(testCtx, nil, def1, []uint64{})
 	require.Equal(t, `--> test.fn1()
 	--> test.fn2()
 	<--
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 9c2d713c..896adb2d 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -1934,17 +1934,19 @@ func Test_fdRead_Errors(t *testing.T) {
 }
 
 var (
-	testDirents = func() []*platform.Dirent {
-		d, err := fstest.FS.Open("dir")
+	testDirents = func() []platform.Dirent {
+		dPath := "dir"
+		d, err := fstest.FS.Open(dPath)
 		if err != nil {
 			panic(err)
 		}
 		defer d.Close()
-		dirents, errno := platform.Readdir(d, -1)
+		pf := platform.NewFsFile(dPath, 0, d)
+		dirents, errno := pf.Readdir(-1)
 		if errno != 0 {
 			panic(errno)
 		}
-		dots := []*platform.Dirent{
+		dots := []platform.Dirent{
 			{Name: ".", Type: fs.ModeDir},
 			{Name: "..", Type: fs.ModeDir},
 		}
@@ -2100,7 +2102,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				dirent, errno := platform.Readdir(dir.File(), 1)
+				dirent, errno := dir.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2125,7 +2127,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				dirent, errno := platform.Readdir(dir.File(), 1)
+				dirent, errno := dir.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2151,7 +2153,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				dirent, errno := platform.Readdir(dir.File(), 1)
+				dirent, errno := dir.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2176,7 +2178,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				dirent, errno := platform.Readdir(dir.File(), 1)
+				dirent, errno := dir.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2201,7 +2203,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				two, errno := platform.Readdir(dir.File(), 2)
+				two, errno := dir.Readdir(2)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2226,7 +2228,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				two, errno := platform.Readdir(dir.File(), 2)
+				two, errno := dir.Readdir(2)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -2251,7 +2253,7 @@ func Test_fdReaddir(t *testing.T) {
 			dir: func() *sys.FileEntry {
 				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
 				require.EqualErrno(t, 0, errno)
-				_, errno = platform.Readdir(dir.File(), 3)
+				_, errno = dir.Readdir(3)
 				require.EqualErrno(t, 0, errno)
 
 				return &sys.FileEntry{
@@ -4017,9 +4019,11 @@ func Test_pathOpen(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().
-				WithFS(tc.fs.(fs.FS))) // built-in impls reverse-implement fs.FS
+			mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
 			defer r.Close(testCtx)
+
+			mod.(*wasm.ModuleInstance).Sys = sys.DefaultContext(tc.fs)
+
 			pathName := tc.path(t)
 			mod.Memory().Write(0, []byte(pathName))
 
@@ -4076,9 +4080,17 @@ func requireContents(t *testing.T, fsc *sys.FSContext, expectedOpenedFd int32, f
 	require.Equal(t, fileName, f.Name)
 
 	// verify the contents are readable
-	b, err := io.ReadAll(f.File.File())
-	require.NoError(t, err)
-	require.Equal(t, fileContents, b)
+	buf := readAll(t, f.File)
+	require.Equal(t, fileContents, buf)
+}
+
+func readAll(t *testing.T, f platform.File) []byte {
+	st, errno := f.Stat()
+	require.EqualErrno(t, 0, errno)
+	buf := make([]byte, st.Size)
+	_, errno = f.Read(buf)
+	require.EqualErrno(t, 0, errno)
+	return buf
 }
 
 func mkdir(t *testing.T, tmpDir, dir string) {
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index bf892a31..8e6270ff 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -17,7 +17,7 @@ func Test_lastDirents(t *testing.T) {
 		name            string
 		f               *sys.ReadDir
 		cookie          int64
-		expectedDirents []*platform.Dirent
+		expectedDirents []platform.Dirent
 		expectedErrno   syscall.Errno
 	}{
 		{
@@ -102,7 +102,7 @@ func Test_lastDirents(t *testing.T) {
 func Test_maxDirents(t *testing.T) {
 	tests := []struct {
 		name                        string
-		dirents                     []*platform.Dirent
+		dirents                     []platform.Dirent
 		maxLen                      uint32
 		expectedCount               uint32
 		expectedwriteTruncatedEntry bool
@@ -194,13 +194,15 @@ func Test_maxDirents(t *testing.T) {
 }
 
 var (
-	testDirents = func() []*platform.Dirent {
-		dir, err := fstest.FS.Open("dir")
+	testDirents = func() []platform.Dirent {
+		dPath := "dir"
+		d, err := fstest.FS.Open(dPath)
 		if err != nil {
 			panic(err)
 		}
-		defer dir.Close()
-		dirents, errno := platform.Readdir(dir, -1)
+		defer d.Close()
+		pf := platform.NewFsFile(dPath, 0, d)
+		dirents, errno := pf.Readdir(-1)
 		if errno != 0 {
 			panic(errno)
 		}
@@ -233,7 +235,7 @@ var (
 func Test_writeDirents(t *testing.T) {
 	tests := []struct {
 		name                string
-		entries             []*platform.Dirent
+		entries             []platform.Dirent
 		entryCount          uint32
 		writeTruncatedEntry bool
 		expectedEntriesBuf  []byte
@@ -363,3 +365,70 @@ func Test_getWasiFiletype_DevNull(t *testing.T) {
 	// Should be a character device, and not contain permissions
 	require.Equal(t, wasip1.FILETYPE_CHARACTER_DEVICE, ft)
 }
+
+func Test_isPreopenedStdio(t *testing.T) {
+	tests := []struct {
+		name     string
+		fd       int32
+		f        *sys.FileEntry
+		expected bool
+	}{
+		{
+			name:     "stdin",
+			fd:       sys.FdStdin,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stdin re-opened",
+			fd:       sys.FdStdin,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "stdout",
+			fd:       sys.FdStdout,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stdout re-opened",
+			fd:       sys.FdStdout,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "stderr",
+			fd:       sys.FdStderr,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stderr re-opened",
+			fd:       sys.FdStderr,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "not stdio pre-open",
+			fd:       sys.FdPreopen,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: false,
+		},
+		{
+			name:     "random file",
+			fd:       42,
+			f:        &sys.FileEntry{},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			ok := isPreopenedStdio(tc.fd, tc.f)
+			require.Equal(t, tc.expected, ok)
+		})
+	}
+}
diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go
index d3d90bee..8b2f0279 100644
--- a/imports/wasi_snapshot_preview1/poll_test.go
+++ b/imports/wasi_snapshot_preview1/poll_test.go
@@ -3,10 +3,13 @@ package wasi_snapshot_preview1_test
 import (
 	"io/fs"
 	"strings"
+	"syscall"
 	"testing"
 	"time"
 
 	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasip1"
@@ -151,7 +154,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 		name                                   string
 		in, out, nsubscriptions, resultNevents uint32
 		mem                                    []byte // at offset in
-		stdioReader                            *sys.StdioFileReader
+		stdin                                  platform.File
 		expectedErrno                          wasip1.Errno
 		expectedMem                            []byte // at offset out
 		expectedLog                            string
@@ -161,14 +164,11 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "Read without explicit timeout (no tty)",
 			nsubscriptions:  1,
 			expectedNevents: 1,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady), // isatty
-			mem:           fdReadSub,
-			expectedErrno: wasip1.ErrnoSuccess,
-			out:           128, // past in
-			resultNevents: 512, // past out
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
+			mem:             fdReadSub,
+			expectedErrno:   wasip1.ErrnoSuccess,
+			out:             128, // past in
+			resultNevents:   512, // past out
 			expectedMem: []byte{
 				0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata
 				byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit
@@ -188,10 +188,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "20ms timeout, fdread on tty (buffer ready): both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerAlwaysReady), // isatty
+			stdin:           &ttyStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -226,10 +223,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "0ns timeout, fdread on tty (buffer ready): both are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerAlwaysReady), // isatty
+			stdin:           &ttyStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -264,10 +258,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "0ns timeout, fdread on regular file: both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady),
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -302,10 +293,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "1ns timeout, fdread on regular file: both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady),
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -340,10 +328,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "20ms timeout, fdread on blocked tty: only clock event is written",
 			nsubscriptions:  2,
 			expectedNevents: 1,
-			stdioReader: sys.NewStdioFileReader(
-				newBlockingReader(t),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerNeverReady),
+			stdin:           &neverReadyTtyStdinFile{StdinFile: sys.StdinFile{Reader: newBlockingReader(t)}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -379,13 +364,13 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			tconfig := wazero.NewModuleConfig().WithStdin(tc.stdioReader)
-			mod, r, log := requireProxyModule(t, tconfig)
+			mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
 			defer r.Close(testCtx)
 			defer log.Reset()
 
-			maskMemory(t, mod, 1024)
+			setStdin(t, mod, tc.stdin)
 
+			maskMemory(t, mod, 1024)
 			if tc.mem != nil {
 				mod.Memory().Write(tc.in, tc.mem)
 			}
@@ -409,18 +394,22 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 	}
 }
 
-func Test_pollOneoff_Zero(t *testing.T) {
-	poller := &poller{ready: true}
+func setStdin(t *testing.T, mod api.Module, stdin platform.File) {
+	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
+	f, ok := fsc.LookupFile(sys.FdStdin)
+	require.True(t, ok)
+	f.File = stdin
+}
 
-	tconfig := wazero.NewModuleConfig().WithStdin(sys.NewStdioFileReader(
-		strings.NewReader("test"),
-		stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-		poller))
+func Test_pollOneoff_Zero(t *testing.T) {
+	poller := &pollStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}, ready: true}
 
-	mod, r, log := requireProxyModule(t, tconfig)
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
 	defer r.Close(testCtx)
 	defer log.Reset()
 
+	setStdin(t, mod, poller)
+
 	maskMemory(t, mod, 1024)
 
 	out := uint32(128)
@@ -539,14 +528,42 @@ func fdReadSubFd(fd byte) []byte {
 // subscription for an EventTypeFdRead on stdin
 var fdReadSub = fdReadSubFd(byte(sys.FdStdin))
 
-type poller struct {
+// ttyStat returns fs.ModeCharDevice as an approximation for isatty.
+// See go-isatty for a more specific approach:
+// https://github.com/mattn/go-isatty/blob/v0.0.18/isatty_tcgets.go#LL11C1-L12C1
+type ttyStat struct{}
+
+// Stat implements the same method as documented on platform.File
+func (ttyStat) Stat() (platform.Stat_t, syscall.Errno) {
+	return platform.Stat_t{
+		Mode:  fs.ModeDevice | fs.ModeCharDevice,
+		Nlink: 1,
+	}, 0
+}
+
+type ttyStdinFile struct {
+	sys.StdinFile
+	ttyStat
+}
+
+type neverReadyTtyStdinFile struct {
+	sys.StdinFile
+	ttyStat
+}
+
+// PollRead implements the same method as documented on platform.File
+func (neverReadyTtyStdinFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
+	time.Sleep(*timeout)
+	return false, 0
+}
+
+type pollStdinFile struct {
+	sys.StdinFile
+	ttyStat
 	ready bool
 }
 
-func (p *poller) Poll(d time.Duration) (bool, error) {
-	if p.ready {
-		return true, nil
-	} else {
-		return false, nil
-	}
+// PollRead implements the same method as documented on platform.File
+func (p *pollStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return p.ready, 0
 }
diff --git a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
index f2488f22..462a6e09 100644
--- a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/sys"
@@ -197,6 +198,10 @@ func testPreopen(t *testing.T, bin []byte) {
 }
 
 func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (console string) {
+	return compileAndRunWithStdin(t, config, bin, nil)
+}
+
+func compileAndRunWithStdin(t *testing.T, config wazero.ModuleConfig, bin []byte, stdin platform.File) (console string) {
 	// same for console and stderr as sometimes the stack trace is in one or the other.
 	var consoleBuf bytes.Buffer
 
@@ -206,8 +211,17 @@ func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (consol
 	_, err := wasi_snapshot_preview1.Instantiate(testCtx, r)
 	require.NoError(t, err)
 
-	_, err = r.InstantiateWithConfig(testCtx, bin,
-		config.WithStdout(&consoleBuf).WithStderr(&consoleBuf))
+	mod, err := r.InstantiateWithConfig(testCtx, bin, config.
+		WithStdout(&consoleBuf).
+		WithStderr(&consoleBuf).
+		WithStartFunctions()) // clear
+	require.NoError(t, err)
+
+	if stdin != nil {
+		setStdin(t, mod, stdin)
+	}
+
+	_, err = mod.ExportedFunction("_start").Call(testCtx)
 	if exitErr, ok := err.(*sys.ExitError); ok {
 		require.Zero(t, exitErr.ExitCode(), consoleBuf.String())
 	} else {
@@ -225,57 +239,42 @@ func Test_Poll(t *testing.T) {
 	tests := []struct {
 		name            string
 		args            []string
-		stdin           io.Reader
+		stdin           platform.File
 		expectedOutput  string
 		expectedTimeout time.Duration
 	}{
 		{
-			name: "custom reader, data ready, not tty",
-			args: []string{"wasi", "poll"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, not tty",
+			args:            []string{"wasi", "poll"},
+			stdin:           &internalsys.StdinFile{Reader: strings.NewReader("test")},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom reader, data ready, not tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, not tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &internalsys.StdinFile{Reader: strings.NewReader("test")},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom reader, data ready, tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: strings.NewReader("test")}},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom, blocking reader, no data, tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				newBlockingReader(t), // simulate waiting for input
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerNeverReady),
+			name:            "custom, blocking reader, no data, tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &neverReadyTtyStdinFile{StdinFile: internalsys.StdinFile{Reader: newBlockingReader(t)}},
 			expectedOutput:  "NOINPUT",
 			expectedTimeout: 500 * time.Millisecond, // always timeouts
 		},
 		{
-			name: "eofReader, not tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				eofReader{}, // simulate waiting for input
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "eofReader, not tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: eofReader{}}},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
@@ -284,10 +283,8 @@ func Test_Poll(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			moduleConfig := wazero.NewModuleConfig().WithArgs(tc.args...).
-				WithStdin(tc.stdin)
 			start := time.Now()
-			console := compileAndRun(t, moduleConfig, wasmZigCc)
+			console := compileAndRunWithStdin(t, wazero.NewModuleConfig().WithArgs(tc.args...), wasmZigCc, tc.stdin)
 			elapsed := time.Since(start)
 			require.True(t, elapsed >= tc.expectedTimeout)
 			require.Equal(t, tc.expectedOutput+"\n", console)
diff --git a/imports/wasi_snapshot_preview1/wasi_test.go b/imports/wasi_snapshot_preview1/wasi_test.go
index a5b59b52..4b90867a 100644
--- a/imports/wasi_snapshot_preview1/wasi_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_test.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	_ "embed"
-	"io/fs"
 	"testing"
 	"time"
 
@@ -167,13 +166,3 @@ func (b blockingReader) Read(p []byte) (n int, err error) {
 	<-b.ctx.Done()
 	return 0, nil
 }
-
-// stdinFileInfo implements fs.FileInfo: it is only representing the mode because it is always stdin
-type stdinFileInfo uint32
-
-func (stdinFileInfo) Name() string        { return "stdin" }
-func (stdinFileInfo) Size() int64         { return 0 }
-func (s stdinFileInfo) Mode() fs.FileMode { return fs.FileMode(s) }
-func (stdinFileInfo) ModTime() time.Time  { return time.Unix(0, 0) }
-func (stdinFileInfo) IsDir() bool         { return false }
-func (stdinFileInfo) Sys() interface{}    { return nil }
diff --git a/internal/engine/compiler/engine_bench_test.go b/internal/engine/compiler/engine_bench_test.go
index 23ca605a..b7132d64 100644
--- a/internal/engine/compiler/engine_bench_test.go
+++ b/internal/engine/compiler/engine_bench_test.go
@@ -14,10 +14,9 @@ func BenchmarkCallEngine_builtinFunctionFunctionListener(b *testing.B) {
 		funcType: &wasm.FunctionType{ParamNumInUint64: 3},
 		parent: &compiledFunction{
 			listener: mockListener{
-				before: func(context.Context, api.Module, api.FunctionDefinition, []uint64, experimental.StackIterator) context.Context {
-					return context.Background()
+				before: func(context.Context, api.Module, api.FunctionDefinition, []uint64, experimental.StackIterator) {
 				},
-				after: func(context.Context, api.Module, api.FunctionDefinition, error, []uint64) {
+				after: func(context.Context, api.Module, api.FunctionDefinition, []uint64) {
 				},
 			},
 			index: 0,
@@ -30,15 +29,15 @@ func BenchmarkCallEngine_builtinFunctionFunctionListener(b *testing.B) {
 	}
 
 	ce := &callEngine{
-		ctx:          context.Background(),
 		stack:        []uint64{0, 1, 2, 3, 4, 0, 0, 0},
 		stackContext: stackContext{stackBasePointerInBytes: 16},
 	}
 
-	module := new(wasm.ModuleInstance)
+	mod := new(wasm.ModuleInstance)
+	ctx := context.Background()
 
 	for i := 0; i < b.N; i++ {
-		ce.builtinFunctionFunctionListenerBefore(ce.ctx, module, f)
-		ce.builtinFunctionFunctionListenerAfter(ce.ctx, module, f)
+		ce.builtinFunctionFunctionListenerBefore(ctx, mod, f)
+		ce.builtinFunctionFunctionListenerAfter(ctx, mod, f)
 	}
 }
diff --git a/internal/engine/compiler/engine_test.go b/internal/engine/compiler/engine_test.go
index 396fa777..302bb1a0 100644
--- a/internal/engine/compiler/engine_test.go
+++ b/internal/engine/compiler/engine_test.go
@@ -219,7 +219,6 @@ func TestCompiler_CompileModule(t *testing.T) {
 			},
 			ID: wasm.ModuleID{},
 		}
-		errModule.BuildFunctionDefinitions()
 
 		e := et.NewEngine(api.CoreFeaturesV1).(*engine)
 		err := e.CompileModule(testCtx, errModule, nil, false)
@@ -336,7 +335,6 @@ func TestCompiler_SliceAllocatedOnHeap(t *testing.T) {
 		},
 		ID: wasm.ModuleID{1},
 	}
-	m.BuildFunctionDefinitions()
 
 	err = s.Engine.CompileModule(testCtx, m, nil, false)
 	require.NoError(t, err)
@@ -382,11 +380,10 @@ func ptrAsUint64(f *function) uint64 {
 }
 
 func TestCallEngine_deferredOnCall(t *testing.T) {
-	vv := &wasm.FunctionType{}
 	s := &wasm.Module{
-		FunctionDefinitionSection: []wasm.FunctionDefinition{
-			{Debugname: "1", Functype: vv}, {Debugname: "2", Functype: vv}, {Debugname: "3", Functype: vv},
-		},
+		FunctionSection: []wasm.Index{0, 1, 2},
+		CodeSection:     []wasm.Code{{}, {}, {}},
+		TypeSection:     []wasm.FunctionType{{}, {}, {}},
 	}
 	f1 := &function{
 		funcType: &wasm.FunctionType{ParamNumInUint64: 2},
@@ -426,12 +423,12 @@ func TestCallEngine_deferredOnCall(t *testing.T) {
 
 	beforeRecoverStack := ce.stack
 
-	err := ce.deferredOnCall(errors.New("some error"))
+	err := ce.deferredOnCall(context.Background(), &wasm.ModuleInstance{}, errors.New("some error"))
 	require.EqualError(t, err, `some error (recovered by wazero)
 wasm stack trace:
-	3()
-	2()
-	1()`)
+	.$2()
+	.$1()
+	.$0()`)
 
 	// After recover, the state of callEngine must be reset except that the underlying slices must be intact
 	// for the subsequent calls to avoid additional allocations on each call.
@@ -577,84 +574,88 @@ type stackEntry struct {
 func assertStackIterator(t *testing.T, it experimental.StackIterator, expected []stackEntry) {
 	var actual []stackEntry
 	for it.Next() {
-		actual = append(actual, stackEntry{def: it.FunctionDefinition(), args: it.Parameters()})
+		actual = append(actual, stackEntry{def: it.Function().Definition(), args: it.Parameters()})
 	}
 	require.Equal(t, expected, actual)
 }
 
 func TestCallEngine_builtinFunctionFunctionListenerBefore(t *testing.T) {
-	nextContext, currentContext, prevContext := context.Background(), context.Background(), context.Background()
+	currentContext := context.Background()
 
 	f := &function{
 		funcType: &wasm.FunctionType{ParamNumInUint64: 3},
 		parent: &compiledFunction{
 			listener: mockListener{
-				before: func(ctx context.Context, _ api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) context.Context {
+				before: func(ctx context.Context, _ api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
 					require.Equal(t, currentContext, ctx)
-					require.Equal(t, []uint64{2, 3, 4}, paramValues)
+					require.Equal(t, []uint64{2, 3, 4}, params)
 					assertStackIterator(t, stackIterator, []stackEntry{{def: def, args: []uint64{2, 3, 4}}})
-					return nextContext
 				},
 			},
 			index: 0,
 			parent: &compiledModule{source: &wasm.Module{
-				FunctionDefinitionSection: []wasm.FunctionDefinition{{}},
+				FunctionSection: []wasm.Index{0},
+				CodeSection:     []wasm.Code{{}},
+				TypeSection:     []wasm.FunctionType{{}},
 			}},
 		},
 	}
 	ce := &callEngine{
-		ctx: currentContext, stack: []uint64{0, 1, 2, 3, 4, 0, 0, 0},
+		stack:        []uint64{0, 1, 2, 3, 4, 0, 0, 0},
 		stackContext: stackContext{stackBasePointerInBytes: 16},
-		contextStack: []context.Context{prevContext},
 	}
-	ce.builtinFunctionFunctionListenerBefore(ce.ctx, &wasm.ModuleInstance{}, f)
-
-	// Contexts must be stacked.
-	require.Equal(t, currentContext, ce.contextStack[1])
-	require.Equal(t, prevContext, ce.contextStack[0])
+	ce.builtinFunctionFunctionListenerBefore(currentContext, &wasm.ModuleInstance{}, f)
 }
 
 func TestCallEngine_builtinFunctionFunctionListenerAfter(t *testing.T) {
-	currentContext, prevContext := context.Background(), context.Background()
+	currentContext := context.Background()
 	f := &function{
 		funcType: &wasm.FunctionType{ResultNumInUint64: 1},
 		parent: &compiledFunction{
 			listener: mockListener{
-				after: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64) {
+				after: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
 					require.Equal(t, currentContext, ctx)
-					require.Equal(t, []uint64{5}, resultValues)
+					require.Equal(t, []uint64{5}, results)
 				},
 			},
 			index: 0,
 			parent: &compiledModule{source: &wasm.Module{
-				FunctionDefinitionSection: []wasm.FunctionDefinition{{}},
+				FunctionSection: []wasm.Index{0},
+				CodeSection:     []wasm.Code{{}},
+				TypeSection:     []wasm.FunctionType{{}},
 			}},
 		},
 	}
 
 	ce := &callEngine{
-		ctx: currentContext, stack: []uint64{0, 1, 2, 3, 4, 5},
+		stack:        []uint64{0, 1, 2, 3, 4, 5},
 		stackContext: stackContext{stackBasePointerInBytes: 40},
-		contextStack: []context.Context{prevContext},
 	}
-	ce.builtinFunctionFunctionListenerAfter(ce.ctx, &wasm.ModuleInstance{}, f)
-
-	// Contexts must be popped.
-	require.Equal(t, 0, len(ce.contextStack))
-	require.Equal(t, prevContext, ce.ctx)
+	ce.builtinFunctionFunctionListenerAfter(currentContext, &wasm.ModuleInstance{}, f)
 }
 
 type mockListener struct {
-	before func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) context.Context
-	after  func(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64)
+	before func(context.Context, api.Module, api.FunctionDefinition, []uint64, experimental.StackIterator)
+	after  func(context.Context, api.Module, api.FunctionDefinition, []uint64)
+	abort  func(context.Context, api.Module, api.FunctionDefinition, error)
+}
+
+func (m mockListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
+	if m.before != nil {
+		m.before(ctx, mod, def, params, stackIterator)
+	}
 }
 
-func (m mockListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) context.Context {
-	return m.before(ctx, mod, def, paramValues, stackIterator)
+func (m mockListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
+	if m.after != nil {
+		m.after(ctx, mod, def, results)
+	}
 }
 
-func (m mockListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64) {
-	m.after(ctx, mod, def, err, resultValues)
+func (m mockListener) Abort(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error) {
+	if m.abort != nil {
+		m.abort(ctx, mod, def, err)
+	}
 }
 
 func TestFunction_getSourceOffsetInWasmBinary(t *testing.T) {
diff --git a/internal/engine/interpreter/interpreter_test.go b/internal/engine/interpreter/interpreter_test.go
index 81a41f30..26efed33 100644
--- a/internal/engine/interpreter/interpreter_test.go
+++ b/internal/engine/interpreter/interpreter_test.go
@@ -514,7 +514,6 @@ func TestInterpreter_Compile(t *testing.T) {
 			},
 			ID: wasm.ModuleID{},
 		}
-		errModule.BuildFunctionDefinitions()
 
 		err := e.CompileModule(testCtx, errModule, nil, false)
 		require.EqualError(t, err, "handling instruction: apply stack failed for call: reading immediates: EOF")
diff --git a/internal/integration_test/bench/hostfunc_bench_test.go b/internal/integration_test/bench/hostfunc_bench_test.go
index e69c4bad..cd0a597c 100644
--- a/internal/integration_test/bench/hostfunc_bench_test.go
+++ b/internal/integration_test/bench/hostfunc_bench_test.go
@@ -172,12 +172,8 @@ func setupHostCallBench(requireNoError func(error)) *wasm.ModuleInstance {
 		},
 		ID: wasm.ModuleID{1, 2, 3, 4, 5},
 	}
-	hostModule.BuildFunctionDefinitions()
 
-	host := &wasm.ModuleInstance{
-		ModuleName: "host", TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: hostModule.FunctionDefinitionSection,
-	}
+	host := &wasm.ModuleInstance{ModuleName: "host", TypeIDs: []wasm.FunctionTypeID{0}}
 	host.Exports = hostModule.Exports
 
 	err := eng.CompileModule(testCtx, hostModule, nil, false)
@@ -214,14 +210,10 @@ func setupHostCallBench(requireNoError func(error)) *wasm.ModuleInstance {
 		ID:            wasm.ModuleID{1},
 	}
 
-	importingModule.BuildFunctionDefinitions()
 	err = eng.CompileModule(testCtx, importingModule, nil, false)
 	requireNoError(err)
 
-	importing := &wasm.ModuleInstance{
-		TypeIDs:     []wasm.FunctionTypeID{0},
-		Definitions: importingModule.FunctionDefinitionSection,
-	}
+	importing := &wasm.ModuleInstance{TypeIDs: []wasm.FunctionTypeID{0}}
 	importing.Exports = importingModule.Exports
 
 	importingMe, err := eng.NewModuleEngine(importingModule, importing)
diff --git a/internal/integration_test/spectest/spectest.go b/internal/integration_test/spectest/spectest.go
index 144a44a1..253e6fd0 100644
--- a/internal/integration_test/spectest/spectest.go
+++ b/internal/integration_test/spectest/spectest.go
@@ -329,7 +329,6 @@ func addSpectestModule(t *testing.T, ctx context.Context, s *wasm.Store, enabled
 
 	maybeSetMemoryCap(mod)
 	mod.BuildMemoryDefinitions()
-	mod.BuildFunctionDefinitions()
 
 	err = mod.Validate(enabledFeatures)
 	require.NoError(t, err)
@@ -403,7 +402,6 @@ func Run(t *testing.T, testDataFS embed.FS, ctx context.Context, fc filecache.Ca
 
 						maybeSetMemoryCap(mod)
 						mod.BuildMemoryDefinitions()
-						mod.BuildFunctionDefinitions()
 						err = s.Engine.CompileModule(ctx, mod, nil, false)
 						require.NoError(t, err, msg)
 
@@ -544,7 +542,6 @@ func Run(t *testing.T, testDataFS embed.FS, ctx context.Context, fc filecache.Ca
 							mod.AssignModuleID(buf, false, false)
 
 							maybeSetMemoryCap(mod)
-							mod.BuildFunctionDefinitions()
 							err = s.Engine.CompileModule(ctx, mod, nil, false)
 							require.NoError(t, err, msg)
 
@@ -581,7 +578,6 @@ func requireInstantiationError(t *testing.T, ctx context.Context, s *wasm.Store,
 
 	maybeSetMemoryCap(mod)
 	mod.BuildMemoryDefinitions()
-	mod.BuildFunctionDefinitions()
 	err = s.Engine.CompileModule(ctx, mod, nil, false)
 	if err != nil {
 		return
diff --git a/internal/platform/dir_test.go b/internal/platform/dir_test.go
index 77440fa8..fb450916 100644
--- a/internal/platform/dir_test.go
+++ b/internal/platform/dir_test.go
@@ -14,99 +14,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func TestReaddirnames(t *testing.T) {
-	t.Parallel()
-
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-	dirFS := os.DirFS(tmpDir)
-
-	tests := []struct {
-		name string
-		fs   fs.FS
-	}{
-		{name: "os.DirFS", fs: dirFS},         // To test readdirnamesFile
-		{name: "fstest.MapFS", fs: fstest.FS}, // To test adaptation of ReadDirFile
-	}
-
-	for _, tc := range tests {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			dotF, err := tc.fs.Open(".")
-			require.NoError(t, err)
-			defer dotF.Close()
-
-			t.Run("dir", func(t *testing.T) {
-				names, errno := platform.Readdirnames(dotF, -1)
-				require.EqualErrno(t, 0, errno)
-
-				sort.Strings(names)
-				require.Equal(t, []string{"animals.txt", "dir", "empty.txt", "emptydir", "sub"}, names)
-
-				// read again even though it is exhausted
-				_, errno = platform.Readdirnames(dotF, 100)
-				require.EqualErrno(t, 0, errno)
-			})
-
-			// Don't err if something else closed the directory while reading.
-			t.Run("closed dir", func(t *testing.T) {
-				require.NoError(t, dotF.Close())
-				_, errno := platform.Readdir(dotF, -1)
-				require.EqualErrno(t, 0, errno)
-			})
-
-			dirF, err := tc.fs.Open("dir")
-			require.NoError(t, err)
-			defer dirF.Close()
-
-			t.Run("partial", func(t *testing.T) {
-				names1, errno := platform.Readdirnames(dirF, 1)
-				require.EqualErrno(t, 0, errno)
-				require.Equal(t, 1, len(names1))
-
-				names2, errno := platform.Readdirnames(dirF, 1)
-				require.EqualErrno(t, 0, errno)
-				require.Equal(t, 1, len(names2))
-
-				// read exactly the last entry
-				names3, errno := platform.Readdirnames(dirF, 1)
-				require.EqualErrno(t, 0, errno)
-				require.Equal(t, 1, len(names3))
-
-				names := []string{names1[0], names2[0], names3[0]}
-				sort.Strings(names)
-
-				require.Equal(t, []string{"-", "a-", "ab-"}, names)
-
-				// no error reading an exhausted directory
-				_, errno = platform.Readdirnames(dirF, 1)
-				require.EqualErrno(t, 0, errno)
-			})
-
-			fileF, err := tc.fs.Open("empty.txt")
-			require.NoError(t, err)
-			defer fileF.Close()
-
-			t.Run("file", func(t *testing.T) {
-				_, errno := platform.Readdirnames(fileF, -1)
-				require.EqualErrno(t, syscall.ENOTDIR, errno)
-			})
-
-			subdirF, err := tc.fs.Open("sub")
-			require.NoError(t, err)
-			defer subdirF.Close()
-
-			t.Run("subdir", func(t *testing.T) {
-				names, errno := platform.Readdirnames(subdirF, -1)
-				require.EqualErrno(t, 0, errno)
-
-				require.Equal(t, []string{"test.txt"}, names)
-			})
-		})
-	}
-}
-
 func TestReaddir(t *testing.T) {
 	t.Parallel()
 
@@ -127,18 +34,24 @@ func TestReaddir(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			dotF, err := tc.fs.Open(".")
+			dF, err := tc.fs.Open(".")
 			require.NoError(t, err)
-			defer dotF.Close()
+			defer dF.Close()
+			dotF := platform.NewFsFile(".", 0, dF)
 
 			t.Run("dir", func(t *testing.T) {
-				dirents, errno := platform.Readdir(dotF, -1)
+				dirents, errno := dotF.Readdir(-1)
 				require.EqualErrno(t, 0, errno) // no io.EOF when -1 is used
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
 				requireIno(t, dirents, tc.expectIno)
 
-				require.Equal(t, []*platform.Dirent{
+				// Scrub inodes so we can compare expectations without them.
+				for i := range dirents {
+					dirents[i].Ino = 0
+				}
+
+				require.Equal(t, []platform.Dirent{
 					{Name: "animals.txt", Type: 0},
 					{Name: "dir", Type: fs.ModeDir},
 					{Name: "empty.txt", Type: 0},
@@ -147,67 +60,75 @@ func TestReaddir(t *testing.T) {
 				}, dirents)
 
 				// read again even though it is exhausted
-				dirents, errno = platform.Readdir(dotF, 100)
+				dirents, errno = dotF.Readdir(100)
 				require.EqualErrno(t, 0, errno)
 				require.Zero(t, len(dirents))
 			})
 
 			// Don't err if something else closed the directory while reading.
 			t.Run("closed dir", func(t *testing.T) {
-				require.NoError(t, dotF.Close())
-				_, errno := platform.Readdir(dotF, -1)
+				require.EqualErrno(t, 0, dotF.Close())
+				_, errno := dotF.Readdir(-1)
 				require.EqualErrno(t, 0, errno)
 			})
 
-			fileF, err := tc.fs.Open("empty.txt")
+			fF, err := tc.fs.Open("empty.txt")
 			require.NoError(t, err)
-			defer fileF.Close()
+			defer fF.Close()
+			fileF := platform.NewFsFile("empty.txt", 0, fF)
 
 			t.Run("file", func(t *testing.T) {
-				_, errno := platform.Readdir(fileF, -1)
+				_, errno := fileF.Readdir(-1)
 				require.EqualErrno(t, syscall.ENOTDIR, errno)
 			})
 
-			dirF, err := tc.fs.Open("dir")
+			dF, err = tc.fs.Open("dir")
 			require.NoError(t, err)
-			defer dirF.Close()
+			defer dF.Close()
+			dirF := platform.NewFsFile("dir", 0, dF)
 
 			t.Run("partial", func(t *testing.T) {
-				dirents1, errno := platform.Readdir(dirF, 1)
+				dirents1, errno := dirF.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 				require.Equal(t, 1, len(dirents1))
 
-				dirents2, errno := platform.Readdir(dirF, 1)
+				dirents2, errno := dirF.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 				require.Equal(t, 1, len(dirents2))
 
 				// read exactly the last entry
-				dirents3, errno := platform.Readdir(dirF, 1)
+				dirents3, errno := dirF.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 				require.Equal(t, 1, len(dirents3))
 
-				dirents := []*platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
+				dirents := []platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
 				requireIno(t, dirents, tc.expectIno)
 
-				require.Equal(t, []*platform.Dirent{
+				// Scrub inodes so we can compare expectations without them.
+				for i := range dirents {
+					dirents[i].Ino = 0
+				}
+
+				require.Equal(t, []platform.Dirent{
 					{Name: "-", Type: 0},
 					{Name: "a-", Type: fs.ModeDir},
 					{Name: "ab-", Type: 0},
 				}, dirents)
 
 				// no error reading an exhausted directory
-				_, errno = platform.Readdir(dirF, 1)
+				_, errno = dirF.Readdir(1)
 				require.EqualErrno(t, 0, errno)
 			})
 
-			subdirF, err := tc.fs.Open("sub")
+			sF, err := tc.fs.Open("sub")
 			require.NoError(t, err)
-			defer subdirF.Close()
+			defer sF.Close()
+			subdirF := platform.NewFsFile("sub", 0, sF)
 
 			t.Run("subdir", func(t *testing.T) {
-				dirents, errno := platform.Readdir(subdirF, -1)
+				dirents, errno := subdirF.Readdir(-1)
 				require.EqualErrno(t, 0, errno)
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
@@ -220,11 +141,12 @@ func TestReaddir(t *testing.T) {
 
 	// Don't err if something else removed the directory while reading.
 	t.Run("removed while open", func(t *testing.T) {
-		dirF, err := dirFS.Open("dir")
+		dF, err := dirFS.Open("dir")
 		require.NoError(t, err)
-		defer dirF.Close()
+		defer dF.Close()
+		dirF := platform.NewFsFile("dir", 0, dF)
 
-		dirents, errno := platform.Readdir(dirF, 1)
+		dirents, errno := dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents))
 
@@ -237,13 +159,13 @@ func TestReaddir(t *testing.T) {
 			require.NoError(t, err)
 		}
 
-		_, errno = platform.Readdir(dirF, 1)
+		_, errno = dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 		// don't validate the contents as due to caching it might be present.
 	})
 }
 
-func requireIno(t *testing.T, dirents []*platform.Dirent, expectIno bool) {
+func requireIno(t *testing.T, dirents []platform.Dirent, expectIno bool) {
 	for _, e := range dirents {
 		if expectIno {
 			require.NotEqual(t, uint64(0), e.Ino, "%+v", e)
diff --git a/internal/platform/file_test.go b/internal/platform/file_test.go
index bad8fed1..9e125182 100644
--- a/internal/platform/file_test.go
+++ b/internal/platform/file_test.go
@@ -10,6 +10,7 @@ import (
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
+	"time"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -36,19 +37,34 @@ func (NoopFile) AccessMode() int {
 // However, we could change UnimplementedFile to return zero.
 func (NoopFile) Close() (errno syscall.Errno) { return }
 
-// Once File.File is removed, it will be possible to implement NoopFile.
-func (NoopFile) File() fs.File { panic("noop") }
-
 //go:embed file_test.go
 var embedFS embed.FS
 
 var (
 	//go:embed testdata
-	testdata  embed.FS
-	readFile  = "wazero.txt"
-	emptyFile = "empty.txt"
+	testdata   embed.FS
+	wazeroFile = "wazero.txt"
+	emptyFile  = "empty.txt"
 )
 
+func TestFsFileSetNonblock(t *testing.T) {
+	// Test using os.Pipe as it is known to support non-blocking reads.
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	defer r.Close()
+	defer w.Close()
+
+	rF := NewFsFile(wazeroFile, syscall.O_RDONLY, r)
+
+	errno := rF.SetNonblock(true)
+	require.EqualErrno(t, 0, errno)
+	require.True(t, rF.IsNonblock())
+
+	errno = rF.SetNonblock(false)
+	require.EqualErrno(t, 0, errno)
+	require.False(t, rF.IsNonblock())
+}
+
 func TestFsFileIsDir(t *testing.T) {
 	dirFS, embedFS, mapFS := dirEmbedMapFS(t, t.TempDir())
 
@@ -66,11 +82,11 @@ func TestFsFileIsDir(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			t.Run("file", func(t *testing.T) {
-				f, err := tc.fs.Open(readFile)
+				f, err := tc.fs.Open(wazeroFile)
 				require.NoError(t, err)
 				defer f.Close()
 
-				fsF := NewFsFile(readFile, syscall.O_RDONLY, f)
+				fsF := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 				isDir, errno := fsF.IsDir()
 				require.EqualErrno(t, 0, errno)
@@ -83,7 +99,7 @@ func TestFsFileIsDir(t *testing.T) {
 				require.NoError(t, err)
 				defer f.Close()
 
-				fsF := NewFsFile(readFile, syscall.O_RDONLY, f)
+				fsF := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 				isDir, errno := fsF.IsDir()
 				require.EqualErrno(t, 0, errno)
@@ -112,11 +128,11 @@ func TestFsFileReadAndPread(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			f, err := tc.fs.Open(readFile)
+			f, err := tc.fs.Open(wazeroFile)
 			require.NoError(t, err)
 			defer f.Close()
 
-			fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+			fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 			// The file should be readable (base case)
 			requireRead(t, fs, buf)
@@ -140,6 +156,43 @@ func TestFsFileReadAndPread(t *testing.T) {
 	}
 }
 
+func TestFsFilePollRead(t *testing.T) {
+	// Test using os.Pipe as it is known to support poll.
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	defer r.Close()
+	defer w.Close()
+
+	rF := NewFsFile(wazeroFile, syscall.O_RDONLY, r)
+	buf := make([]byte, 10)
+	timeout := time.Duration(0) // return immediately
+
+	// When there's nothing in the pipe, it isn't ready.
+	ready, errno := rF.PollRead(&timeout)
+	if runtime.GOOS == "windows" {
+		require.EqualErrno(t, syscall.ENOSYS, errno)
+		t.Skip("TODO: windows File.PollRead")
+	}
+	require.EqualErrno(t, 0, errno)
+	require.False(t, ready)
+
+	// Write to the pipe to make the data available
+	expected := []byte("wazero")
+	_, err = w.Write([]byte("wazero"))
+	require.NoError(t, err)
+
+	// We should now be able to poll ready
+	ready, errno = rF.PollRead(&timeout)
+	require.EqualErrno(t, 0, errno)
+	require.True(t, ready)
+
+	// We should now be able to read from the pipe
+	n, errno := rF.Read(buf)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(expected), n)
+	require.Equal(t, expected, buf[:len(expected)])
+}
+
 func requireRead(t *testing.T, f File, buf []byte) {
 	n, errno := f.Read(buf)
 	require.EqualErrno(t, 0, errno)
@@ -174,7 +227,7 @@ func TestFsFileRead_empty(t *testing.T) {
 			require.NoError(t, err)
 			defer f.Close()
 
-			fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+			fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 			t.Run("Read", func(t *testing.T) {
 				// We should be able to read an empty file
@@ -203,7 +256,7 @@ func TestFsFilePread_Unsupported(t *testing.T) {
 	// mask both io.ReaderAt and io.Seeker
 	f = struct{ fs.File }{f}
 
-	fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+	fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 	buf := make([]byte, 3)
 	_, errno := fs.Pread(buf, 0)
@@ -269,11 +322,11 @@ func TestFsFileSeek(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			f, err := tc.fs.Open(readFile)
+			f, err := tc.fs.Open(wazeroFile)
 			require.NoError(t, err)
 			defer f.Close()
 
-			fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+			fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 			// Shouldn't be able to use an invalid whence
 			_, errno := fs.Seek(0, io.SeekEnd+1)
@@ -349,7 +402,7 @@ func TestFsFileSeek_empty(t *testing.T) {
 			require.NoError(t, err)
 			defer f.Close()
 
-			fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+			fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 			t.Run("Start", func(t *testing.T) {
 				require.Zero(t, requireSeek(t, fs, 0, io.SeekStart))
@@ -377,7 +430,7 @@ func TestFsFileSeek_Unsupported(t *testing.T) {
 	// mask io.Seeker
 	f = struct{ fs.File }{f}
 
-	fs := NewFsFile(readFile, syscall.O_RDONLY, f)
+	fs := NewFsFile(wazeroFile, syscall.O_RDONLY, f)
 
 	_, errno := fs.Seek(0, io.SeekCurrent)
 	require.EqualErrno(t, syscall.ENOSYS, errno)
@@ -386,7 +439,7 @@ func TestFsFileSeek_Unsupported(t *testing.T) {
 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(), readFile)
+	path := path.Join(t.TempDir(), wazeroFile)
 	f := openFsFile(t, path, syscall.O_RDWR|os.O_CREATE, 0o600)
 	defer f.Close()
 
@@ -480,7 +533,7 @@ func TestFsFileWrite_Unsupported(t *testing.T) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
-	f, err := embedFS.Open(readFile)
+	f, err := embedFS.Open(wazeroFile)
 	require.NoError(t, err)
 	defer f.Close()
 
@@ -503,7 +556,7 @@ func TestFsFileWrite_Unsupported(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			// Use syscall.O_RDWR so that it fails due to type not flags
-			f := NewFsFile(readFile, syscall.O_RDWR, f)
+			f := NewFsFile(wazeroFile, syscall.O_RDWR, f)
 			_, errno := tc.fn(f, buf)
 			require.EqualErrno(t, syscall.ENOSYS, errno)
 		})
@@ -591,7 +644,7 @@ func testSync_NoError(t *testing.T, sync func(File) syscall.Errno) {
 	for _, tt := range tests {
 		tc := tt
 
-		t.Run(tc.name, func(b *testing.T) {
+		t.Run(tc.name, func(t *testing.T) {
 			require.EqualErrno(t, 0, sync(tc.f))
 		})
 	}
@@ -744,6 +797,85 @@ func TestFsFileUtimens(t *testing.T) {
 	})
 }
 
+func TestNewStdioFile(t *testing.T) {
+	// simulate regular file attached to stdin
+	f, err := os.CreateTemp(t.TempDir(), "somefile")
+	require.NoError(t, err)
+	defer f.Close()
+
+	stdin, err := NewStdioFile(true, os.Stdin)
+	require.NoError(t, err)
+	stdinStat, err := os.Stdin.Stat()
+	require.NoError(t, err)
+
+	stdinFile, err := NewStdioFile(true, f)
+	require.NoError(t, err)
+
+	stdout, err := NewStdioFile(false, os.Stdout)
+	require.NoError(t, err)
+	stdoutStat, err := os.Stdout.Stat()
+	require.NoError(t, err)
+
+	stdoutFile, err := NewStdioFile(false, f)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name string
+		f    File
+		// Depending on how the tests run, os.Stdin won't necessarily be a char
+		// device. We compare against an os.File, to account for this.
+		expectedType fs.FileMode
+	}{
+		{
+			name:         "stdin",
+			f:            stdin,
+			expectedType: stdinStat.Mode().Type(),
+		},
+		{
+			name:         "stdin file",
+			f:            stdinFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stdout",
+			f:            stdout,
+			expectedType: stdoutStat.Mode().Type(),
+		},
+		{
+			name:         "stdout file",
+			f:            stdoutFile,
+			expectedType: 0, // normal file
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name+" Stat", func(t *testing.T) {
+			st, errno := tc.f.Stat()
+			require.EqualErrno(t, 0, errno)
+			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
+			require.Equal(t, uint64(1), st.Nlink)
+
+			// Fake times are needed to pass wasi-testsuite.
+			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+			require.Zero(t, st.Ctim)
+			require.Zero(t, st.Mtim)
+			require.Zero(t, st.Atim)
+		})
+
+		t.Run(tc.name+" AccessMode", func(t *testing.T) {
+			accessMode := tc.f.AccessMode()
+			switch tc.f {
+			case stdin, stdinFile:
+				require.Equal(t, syscall.O_RDONLY, accessMode)
+			case stdout, stdoutFile:
+				require.Equal(t, syscall.O_WRONLY, accessMode)
+			}
+		})
+	}
+}
+
 func testEBADFIfDirClosed(t *testing.T, fn func(File) syscall.Errno) bool {
 	return t.Run("EBADF if dir closed", func(t *testing.T) {
 		d := openFsFile(t, t.TempDir(), syscall.O_RDONLY, 0o755)
@@ -792,7 +924,7 @@ func dirEmbedMapFS(t *testing.T, tmpDir string) (fs.FS, fs.FS, fs.FS) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
-	f, err := embedFS.Open(readFile)
+	f, err := embedFS.Open(wazeroFile)
 	require.NoError(t, err)
 	defer f.Close()
 
@@ -800,14 +932,14 @@ func dirEmbedMapFS(t *testing.T, tmpDir string) (fs.FS, fs.FS, fs.FS) {
 	require.NoError(t, err)
 
 	mapFS := gofstest.MapFS{
-		emptyFile: &gofstest.MapFile{},
-		readFile:  &gofstest.MapFile{Data: bytes},
+		emptyFile:  &gofstest.MapFile{},
+		wazeroFile: &gofstest.MapFile{Data: bytes},
 	}
 
 	// Write a file as can't open "testdata" in scratch tests because they
 	// can't read the original filesystem.
 	require.NoError(t, os.WriteFile(path.Join(tmpDir, emptyFile), nil, 0o600))
-	require.NoError(t, os.WriteFile(path.Join(tmpDir, readFile), bytes, 0o600))
+	require.NoError(t, os.WriteFile(path.Join(tmpDir, wazeroFile), bytes, 0o600))
 	dirFS := os.DirFS(tmpDir)
 	return dirFS, embedFS, mapFS
 }
diff --git a/internal/platform/select_test.go b/internal/platform/select_test.go
index 4a1ebe7e..fff86461 100644
--- a/internal/platform/select_test.go
+++ b/internal/platform/select_test.go
@@ -14,7 +14,7 @@ func TestSelect(t *testing.T) {
 	t.Run("should return immediately with no fds and duration 0", func(t *testing.T) {
 		for {
 			dur := time.Duration(0)
-			n, err := Select(0, nil, nil, nil, &dur)
+			n, err := _select(0, nil, nil, nil, &dur)
 			if err == syscall.EINTR {
 				t.Logf("Select interrupted")
 				continue
@@ -33,7 +33,7 @@ func TestSelect(t *testing.T) {
 			// updated by select(2). We are not accounting for this
 			// in our implementation.
 			start := time.Now()
-			n, err := Select(0, nil, nil, nil, &dur)
+			n, err := _select(0, nil, nil, nil, &dur)
 			took = time.Since(start)
 			if err == syscall.EINTR {
 				t.Logf("Select interrupted after %v", took)
@@ -73,7 +73,7 @@ func TestSelect(t *testing.T) {
 		rFdSet.Set(fd)
 
 		for {
-			n, err := Select(fd+1, rFdSet, nil, nil, nil)
+			n, err := _select(fd+1, rFdSet, nil, nil, nil)
 			if runtime.GOOS == "windows" {
 				// Not implemented for fds != wasiFdStdin
 				require.ErrorIs(t, err, syscall.ENOSYS)
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index d4d34beb..59839630 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -3,9 +3,7 @@ package sys
 import (
 	"embed"
 	"errors"
-	"io"
 	"io/fs"
-	"log"
 	"os"
 	"path"
 	"syscall"
@@ -18,12 +16,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-var (
-	noopStdin  = &FileEntry{Name: "stdin", File: platform.NewFsFile("", syscall.O_RDONLY, NewStdioFileReader(eofReader{}, noopStdinStat, PollerDefaultStdin))}
-	noopStdout = &FileEntry{Name: "stdout", File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: io.Discard, s: noopStdoutStat})}
-	noopStderr = &FileEntry{Name: "stderr", File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: io.Discard, s: noopStderrStat})}
-)
-
 //go:embed testdata
 var testdata embed.FS
 
@@ -61,7 +53,7 @@ func TestNewFSContext(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 
-		t.Run(tc.name, func(b *testing.T) {
+		t.Run(tc.name, func(t *testing.T) {
 			c := Context{}
 			err := c.NewFSContext(nil, nil, nil, tc.fs)
 			require.NoError(t, err)
@@ -139,8 +131,11 @@ func TestUnimplementedFSContext(t *testing.T) {
 	require.NoError(t, err)
 
 	expected := &FSContext{rootFS: sysfs.UnimplementedFS{}}
+	noopStdin, _ := stdinFile(nil)
 	expected.openedFiles.Insert(noopStdin)
+	noopStdout, _ := stdioWriterFile("stdout", nil)
 	expected.openedFiles.Insert(noopStdout)
+	noopStderr, _ := stdioWriterFile("stderr", nil)
 	expected.openedFiles.Insert(noopStderr)
 
 	t.Run("Close closes", func(t *testing.T) {
@@ -254,7 +249,7 @@ func TestFSContext_ReOpenDir(t *testing.T) {
 		require.True(t, ok)
 
 		// Set arbitrary state.
-		ent.ReadDir = &ReadDir{Dirents: make([]*platform.Dirent, 10), CountRead: 12345}
+		ent.ReadDir = &ReadDir{Dirents: make([]platform.Dirent, 10), CountRead: 12345}
 
 		// Then reopen the same file descriptor.
 		ent, errno = fsc.ReOpenDir(dirFd)
@@ -367,55 +362,131 @@ func TestFSContext_ChangeOpenFlag(t *testing.T) {
 	require.Equal(t, f2.openFlag&syscall.O_APPEND, 0)
 }
 
-func TestStdioStat(t *testing.T) {
-	stat, err := stdioStat(os.Stdin, noopStdinStat)
+func TestStdio(t *testing.T) {
+	// simulate regular file attached to stdin
+	f, err := os.CreateTemp(t.TempDir(), "somefile")
 	require.NoError(t, err)
-	stdinStatMode := stat.Mode()
-	// ensure we are consistent with sys stdin
-	osStdinStat, _ := os.Stdin.Stat()
-	osStdinMode := osStdinStat.Mode().Type()
-	require.Equal(t, osStdinMode&fs.ModeDevice, stdinStatMode&fs.ModeDevice)
-	require.Equal(t, osStdinMode&fs.ModeCharDevice, stdinStatMode&fs.ModeCharDevice)
-
-	stat, err = stdioStat(os.Stdout, noopStdoutStat)
-	stdoutStatMode := stat.Mode()
+	defer f.Close()
+
+	stdin, err := stdinFile(os.Stdin)
+	require.NoError(t, err)
+	stdinStat, err := os.Stdin.Stat()
 	require.NoError(t, err)
-	// ensure we are consistent with sys stdout
-	osStdoutStat, _ := os.Stdout.Stat()
-	osStdoutMode := osStdoutStat.Mode().Type()
-	require.Equal(t, osStdoutMode&fs.ModeDevice, stdoutStatMode&fs.ModeDevice)
-	require.Equal(t, osStdoutMode&fs.ModeCharDevice, stdoutStatMode&fs.ModeCharDevice)
 
-	stat, err = stdioStat(os.Stderr, noopStderrStat)
+	stdinNil, err := stdinFile(nil)
 	require.NoError(t, err)
-	stderrStatMode := stat.Mode()
-	// ensure we are consistent with sys stderr
-	osStderrStat, _ := os.Stderr.Stat()
-	osStderrMode := osStderrStat.Mode().Type()
-	require.Equal(t, osStderrMode&fs.ModeDevice, stderrStatMode&fs.ModeDevice)
-	require.Equal(t, osStderrMode&fs.ModeCharDevice, stderrStatMode&fs.ModeCharDevice)
 
-	// simulate regular file attached to stdin
-	f, err := os.CreateTemp("", "somefile")
-	if err != nil {
-		log.Fatal(err)
-	}
-	defer os.Remove(f.Name()) // clean up
-	stat, err = stdioStat(f, noopStdinStat)
+	stdinFile, err := stdinFile(f)
 	require.NoError(t, err)
-	fStat := stat.Mode()
-	osFStat, _ := f.Stat()
-	osFStatMode := osFStat.Mode()
-	require.Equal(t, osFStatMode&fs.ModeDevice, fStat&fs.ModeDevice)
-	require.Equal(t, osFStatMode&fs.ModeCharDevice, fStat&fs.ModeCharDevice)
-
-	// interface{} returns default
-	stat, err = stdioStat("whatevs", noopStdinStat)
+
+	stdout, err := stdioWriterFile("stdout", os.Stdout)
+	require.NoError(t, err)
+	stdoutStat, err := os.Stdout.Stat()
+	require.NoError(t, err)
+
+	stdoutNil, err := stdioWriterFile("stdout", nil)
+	require.NoError(t, err)
+
+	stdoutFile, err := stdioWriterFile("stdout", f)
 	require.NoError(t, err)
-	require.Equal(t, noopStdinStat, stat)
 
-	// nil *File returns err
-	var nilFile *os.File
-	_, err = stdioStat(nilFile, noopStdinStat)
-	require.Error(t, err)
+	stderr, err := stdioWriterFile("stderr", os.Stderr)
+	require.NoError(t, err)
+	stderrStat, err := os.Stderr.Stat()
+	require.NoError(t, err)
+
+	stderrNil, err := stdioWriterFile("stderr", nil)
+	require.NoError(t, err)
+
+	stderrFile, err := stdioWriterFile("stderr", f)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name string
+		f    *FileEntry
+		// Depending on how the tests run, os.Stdin won't necessarily be a char
+		// device. We compare against an os.File, to account for this.
+		expectedType fs.FileMode
+	}{
+		{
+			name:         "stdin",
+			f:            stdin,
+			expectedType: stdinStat.Mode().Type(),
+		},
+		{
+			name:         "stdin noop",
+			f:            stdinNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdin file",
+			f:            stdinFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stdout",
+			f:            stdout,
+			expectedType: stdoutStat.Mode().Type(),
+		},
+		{
+			name:         "stdout noop",
+			f:            stdoutNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdout file",
+			f:            stdoutFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stderr",
+			f:            stderr,
+			expectedType: stderrStat.Mode().Type(),
+		},
+		{
+			name:         "stderr noop",
+			f:            stderrNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stderr file",
+			f:            stderrFile,
+			expectedType: 0, // normal file
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name+" Stat", func(t *testing.T) {
+			st, errno := tc.f.Stat()
+			require.EqualErrno(t, 0, errno)
+			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
+			require.Equal(t, uint64(1), st.Nlink)
+
+			// Fake times are needed to pass wasi-testsuite.
+			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+			require.Zero(t, st.Ctim)
+			require.Zero(t, st.Mtim)
+			require.Zero(t, st.Atim)
+		})
+
+		buf := make([]byte, 5)
+		switch tc.f {
+		case stdinNil:
+			t.Run(tc.name+" returns zero on Read", func(t *testing.T) {
+				n, errno := tc.f.File.Read(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Zero(t, n) // like reading io.EOF
+			})
+		case stdoutNil, stderrNil:
+			// This is important because some code will loop forever attempting
+			// to write data. This happened in TestShortHash.
+			t.Run(tc.name+" returns length on Write", func(t *testing.T) {
+				n, errno := tc.f.File.Write(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Equal(t, len(buf), n) // like io.Discard
+			})
+		}
+	}
 }
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index 27e30e6b..24119687 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -53,17 +53,20 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, platform.FakeNanosleep, sysCtx.nanosleep)
 	require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource())
 
-	expectedOpenedFiles := FileTable{}
-	expectedOpenedFiles.Insert(noopStdin)
-	expectedOpenedFiles.Insert(noopStdout)
-	expectedOpenedFiles.Insert(noopStderr)
-	expectedOpenedFiles.Insert(&FileEntry{
+	expected := FileTable{}
+	noopStdin, _ := stdinFile(nil)
+	expected.Insert(noopStdin)
+	noopStdout, _ := stdioWriterFile("stdout", nil)
+	expected.Insert(noopStdout)
+	noopStderr, _ := stdioWriterFile("stderr", nil)
+	expected.Insert(noopStderr)
+	expected.Insert(&FileEntry{
 		IsPreopen: true,
 		Name:      "/",
 		FS:        testFS,
 		File:      &lazyDir{fs: testFS},
 	})
-	require.Equal(t, expectedOpenedFiles, sysCtx.FS().openedFiles)
+	require.Equal(t, expected, sysCtx.FS().openedFiles)
 }
 
 func TestFileEntryInode(t *testing.T) {
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index b81af5ee..054c9b84 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -167,33 +167,3 @@ func TestAdapt_HackedWrites(t *testing.T) {
 
 	testOpen_O_RDWR(t, tmpDir, testFS)
 }
-
-func TestAdapt_TestFS(t *testing.T) {
-	t.Parallel()
-
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-	dirFS := os.DirFS(tmpDir)
-
-	// TODO: We can't currently test embed.FS here because the source of
-	// fstest.FS are not real files.
-	tests := []struct {
-		name string
-		fs   fs.FS
-	}{
-		{name: "os.DirFS", fs: dirFS},
-		{name: "fstest.MapFS", fs: fstest.FS},
-	}
-
-	for _, tc := range tests {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			// Adapt a normal fs.FS to sysfs.FS
-			testFS := Adapt(tc.fs)
-
-			// Adapt it back to fs.FS and run the tests
-			require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
-		})
-	}
-}
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index dbbb0986..64af1e94 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -34,8 +34,8 @@ func TestNewDirFS(t *testing.T) {
 		testFS := NewDirFS(arg0)
 		d, errno := testFS.OpenFile(".", os.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
-		_, err := d.File().(fs.ReadDirFile).ReadDir(-1)
-		require.EqualErrno(t, syscall.ENOTDIR, platform.UnwrapOSError(err))
+		_, errno = d.Readdir(-1)
+		require.EqualErrno(t, syscall.ENOTDIR, errno)
 	})
 }
 
@@ -801,20 +801,6 @@ func TestDirFS_Truncate(t *testing.T) {
 	})
 }
 
-func TestDirFS_TestFS(t *testing.T) {
-	t.Parallel()
-
-	// Set up the test files
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-
-	// Create a writeable filesystem
-	testFS := NewDirFS(tmpDir)
-
-	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
-}
-
 // Test_fdReaddir_opened_file_written ensures that writing files to the already-opened directory
 // is visible. This is significant on Windows.
 // https://github.com/ziglang/zig/blob/2ccff5115454bab4898bae3de88f5619310bc5c1/lib/std/fs/test.zig#L156-L184
@@ -836,14 +822,11 @@ func Test_fdReaddir_opened_file_written(t *testing.T) {
 	require.NoError(t, err)
 	defer f.Close()
 
-	dir, ok := dirFile.File().(fs.ReadDirFile)
-	require.True(t, ok)
-
-	entries, err := dir.ReadDir(-1)
-	require.NoError(t, err)
+	dirents, errno := dirFile.Readdir(-1)
+	require.EqualErrno(t, 0, errno)
 
-	require.Equal(t, 1, len(entries))
-	require.Equal(t, "my-file", entries[0].Name())
+	require.Equal(t, 1, len(dirents))
+	require.Equal(t, "my-file", dirents[0].Name)
 }
 
 func TestDirFS_Link(t *testing.T) {
@@ -860,7 +843,7 @@ func TestDirFS_Link(t *testing.T) {
 	require.EqualErrno(t, testFS.Link("sub/test.txt", "."), syscall.EEXIST)
 	require.EqualErrno(t, testFS.Link("sub/test.txt", ""), syscall.EEXIST)
 	require.EqualErrno(t, testFS.Link("sub/test.txt", "/"), syscall.EEXIST)
-	require.Zero(t, testFS.Link("sub/test.txt", "foo"))
+	require.EqualErrno(t, 0, testFS.Link("sub/test.txt", "foo"))
 }
 
 func TestDirFS_Symlink(t *testing.T) {
@@ -872,10 +855,10 @@ func TestDirFS_Symlink(t *testing.T) {
 
 	testFS := NewDirFS(tmpDir)
 
-	require.EqualErrno(t, testFS.Symlink("sub/test.txt", "sub/test.txt"), syscall.EEXIST)
+	require.EqualErrno(t, syscall.EEXIST, testFS.Symlink("sub/test.txt", "sub/test.txt"))
 	// Non-existing old name is allowed.
-	require.Zero(t, testFS.Symlink("non-existing", "aa"))
-	require.Zero(t, testFS.Symlink("sub/", "symlinked-subdir"))
+	require.EqualErrno(t, 0, testFS.Symlink("non-existing", "aa"))
+	require.EqualErrno(t, 0, testFS.Symlink("sub/", "symlinked-subdir"))
 
 	st, err := os.Lstat(path.Join(tmpDir, "aa"))
 	require.NoError(t, err)
diff --git a/internal/sysfs/dirfs_unix_test.go b/internal/sysfs/dirfs_unix_test.go
index 3857a8ef..5bb48f10 100644
--- a/internal/sysfs/dirfs_unix_test.go
+++ b/internal/sysfs/dirfs_unix_test.go
@@ -20,10 +20,8 @@ func TestDirFS_Chown(t *testing.T) {
 	dirF, errno := testFS.OpenFile("dir", syscall.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
 
-	dirStat, err := dirF.File().Stat()
-	require.NoError(t, err)
-
-	dirSys := dirStat.Sys().(*syscall.Stat_t)
+	dirSt, errno := dirF.Stat()
+	require.EqualErrno(t, 0, errno)
 
 	// Similar to TestChown in os_unix_test.go, we can't expect to change
 	// owner unless root, and with another user. Instead, test gid.
@@ -33,12 +31,12 @@ func TestDirFS_Chown(t *testing.T) {
 
 	t.Run("-1 parameters means leave alone", func(t *testing.T) {
 		require.EqualErrno(t, 0, testFS.Chown("dir", -1, -1))
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, dirSys.Gid)
+		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
 	})
 
 	t.Run("change gid, but not uid", func(t *testing.T) {
 		require.EqualErrno(t, 0, testFS.Chown("dir", -1, gid))
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, uint32(gid))
+		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(gid))
 	})
 
 	// Now, try any other groups of the current user.
@@ -47,11 +45,11 @@ func TestDirFS_Chown(t *testing.T) {
 		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
 			// Test using our Chown
 			require.EqualErrno(t, 0, testFS.Chown("dir", -1, g))
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, uint32(g))
+			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(g))
 
 			// Revert back
 			require.EqualErrno(t, 0, dirF.Chown(-1, gid))
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, uint32(gid))
+			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(gid))
 		})
 	}
 
@@ -68,19 +66,15 @@ func TestDirFS_Lchown(t *testing.T) {
 	dirF, errno := testFS.OpenFile("dir", syscall.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
 
-	dirStat, err := dirF.File().Stat()
-	require.NoError(t, err)
-
-	dirSys := dirStat.Sys().(*syscall.Stat_t)
+	dirSt, errno := dirF.Stat()
+	require.EqualErrno(t, 0, errno)
 
 	require.EqualErrno(t, 0, testFS.Symlink("dir", "link"))
 	linkF, errno := testFS.OpenFile("link", syscall.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
 
-	linkStat, err := linkF.File().Stat()
-	require.NoError(t, err)
-
-	linkSys := linkStat.Sys().(*syscall.Stat_t)
+	linkSt, errno := linkF.Stat()
+	require.EqualErrno(t, 0, errno)
 
 	// Similar to TestLchown in os_unix_test.go, we can't expect to change
 	// owner unless root, and with another user. Instead, test gid.
@@ -90,14 +84,14 @@ func TestDirFS_Lchown(t *testing.T) {
 
 	t.Run("-1 parameters means leave alone", func(t *testing.T) {
 		require.EqualErrno(t, 0, testFS.Lchown("link", -1, -1))
-		checkUidGid(t, path.Join(tmpDir, "link"), linkSys.Uid, linkSys.Gid)
+		checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, linkSt.Gid)
 	})
 
 	t.Run("change gid, but not uid", func(t *testing.T) {
 		require.EqualErrno(t, 0, testFS.Chown("dir", -1, gid))
-		checkUidGid(t, path.Join(tmpDir, "link"), linkSys.Uid, uint32(gid))
+		checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(gid))
 		// Make sure the target didn't change.
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, dirSys.Gid)
+		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
 	})
 
 	// Now, try any other groups of the current user.
@@ -106,13 +100,13 @@ func TestDirFS_Lchown(t *testing.T) {
 		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
 			// Test using our Lchown
 			require.EqualErrno(t, 0, testFS.Lchown("link", -1, g))
-			checkUidGid(t, path.Join(tmpDir, "link"), linkSys.Uid, uint32(g))
+			checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(g))
 			// Make sure the target didn't change.
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSys.Uid, dirSys.Gid)
+			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
 
 			// Revert back
 			require.EqualErrno(t, 0, testFS.Lchown("link", -1, gid))
-			checkUidGid(t, path.Join(tmpDir, "link"), linkSys.Uid, uint32(gid))
+			checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(gid))
 		})
 	}
 
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 9ffa016b..8eb2f242 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -174,20 +174,3 @@ func TestReadFS_Readlink(t *testing.T) {
 	testFS := NewReadFS(writeable)
 	testReadlink(t, testFS, writeable)
 }
-
-func TestReadFS_TestFS(t *testing.T) {
-	t.Parallel()
-
-	// Set up the test files
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-
-	// Create a writeable filesystem
-	testFS := NewDirFS(tmpDir)
-
-	// Wrap it as read-only
-	testFS = NewReadFS(testFS)
-
-	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
-}
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
index 2bb7408e..270e494c 100644
--- a/internal/sysfs/rootfs_test.go
+++ b/internal/sysfs/rootfs_test.go
@@ -2,7 +2,6 @@ package sysfs
 
 import (
 	"errors"
-	"io"
 	"io/fs"
 	"os"
 	"path"
@@ -13,7 +12,6 @@ import (
 	gofstest "testing/fstest"
 
 	"github.com/tetratelabs/wazero/internal/fstest"
-	"github.com/tetratelabs/wazero/internal/platform"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -56,10 +54,10 @@ func TestNewRootFS(t *testing.T) {
 		f, errno = rootFS.OpenFile("/", os.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 
-		dirents, err := f.File().(fs.ReadDirFile).ReadDir(-1)
-		require.NoError(t, err)
+		dirents, errno := f.Readdir(-1)
+		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents))
-		require.Equal(t, "tmp", dirents[0].Name())
+		require.Equal(t, "tmp", dirents[0].Name)
 		require.True(t, dirents[0].IsDir())
 	})
 	t.Run("multiple roots unsupported", func(t *testing.T) {
@@ -100,8 +98,7 @@ func TestNewRootFS(t *testing.T) {
 			require.EqualErrno(t, 0, errno)
 			defer f.Close()
 
-			b, err := io.ReadAll(f.File())
-			require.NoError(t, err)
+			b := readAll(t, f)
 			require.Equal(t, []byte{2}, b)
 		})
 
@@ -111,18 +108,19 @@ func TestNewRootFS(t *testing.T) {
 			require.EqualErrno(t, 0, errno)
 			defer f.Close()
 
-			require.Equal(t, []string{"a", "tmp"}, readDirNames(t, f.File()))
+			entries, errno := f.Readdir(-1)
+			require.EqualErrno(t, 0, errno)
+			names := make([]string, 0, len(entries))
+			for _, e := range entries {
+				names = append(names, e.Name)
+			}
+			sort.Strings(names)
+
+			require.Equal(t, []string{"a", "tmp"}, names)
 		})
 	})
 }
 
-func readDirNames(t *testing.T, f fs.File) []string {
-	names, errno := platform.Readdirnames(f, -1)
-	require.EqualErrno(t, 0, errno)
-	sort.Strings(names)
-	return names
-}
-
 func TestRootFS_String(t *testing.T) {
 	tmpFS := NewDirFS(".")
 	rootFS := NewDirFS(".")
@@ -168,30 +166,6 @@ func TestRootFS_Stat(t *testing.T) {
 	testStat(t, testFS)
 }
 
-func TestRootFS_TestFS(t *testing.T) {
-	t.Parallel()
-
-	// Set up the test files
-	tmpDir1 := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir1))
-
-	// move one directory outside the other
-	tmpDir2 := t.TempDir()
-	require.NoError(t, os.Rename(path.Join(tmpDir1, "dir"), path.Join(tmpDir2, "dir")))
-
-	// Create a root mount
-	testFS1 := NewDirFS(tmpDir1)
-
-	// Create a dir mount
-	testFS2 := NewDirFS(path.Join(tmpDir2, "dir"))
-
-	testFS, err := NewRootFS([]FS{testFS1, testFS2}, []string{"/", "/dir"})
-	require.NoError(t, err)
-
-	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
-}
-
 func TestRootFS_examples(t *testing.T) {
 	tests := []struct {
 		name                 string
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 6d4f8a9b..16ca0e2d 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -81,9 +81,14 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 		require.EqualErrno(t, 0, errno)
 		defer f.Close()
 
-		dirents := requireReaddir(t, f.File(), -1, expectIno)
+		dirents := requireReaddir(t, f, -1, expectIno)
 
-		require.Equal(t, []*platform.Dirent{
+		// Scrub inodes so we can compare expectations without them.
+		for i := range dirents {
+			dirents[i].Ino = 0
+		}
+
+		require.Equal(t, []platform.Dirent{
 			{Name: "animals.txt", Type: 0},
 			{Name: "dir", Type: fs.ModeDir},
 			{Name: "empty.txt", Type: 0},
@@ -92,94 +97,51 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 		}, dirents)
 	})
 
-	t.Run("readdirnames . opens root", func(t *testing.T) {
-		f, errno := testFS.OpenFile(".", os.O_RDONLY, 0)
-		require.EqualErrno(t, 0, errno)
-		defer f.Close()
-
-		names := requireReaddirnames(t, f.File(), -1)
-		require.Equal(t, []string{"animals.txt", "dir", "empty.txt", "emptydir", "sub"}, names)
-	})
-
 	t.Run("readdir empty", func(t *testing.T) {
 		f, errno := testFS.OpenFile("emptydir", os.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 		defer f.Close()
 
-		entries := requireReaddir(t, f.File(), -1, expectIno)
+		entries := requireReaddir(t, f, -1, expectIno)
 		require.Zero(t, len(entries))
 	})
 
-	t.Run("readdirnames empty", func(t *testing.T) {
-		f, errno := testFS.OpenFile("emptydir", os.O_RDONLY, 0)
-		require.EqualErrno(t, 0, errno)
-		defer f.Close()
-
-		names := requireReaddirnames(t, f.File(), -1)
-		require.Zero(t, len(names))
-	})
-
 	t.Run("readdir partial", func(t *testing.T) {
 		dirF, errno := testFS.OpenFile("dir", os.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 		defer dirF.Close()
 
-		dirents1, errno := platform.Readdir(dirF.File(), 1)
+		dirents1, errno := dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents1))
 
-		dirents2, errno := platform.Readdir(dirF.File(), 1)
+		dirents2, errno := dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents2))
 
 		// read exactly the last entry
-		dirents3, errno := platform.Readdir(dirF.File(), 1)
+		dirents3, errno := dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents3))
 
-		dirents := []*platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
+		dirents := []platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
 		sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
 		requireIno(t, dirents, expectIno)
 
-		require.Equal(t, []*platform.Dirent{
+		// Scrub inodes so we can compare expectations without them.
+		for i := range dirents {
+			dirents[i].Ino = 0
+		}
+
+		require.Equal(t, []platform.Dirent{
 			{Name: "-", Type: 0},
 			{Name: "a-", Type: fs.ModeDir},
 			{Name: "ab-", Type: 0},
 		}, dirents)
 
 		// no error reading an exhausted directory
-		_, errno = platform.Readdir(dirF.File(), 1)
-		require.EqualErrno(t, 0, errno)
-	})
-
-	// TODO: consolidate duplicated tests from platform once we have our own
-	// file type
-	t.Run("readdirnames partial", func(t *testing.T) {
-		dirF, errno := testFS.OpenFile("dir", os.O_RDONLY, 0)
-		require.EqualErrno(t, 0, errno)
-		defer dirF.Close()
-
-		names1, errno := platform.Readdirnames(dirF.File(), 1)
-		require.EqualErrno(t, 0, errno)
-		require.Equal(t, 1, len(names1))
-
-		names2, errno := platform.Readdirnames(dirF.File(), 1)
-		require.EqualErrno(t, 0, errno)
-		require.Equal(t, 1, len(names2))
-
-		// read exactly the last entry
-		names3, errno := platform.Readdirnames(dirF.File(), 1)
-		require.EqualErrno(t, 0, errno)
-		require.Equal(t, 1, len(names3))
-
-		names := []string{names1[0], names2[0], names3[0]}
-		sort.Strings(names)
-
-		require.Equal(t, []string{"-", "a-", "ab-"}, names)
-
-		// no error reading an exhausted directory
-		_, errno = platform.Readdirnames(dirF.File(), 1)
+		_, errno = dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 	})
 
@@ -337,31 +299,26 @@ func testStat(t *testing.T, testFS FS) {
 	}
 }
 
+func readAll(t *testing.T, f platform.File) []byte {
+	st, errno := f.Stat()
+	require.EqualErrno(t, 0, errno)
+	buf := make([]byte, st.Size)
+	_, errno = f.Read(buf)
+	require.EqualErrno(t, 0, errno)
+	return buf
+}
+
 // requireReaddir ensures the input file is a directory, and returns its
 // entries.
-func requireReaddir(t *testing.T, f fs.File, n int, expectIno bool) []*platform.Dirent {
-	entries, errno := platform.Readdir(f, n)
+func requireReaddir(t *testing.T, f platform.File, n int, expectIno bool) []platform.Dirent {
+	entries, errno := f.Readdir(n)
 	require.EqualErrno(t, 0, errno)
 
 	sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
-	if _, ok := f.(*openRootDir); ok {
-		// TODO: get inodes to work on the root directory of a composite FS
-		requireIno(t, entries, false)
-	} else {
-		requireIno(t, entries, expectIno)
-	}
+	requireIno(t, entries, expectIno)
 	return entries
 }
 
-// requireReaddirnames ensures the input file is a directory, and returns its
-// entries.
-func requireReaddirnames(t *testing.T, f fs.File, n int) []string {
-	names, errno := platform.Readdirnames(f, n)
-	require.EqualErrno(t, 0, errno)
-	sort.Strings(names)
-	return names
-}
-
 func testReadlink(t *testing.T, readFS, writeFS FS) {
 	testLinks := []struct {
 		old, dst string
@@ -394,13 +351,14 @@ func testReadlink(t *testing.T, readFS, writeFS FS) {
 	})
 }
 
-func requireIno(t *testing.T, dirents []*platform.Dirent, expectIno bool) {
-	for _, e := range dirents {
+func requireIno(t *testing.T, dirents []platform.Dirent, expectIno bool) {
+	for i := range dirents {
+		d := dirents[i]
 		if expectIno {
-			require.NotEqual(t, uint64(0), e.Ino, "%+v", e)
-			e.Ino = 0
+			require.NotEqual(t, uint64(0), d.Ino, "%+v", d)
+			d.Ino = 0
 		} else {
-			require.Zero(t, e.Ino, "%+v", e)
+			require.Zero(t, d.Ino, "%+v", d)
 		}
 	}
 }
diff --git a/internal/testing/enginetest/enginetest.go b/internal/testing/enginetest/enginetest.go
index d74dae6a..b9dbbb77 100644
--- a/internal/testing/enginetest/enginetest.go
+++ b/internal/testing/enginetest/enginetest.go
@@ -109,7 +109,6 @@ func RunTestEngineMemoryGrowInRecursiveCall(t *testing.T, et EngineTester) {
 		ImportSection:   []wasm.Import{{Module: hostModuleName, Name: hostFnName, DescFunc: 0}},
 		ImportPerModule: map[string][]*wasm.Import{hostModuleName: {{Module: hostModuleName, Name: hostFnName, DescFunc: 0}}},
 	}
-	m.BuildFunctionDefinitions()
 	m.BuildMemoryDefinitions()
 
 	err = s.Engine.CompileModule(testCtx, m, nil, false)
@@ -155,16 +154,12 @@ func RunTestModuleEngineCall(t *testing.T, et EngineTester) {
 		},
 	}
 
-	m.BuildFunctionDefinitions()
 	listeners := buildFunctionListeners(et.ListenerFactory(), m)
 	err := e.CompileModule(testCtx, m, listeners, false)
 	require.NoError(t, err)
 
 	// To use the function, we first need to add it to a module.
-	module := &wasm.ModuleInstance{
-		ModuleName: t.Name(), TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: m.FunctionDefinitionSection,
-	}
+	module := &wasm.ModuleInstance{ModuleName: t.Name(), TypeIDs: []wasm.FunctionTypeID{0}}
 
 	// Compile the module
 	me, err := e.NewModuleEngine(m, module)
@@ -212,16 +207,12 @@ func RunTestModuleEngineCallWithStack(t *testing.T, et EngineTester) {
 		},
 	}
 
-	m.BuildFunctionDefinitions()
 	listeners := buildFunctionListeners(et.ListenerFactory(), m)
 	err := e.CompileModule(testCtx, m, listeners, false)
 	require.NoError(t, err)
 
 	// To use the function, we first need to add it to a module.
-	module := &wasm.ModuleInstance{
-		ModuleName: t.Name(), TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: m.FunctionDefinitionSection,
-	}
+	module := &wasm.ModuleInstance{ModuleName: t.Name(), TypeIDs: []wasm.FunctionTypeID{0}}
 
 	// Compile the module
 	me, err := e.NewModuleEngine(m, module)
@@ -257,7 +248,6 @@ func RunTestModuleEngineLookupFunction(t *testing.T, et EngineTester) {
 		},
 	}
 
-	mod.BuildFunctionDefinitions()
 	err := e.CompileModule(testCtx, mod, nil, false)
 	require.NoError(t, err)
 	m := &wasm.ModuleInstance{
@@ -556,18 +546,17 @@ func RunTestModuleEngineBeforeListenerStackIterator(t *testing.T, et EngineTeste
 	}
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, si experimental.StackIterator) context.Context {
+		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, si experimental.StackIterator) {
 			require.True(t, len(expectedCallstacks) > 0)
 			expectedCallstack := expectedCallstacks[0]
 			for si.Next() {
 				require.True(t, len(expectedCallstack) > 0)
-				require.Equal(t, expectedCallstack[0].debugName, si.FunctionDefinition().DebugName())
+				require.Equal(t, expectedCallstack[0].debugName, si.Function().Definition().DebugName())
 				require.Equal(t, expectedCallstack[0].args, si.Parameters())
 				expectedCallstack = expectedCallstack[1:]
 			}
 			require.Equal(t, 0, len(expectedCallstack))
 			expectedCallstacks = expectedCallstacks[1:]
-			return ctx
 		},
 	}
 
@@ -654,17 +643,15 @@ func RunTestModuleEngineBeforeListenerStackIterator(t *testing.T, et EngineTeste
 		},
 		ID: wasm.ModuleID{0},
 	}
-	m.BuildFunctionDefinitions()
 
 	listeners := buildFunctionListeners(fnListener, m)
 	err := e.CompileModule(testCtx, m, listeners, false)
 	require.NoError(t, err)
 
 	module := &wasm.ModuleInstance{
-		ModuleName:  t.Name(),
-		TypeIDs:     []wasm.FunctionTypeID{0, 1, 2, 3},
-		Definitions: m.FunctionDefinitionSection,
-		Exports:     exportMap(m),
+		ModuleName: t.Name(),
+		TypeIDs:    []wasm.FunctionTypeID{0, 1, 2, 3},
+		Exports:    exportMap(m),
 	}
 
 	me, err := e.NewModuleEngine(m, module)
@@ -693,7 +680,7 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 	}
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, si experimental.StackIterator) context.Context {
+		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, si experimental.StackIterator) {
 			require.True(t, len(expectedGlobals) > 0)
 
 			imod := mod.(experimental.InternalModule)
@@ -707,7 +694,6 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 			}
 
 			expectedGlobals = expectedGlobals[1:]
-			return ctx
 		},
 	}
 
@@ -775,17 +761,15 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 		},
 		ID: wasm.ModuleID{0},
 	}
-	m.BuildFunctionDefinitions()
 
 	listeners := buildFunctionListeners(fnListener, m)
 	err := e.CompileModule(testCtx, m, listeners, false)
 	require.NoError(t, err)
 
 	module := &wasm.ModuleInstance{
-		ModuleName:  t.Name(),
-		TypeIDs:     []wasm.FunctionTypeID{0, 1, 2, 3},
-		Definitions: m.FunctionDefinitionSection,
-		Exports:     exportMap(m),
+		ModuleName: t.Name(),
+		TypeIDs:    []wasm.FunctionTypeID{0, 1, 2, 3},
+		Exports:    exportMap(m),
 		Globals: []*wasm.GlobalInstance{
 			{Val: 100, Type: wasm.GlobalType{ValType: wasm.ValueTypeI32, Mutable: true}},
 			{Val: 200, Type: wasm.GlobalType{ValType: wasm.ValueTypeI32, Mutable: true}},
@@ -803,24 +787,30 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 }
 
 type fnListener struct {
-	beforeFn func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) context.Context
-	afterFn  func(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64)
+	beforeFn func(context.Context, api.Module, api.FunctionDefinition, []uint64, experimental.StackIterator)
+	afterFn  func(context.Context, api.Module, api.FunctionDefinition, []uint64)
+	abortFn  func(context.Context, api.Module, api.FunctionDefinition, any)
 }
 
 func (f *fnListener) NewFunctionListener(api.FunctionDefinition) experimental.FunctionListener {
 	return f
 }
 
-func (f *fnListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, stackIterator experimental.StackIterator) context.Context {
+func (f *fnListener) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) {
 	if f.beforeFn != nil {
-		return f.beforeFn(ctx, mod, def, paramValues, stackIterator)
+		f.beforeFn(ctx, mod, def, params, stackIterator)
 	}
-	return ctx
 }
 
-func (f *fnListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error, resultValues []uint64) {
+func (f *fnListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
 	if f.afterFn != nil {
-		f.afterFn(ctx, mod, def, err, resultValues)
+		f.afterFn(ctx, mod, def, results)
+	}
+}
+
+func (f *fnListener) Abort(ctx context.Context, mod api.Module, def api.FunctionDefinition, err error) {
+	if f.abortFn != nil {
+		f.abortFn(ctx, mod, def, err)
 	}
 }
 
@@ -835,13 +825,14 @@ func RunTestModuleEngineStackIteratorOffset(t *testing.T, et EngineTester) {
 	var tape [][]frame
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, paramValues []uint64, si experimental.StackIterator) context.Context {
+		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, si experimental.StackIterator) {
 			var stack []frame
 			for si.Next() {
-				stack = append(stack, frame{si.FunctionDefinition(), si.SourceOffset()})
+				fn := si.Function()
+				pc := si.ProgramCounter()
+				stack = append(stack, frame{fn.Definition(), fn.SourceOffsetForPC(pc)})
 			}
 			tape = append(tape, stack)
-			return ctx
 		},
 	}
 
@@ -948,21 +939,19 @@ func RunTestModuleEngineStackIteratorOffset(t *testing.T, et EngineTester) {
 	m.CodeSection[0].BodyOffsetInCodeSection = f1offset
 	m.CodeSection[1].BodyOffsetInCodeSection = f2offset
 
-	m.BuildFunctionDefinitions()
-
 	listeners := buildFunctionListeners(fnListener, m)
 	err = e.CompileModule(testCtx, m, listeners, false)
 	require.NoError(t, err)
 
 	module := &wasm.ModuleInstance{
-		ModuleName:  t.Name(),
-		TypeIDs:     []wasm.FunctionTypeID{0, 1, 2},
-		Definitions: m.FunctionDefinitionSection,
-		Exports:     exportMap(m),
+		ModuleName: t.Name(),
+		TypeIDs:    []wasm.FunctionTypeID{0, 1, 2},
+		Exports:    exportMap(m),
 		Globals: []*wasm.GlobalInstance{
 			{Val: 100, Type: wasm.GlobalType{ValType: wasm.ValueTypeI32, Mutable: true}},
 			{Val: 200, Type: wasm.GlobalType{ValType: wasm.ValueTypeI32, Mutable: true}},
 		},
+		Source: m,
 	}
 
 	me, err := e.NewModuleEngine(m, module)
@@ -1059,7 +1048,6 @@ func RunTestModuleEngineMemory(t *testing.T, et EngineTester) {
 			{Name: "init", Type: wasm.ExternTypeFunc, Index: 1},
 		},
 	}
-	m.BuildFunctionDefinitions()
 	listeners := buildFunctionListeners(et.ListenerFactory(), m)
 
 	err := e.CompileModule(testCtx, m, listeners, false)
@@ -1071,7 +1059,6 @@ func RunTestModuleEngineMemory(t *testing.T, et EngineTester) {
 		MemoryInstance: wasm.NewMemoryInstance(m.MemorySection),
 		DataInstances:  []wasm.DataInstance{m.DataSection[0].Init},
 		TypeIDs:        []wasm.FunctionTypeID{0, 1},
-		Definitions:    m.FunctionDefinitionSection,
 	}
 	memory := module.MemoryInstance
 
@@ -1191,14 +1178,10 @@ func setupCallTests(t *testing.T, e wasm.Engine, divBy *wasm.Code, fnlf experime
 		},
 		ID: wasm.ModuleID{0},
 	}
-	hostModule.BuildFunctionDefinitions()
 	lns := buildFunctionListeners(fnlf, hostModule)
 	err := e.CompileModule(testCtx, hostModule, lns, false)
 	require.NoError(t, err)
-	host := &wasm.ModuleInstance{
-		ModuleName: hostModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: hostModule.FunctionDefinitionSection,
-	}
+	host := &wasm.ModuleInstance{ModuleName: hostModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0}}
 	host.Exports = exportMap(hostModule)
 
 	hostME, err := e.NewModuleEngine(hostModule, host)
@@ -1228,14 +1211,12 @@ func setupCallTests(t *testing.T, e wasm.Engine, divBy *wasm.Code, fnlf experime
 		},
 		ID: wasm.ModuleID{1},
 	}
-	importedModule.BuildFunctionDefinitions()
 	lns = buildFunctionListeners(fnlf, importedModule)
 	err = e.CompileModule(testCtx, importedModule, lns, false)
 	require.NoError(t, err)
 
 	imported := &wasm.ModuleInstance{
 		ModuleName: importedModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: importedModule.FunctionDefinitionSection,
 	}
 	imported.Exports = exportMap(importedModule)
 
@@ -1263,16 +1244,12 @@ func setupCallTests(t *testing.T, e wasm.Engine, divBy *wasm.Code, fnlf experime
 		},
 		ID: wasm.ModuleID{2},
 	}
-	importingModule.BuildFunctionDefinitions()
 	lns = buildFunctionListeners(fnlf, importingModule)
 	err = e.CompileModule(testCtx, importingModule, lns, false)
 	require.NoError(t, err)
 
 	// Add the exported function.
-	importing := &wasm.ModuleInstance{
-		ModuleName: importingModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: importingModule.FunctionDefinitionSection,
-	}
+	importing := &wasm.ModuleInstance{ModuleName: importingModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0}}
 	importing.Exports = exportMap(importingModule)
 
 	// Compile the importing module
@@ -1299,13 +1276,9 @@ func setupCallMemTests(t *testing.T, e wasm.Engine, readMem *wasm.Code) *wasm.Mo
 		},
 		ID: wasm.ModuleID{0},
 	}
-	hostModule.BuildFunctionDefinitions()
 	err := e.CompileModule(testCtx, hostModule, nil, false)
 	require.NoError(t, err)
-	host := &wasm.ModuleInstance{
-		ModuleName: hostModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: hostModule.FunctionDefinitionSection,
-	}
+	host := &wasm.ModuleInstance{ModuleName: hostModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0}}
 	host.Exports = exportMap(hostModule)
 
 	hostMe, err := e.NewModuleEngine(hostModule, host)
@@ -1336,15 +1309,11 @@ func setupCallMemTests(t *testing.T, e wasm.Engine, readMem *wasm.Code) *wasm.Mo
 		MemorySection: &wasm.Memory{Min: 1},
 		ID:            wasm.ModuleID{1},
 	}
-	importingModule.BuildFunctionDefinitions()
 	err = e.CompileModule(testCtx, importingModule, nil, false)
 	require.NoError(t, err)
 
 	// Add the exported function.
-	importing := &wasm.ModuleInstance{
-		ModuleName: importingModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0},
-		Definitions: importingModule.FunctionDefinitionSection,
-	}
+	importing := &wasm.ModuleInstance{ModuleName: importingModule.NameSection.ModuleName, TypeIDs: []wasm.FunctionTypeID{0}}
 	// Note: adds imported functions readMemFn and callReadMemFn at index 0 and 1.
 	importing.Exports = exportMap(importingModule)
 
@@ -1374,7 +1343,7 @@ func buildFunctionListeners(factory experimental.FunctionListenerFactory, m *was
 	listeners := make([]experimental.FunctionListener, len(m.FunctionSection))
 	importCount := m.ImportFunctionCount
 	for i := 0; i < len(listeners); i++ {
-		listeners[i] = factory.NewFunctionListener(&m.FunctionDefinitionSection[uint32(i)+importCount])
+		listeners[i] = factory.NewFunctionListener(m.FunctionDefinition(uint32(i) + importCount))
 	}
 	return listeners
 }
diff --git a/internal/wasm/function_definition_test.go b/internal/wasm/function_definition_test.go
index 99c71f55..6e6e92f2 100644
--- a/internal/wasm/function_definition_test.go
+++ b/internal/wasm/function_definition_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/testing/hammer"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -253,7 +254,25 @@ func TestModule_BuildFunctionDefinitions(t *testing.T) {
 	for _, tc := range tests {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-			tc.m.BuildFunctionDefinitions()
+			tc.m.buildFunctionDefinitions()
+			require.Equal(t, tc.expected, tc.m.FunctionDefinitionSection)
+			require.Equal(t, tc.expectedImports, tc.m.ImportedFunctions())
+			require.Equal(t, tc.expectedExports, tc.m.ExportedFunctions())
+		})
+	}
+
+	// Execute the same tests with n=`concurrentCount` goroutines invoking `buildFunctionDefinitions()` at once.
+	const nGoroutines = 100
+	const nIterations = 10
+	for _, tc := range tests {
+		tc := tc
+		testName := tc.name + " (concurrent)"
+		t.Run(testName, func(t *testing.T) {
+			hammer.NewHammer(t, nGoroutines, nIterations).
+				Run(func(name string) {
+					tc.m.buildFunctionDefinitions()
+				}, nil)
+
 			require.Equal(t, tc.expected, tc.m.FunctionDefinitionSection)
 			require.Equal(t, tc.expectedImports, tc.m.ImportedFunctions())
 			require.Equal(t, tc.expectedExports, tc.m.ExportedFunctions())
diff --git a/internal/wasm/module_test.go b/internal/wasm/module_test.go
index dadb2bda..77eb830d 100644
--- a/internal/wasm/module_test.go
+++ b/internal/wasm/module_test.go
@@ -376,8 +376,9 @@ func TestModule_validateStartSection(t *testing.T) {
 	t.Run("imported valid func", func(t *testing.T) {
 		index := Index(1)
 		m := Module{
-			StartSection: &index,
-			TypeSection:  []FunctionType{{}, {Results: []ValueType{ValueTypeI32}}},
+			StartSection:        &index,
+			TypeSection:         []FunctionType{{}, {Results: []ValueType{ValueTypeI32}}},
+			ImportFunctionCount: 2,
 			ImportSection: []Import{
 				{Type: ExternTypeFunc, DescFunc: 1},
 				// import with index 1 is global but this should be skipped when searching imported functions.
diff --git a/internal/wasm/store_test.go b/internal/wasm/store_test.go
index 9046ab02..383757af 100644
--- a/internal/wasm/store_test.go
+++ b/internal/wasm/store_test.go
@@ -211,7 +211,6 @@ func TestStore_hammer(t *testing.T) {
 			{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 		},
 	}
-	importingModule.BuildFunctionDefinitions()
 
 	// Concurrent instantiate, close should test if locks work on the ns. If they don't, we should see leaked modules
 	// after all of these complete, or an error raised.
@@ -271,7 +270,6 @@ func TestStore_hammer_close(t *testing.T) {
 			{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 		},
 	}
-	importingModule.BuildFunctionDefinitions()
 
 	const instCount = 10000
 	instances := make([]api.Module, instCount)
@@ -371,7 +369,6 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 				{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 			},
 		}
-		importingModule.BuildFunctionDefinitions()
 
 		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil, []FunctionTypeID{0})
 		require.EqualError(t, err, "some engine creation error")
@@ -399,7 +396,6 @@ func TestStore_Instantiate_Errors(t *testing.T) {
 				{Type: ExternTypeFunc, Module: importedModuleName, Name: "fn", DescFunc: 0},
 			},
 		}
-		importingModule.BuildFunctionDefinitions()
 
 		_, err = s.Instantiate(testCtx, importingModule, importingModuleName, nil, []FunctionTypeID{0})
 		require.EqualError(t, err, "start function[1] failed: call failed")
@@ -692,19 +688,21 @@ func Test_resolveImports(t *testing.T) {
 					"":   {Type: ExternTypeFunc, Index: 4},
 				},
 				ModuleName: moduleName,
-				Definitions: []FunctionDefinition{
-					{},
-					{},
-					{Functype: &FunctionType{Params: []ValueType{i32}, Results: []ValueType{ValueTypeV128}}},
-					{},
-					{Functype: &FunctionType{Params: []ValueType{ExternTypeFunc}, Results: []ValueType{}}},
+				Source: &Module{
+					FunctionSection: []Index{0, 0, 1, 0, 0},
+					TypeSection: []FunctionType{
+						{Params: []ValueType{ExternTypeFunc}},
+						{Params: []ValueType{i32}, Results: []ValueType{ValueTypeV128}},
+					},
 				},
 			}
+
 			module := &Module{
 				TypeSection: []FunctionType{
 					{Params: []ValueType{i32}, Results: []ValueType{ValueTypeV128}},
 					{Params: []ValueType{ExternTypeFunc}},
 				},
+				ImportFunctionCount: 2,
 				ImportPerModule: map[string][]*Import{
 					moduleName: {
 						{Module: moduleName, Name: name, Type: ExternTypeFunc, DescFunc: 0, IndexPerType: 0},
@@ -713,7 +711,7 @@ func Test_resolveImports(t *testing.T) {
 				},
 			}
 
-			m := &ModuleInstance{Engine: &mockModuleEngine{resolveImportsCalled: map[Index]Index{}}, s: s}
+			m := &ModuleInstance{Engine: &mockModuleEngine{resolveImportsCalled: map[Index]Index{}}, s: s, Source: module}
 			err := m.resolveImports(module)
 			require.NoError(t, err)
 
@@ -727,9 +725,14 @@ func Test_resolveImports(t *testing.T) {
 				Exports: map[string]*Export{
 					name: {Type: ExternTypeFunc, Index: 0},
 				},
-				ModuleName:  moduleName,
-				TypeIDs:     []FunctionTypeID{123435},
-				Definitions: []FunctionDefinition{{Functype: &FunctionType{}}},
+				ModuleName: moduleName,
+				TypeIDs:    []FunctionTypeID{123435},
+				Source: &Module{
+					FunctionSection: []Index{0},
+					TypeSection: []FunctionType{
+						{Params: []ValueType{}},
+					},
+				},
 			}
 			module := &Module{
 				TypeSection: []FunctionType{{Results: []ValueType{ValueTypeF32}}},
@@ -738,7 +741,7 @@ func Test_resolveImports(t *testing.T) {
 				},
 			}
 
-			m := &ModuleInstance{Engine: &mockModuleEngine{resolveImportsCalled: map[Index]Index{}}, s: s}
+			m := &ModuleInstance{Engine: &mockModuleEngine{resolveImportsCalled: map[Index]Index{}}, s: s, Source: module}
 			err := m.resolveImports(module)
 			require.EqualError(t, err, "import func[test.target]: signature mismatch: v_f32 != v_v")
 		})