Repository: tetratelabs/wazero
Pull request #1453 (2023-05-10 23:20:56)

experimental: remove return value of FunctionListener.Before
Another follow up from our discussion in #wazero-dev, this PR modifies the signature of `FunctionListener.Before` to remove the returned `context.Context`.

This change aims to remove the internal context stack tracking return values of the `Before` methods. For context, I am copying a quote from the Slack discussion:

> While I find the API somewhat elegant, it has proven to be unusable in most cases where we've used FunctionListener because embedding values in a context requires at least one heap allocation, but sometimes two or more. The listeners often being on hot code paths of the guest program, and this heap allocation ends up being extremely costly.
>
> We worked around the issue by implementing internal stacks within the listeners, in the same way, that the wazero engine keeps track of a stack of the context.Context returned by Before. Implementing those stacks as slices allows reusing the underlying memory, which ends up being an extremely cheap mechanism for correlation between the Before and After methods: the allocation of context values added 50 to 100ns of latency per function, while the internal stack ends up adding 5 to 10ns (~10x cheaper).
>
> However, we still end up paying for the engine's internal context stack, despite not needing it. On performance-critical code paths, it almost doubles the overall latency of invoking function listeners.

## Using stacks to correlate Before and After

One of the issues was the fact that `After` was not always being called. For example, if a module hit a trap, or a host function panicked, the call to the `After` method would be skipped, which made it challenging to implement stack-based context tracking internally in the listener. This limitation actually impacted the host logging listeners and caused multiple tests within wazero to fail after making the initial change.

I am suggesting that we address it by adding a third `Abort` method on `FunctionListener`, which is invoked when a module execution is aborted, in which case there are no result values to pass to `After` and instead of communicate the reason for aborting to the call to `Abort`.

The guarantee that either `After` or `Abort` will be called allows listeners to use an internal stack to carry state from `Before` to one of these methods.

## State propagation through the call stack

One of the details that @codefromthecrypt shared in #wazero-dev was that the second reason for returning a `context.Context` was to pass state not just between `Before` and `After`, but also to other listeners and host function calls through the call stack. Use cases for that include telemetry like tracing, where a listener creates a span and injects it in the context that eventually gets passed as input to another callee.

While this use case isn't covered in the change I'm submitting here, I believe that it can be served by injecting a _mutable context value_ (as @codefromthecrypt described it). Here would be an example:

```go
// Completely hypothetical API, just to demonstrate the idea

rootSpan := &tracing.Span{
  ParentSpan: ...,
}

ctx := context.WithValue(ctx, SpanKey{}, &rootSpan)
...
// eventually instantiate module or call exported function with ctx
```
```go
func (f *functionTracer) Before(ctx context.Context, ...) {
  spanPtr := ctx.Value(SpanKey{}).(**tracing.Span)
  span := &tracing.Span{
    ParentSpan: *spanPtr,
    ...
  }
  *spanPtr = span // push
  span.Start()
}

func (f *functionTracer) After(ctx context.Context, ...) {
  spanPtr := ctx.Value(SpanKey{}).(**tracing.Span)
  span := *spanPtr
  span.Finish()
  *spanPtr = spanPtr.Parent // pop
}

// + Abort
```
In the future, injecting and extracting the context value may also be abstracted into a generic mechanism to bridge between the `FunctionListener` interface and higher level abstractions.

Some of the benefits I'm seeing to this approach is that it reduces the baseline cost of function listeners, the higher cost of maintaining stacks is only paid when necessary, and can be optimized individually by each listener to suite the use cases.

The trade off is that we offer something that is a bit less _packaged_, in some ways we are lowering the abstraction level of function listeners, leaving more of the implementation decisions to the host.

Please take a look and let me know if you would like to see anything changed!

achille-roussel: > I can see the sense in not having the error param, and also the return removal, just not adding Abort when it isn't called (unless I am missing that). I think if we are removing things because they aren't implemented, we shouldn't add new unimplemented things as it makes it confusing. Can you separate these steps?

This was probably a miss on my part in the description, I can try to clarify.

The host logging code was depending on context propagation to calculate things like indentation levels. Removing the context means we needed a different way. Because the intention was to move state tracking into the function listener instead of having it in the wazero engine, I modified the loggers to track their state without the context, which highlighted the fact that sometimes `After` is not called.

For example, `Test_procExit` makes two calls to `proc_exit` which never calls `After` because the function never returns, and invalidated the state, causing the output to change and the tests to fail. This is just one example of the many places where we use the loggers to validate beahvior, wasi_snapshot_preview1 had many such tests.

In order to fix the issue and reconcile the state of the loggers, I added the `Abort` logic so everything that gets pushed to the inner stack gets popped. Now I couldn't just ensure that `After` was going to always be called because that would cause the loggers to print return values in places where the tests validated that there would be no returns (e.g. `proc_exit`).

I agree that adding `Abort` is expanding the scope, here is the place where it's needed in wazero https://github.com/tetratelabs/wazero/pull/1453/files#diff-d60dc448f4c159bc82ccabf4dca5fb109524074e4fc7810d766bcecb7a649af4R229-R232

Something I have to admit I did not explore was to take the approach I described in **State propagation through the call stack** with the loggers. I can probably get around not adding `Abort` with this model.

After exploring this code further, I feel like the presence of `Abort` is meaningful, tho I understand the desire not to couple changes:
- I can rework the loggers to not need `Abort`, and submit that as a separate PR if you think it's important to separate the change and discussion
- we can continue the discussion here until we feel comfortable with the change or choose not to, there is no rush!

Patch:
diff --git a/experimental/listener.go b/experimental/listener.go
index ecf89bd5..7c1ce6fb 100644
--- a/experimental/listener.go
+++ b/experimental/listener.go
@@ -46,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
 	//
@@ -62,19 +65,37 @@ type FunctionListener interface {
 	//
 	// 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, params []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.
 	//   - results: api.ValueType encoded results.
 	//
-	// Note: api.Memory is meant for inspection, not modification.
+	// # 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
@@ -88,9 +109,8 @@ 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, params []uint64, stackIterator StackIterator) context.Context {
+func (f FunctionListenerFunc) Before(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator StackIterator) {
 	f(ctx, mod, def, params, stackIterator)
-	return ctx
 }
 
 // After is declared to satisfy the FunctionListener interface, but it does
@@ -98,6 +118,11 @@ func (f FunctionListenerFunc) Before(ctx context.Context, mod api.Module, def ap
 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
 // FunctionListenerFactory interface, making it possible to use regular
 // functions and methods as factory of function listeners.
@@ -149,13 +174,12 @@ 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, results []uint64) {
@@ -164,6 +188,12 @@ func (multi *multiFunctionListener) After(ctx context.Context, mod api.Module, d
 	}
 }
 
+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)
+	}
+}
+
 type parameters struct {
 	values []uint64
 	limits []uint8
@@ -319,16 +349,13 @@ func BenchmarkFunctionListener(n int, module api.Module, stack []StackFrame, lis
 
 	for i := 0; i < n; i++ {
 		stackIterator.index = -1
-		callContext := listener.Before(ctx, module, def, params, stackIterator)
-		listener.After(callContext, module, def, results)
+		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: 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 7e2aecc7..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,69 +192,48 @@ 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(nestLevel, l.beforePrefix, func() {
-		l.logParams(ctx, mod, params)
-	})
-
-	// 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, 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(state.nestLevel, l.afterPrefix, func() {
-			l.logResults(ctx, mod, state.params, 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)
 		})
 	}
 }
 
+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 := 1; i < nestLevel; i++ {
+	for i := 0; i < nestLevel; i++ {
 		l.w.WriteByte('\t') //nolint
 	}
 	l.w.WriteString(prefix) //nolint
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 83794eba..ef68e373 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -844,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
@@ -882,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
 		}
@@ -925,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 {
@@ -943,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
@@ -952,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
@@ -995,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
@@ -1030,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
 
@@ -1052,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,
@@ -1094,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 {
@@ -1111,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
 	}
 }
 
diff --git a/internal/engine/compiler/engine.go b/internal/engine/compiler/engine.go
index a6ec03f9..9595fec6 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.
@@ -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
@@ -1174,21 +1192,14 @@ func (ce *callEngine) builtinFunctionFunctionListenerBefore(ctx context.Context,
 	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(), 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]
 }
 
 func compileGoDefinedHostFunction(cmp compiler) (body []byte, err error) {
diff --git a/internal/engine/interpreter/interpreter.go b/internal/engine/interpreter/interpreter.go
index c25bcc9a..a820005c 100644
--- a/internal/engine/interpreter/interpreter.go
+++ b/internal/engine/interpreter/interpreter.go
@@ -527,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)
 		}
 	}()
 
@@ -551,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
@@ -566,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]
@@ -590,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)}
@@ -4084,7 +4103,7 @@ 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, ce.peekValues(len(typ.Results)))
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 26b27f9d..eee324d7 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -324,7 +324,7 @@ func syscallReaddir(_ context.Context, mod api.Module, name string) (*objectArra
 	}
 	defer f.Close() //nolint
 
-	if dirents, errno := platform.Readdir(f.File(), -1); errno != 0 {
+	if dirents, errno := f.Readdir(-1); errno != 0 {
 		return nil, errno
 	} else {
 		entries := make([]interface{}, 0, len(dirents))
diff --git a/internal/platform/dir.go b/internal/platform/dir.go
index a777ff9e..9f2e183b 100644
--- a/internal/platform/dir.go
+++ b/internal/platform/dir.go
@@ -5,6 +5,7 @@ import (
 	"io"
 	"io/fs"
 	"syscall"
+	"time"
 )
 
 // Dirent is an entry read from a directory.
@@ -35,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) {
@@ -58,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
@@ -66,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
@@ -99,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 27532629..6488f58c 100644
--- a/internal/platform/file.go
+++ b/internal/platform/file.go
@@ -96,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
@@ -112,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.
@@ -180,6 +180,33 @@ type File interface {
 	//     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.
 	//
@@ -328,9 +355,6 @@ 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,
@@ -372,6 +396,11 @@ 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
@@ -555,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)
 	}
@@ -575,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 {
@@ -633,6 +662,16 @@ func (f *fsFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.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 {
@@ -644,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)
 	}
@@ -663,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)
 	}
@@ -735,30 +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.
-	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.
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/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 33a49809..4f7c0c5c 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -118,13 +118,12 @@ func (noopStdioFile) IsDir() (bool, syscall.Errno) {
 // Close implements the same method as documented on platform.File
 func (noopStdioFile) Close() (errno syscall.Errno) { return }
 
-// Once File.File is removed, it will be possible to implement NoopFile.
-func (noopStdioFile) File() fs.File { panic("noop") }
-
 // 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
 }
@@ -134,26 +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
-}
-
-// IsNonblock implements the same method as documented on platform.File
-func (r *lazyDir) IsNonblock() bool {
-	return false
-}
-
-// SetNonblock implements the same method as documented on platform.File
-func (r *lazyDir) SetNonblock(bool) syscall.Errno {
-	return syscall.EISDIR
-}
-
-// 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 {
@@ -163,39 +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
-}
-
-// PollRead implements File.PollRead
-func (r *lazyDir) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
-	return false, syscall.ENOSYS
-}
-
-// 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
@@ -243,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 {
@@ -352,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 {
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


Test patch:
diff --git a/experimental/listener_example_test.go b/experimental/listener_example_test.go
index 6fc48844..8b7f37d5 100644
--- a/experimental/listener_example_test.go
+++ b/experimental/listener_example_test.go
@@ -44,14 +44,16 @@ 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, []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() {
 	u := uniqGoFuncs{}
diff --git a/experimental/listener_test.go b/experimental/listener_test.go
index 55a8aa5c..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, _ []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
diff --git a/experimental/logging/log_listener_test.go b/experimental/logging/log_listener_test.go
index 81580bef..8234f199 100644
--- a/experimental/logging/log_listener_test.go
+++ b/experimental/logging/log_listener_test.go
@@ -279,8 +279,8 @@ func Test_loggingListener(t *testing.T) {
 			l := lf.NewFunctionListener(def)
 
 			out.Reset()
-			ctx := l.Before(testCtx, nil, def, tc.params, nil)
-			l.After(ctx, nil, def, tc.results)
+			l.Before(testCtx, nil, def, tc.params, nil)
+			l.After(testCtx, nil, def, tc.results)
 			require.Equal(t, tc.expected, out.String())
 		})
 	}
@@ -315,10 +315,10 @@ func Test_loggingListener_indentation(t *testing.T) {
 	def2 := &m.FunctionDefinitionSection[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, []uint64{})
-	l1.After(ctx, nil, def1, []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 5fa4e716..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
diff --git a/internal/engine/compiler/engine_bench_test.go b/internal/engine/compiler/engine_bench_test.go
index c3b6fc06..b7132d64 100644
--- a/internal/engine/compiler/engine_bench_test.go
+++ b/internal/engine/compiler/engine_bench_test.go
@@ -14,8 +14,7 @@ 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, []uint64) {
 				},
@@ -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 3b081e74..7938a52e 100644
--- a/internal/engine/compiler/engine_test.go
+++ b/internal/engine/compiler/engine_test.go
@@ -426,7 +426,7 @@ 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()
@@ -583,17 +583,16 @@ func assertStackIterator(t *testing.T, it experimental.StackIterator, expected [
 }
 
 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, params []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}, params)
 					assertStackIterator(t, stackIterator, []stackEntry{{def: def, args: []uint64{2, 3, 4}}})
-					return nextContext
 				},
 			},
 			index: 0,
@@ -603,19 +602,14 @@ func TestCallEngine_builtinFunctionFunctionListenerBefore(t *testing.T) {
 		},
 	}
 	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{
@@ -633,28 +627,34 @@ func TestCallEngine_builtinFunctionFunctionListenerAfter(t *testing.T) {
 	}
 
 	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, params []uint64, stackIterator experimental.StackIterator) context.Context
-	after  func(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []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) context.Context {
-	return m.before(ctx, mod, def, params, stackIterator)
+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) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
-	m.after(ctx, mod, def, results)
+	if m.after != nil {
+		m.after(ctx, mod, def, results)
+	}
+}
+
+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/platform/dir_test.go b/internal/platform/dir_test.go
index 8dc41e54..fb450916 100644
--- a/internal/platform/dir_test.go
+++ b/internal/platform/dir_test.go
@@ -34,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},
@@ -54,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 })
 
@@ -127,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))
 
@@ -144,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 5ae32377..9e125182 100644
--- a/internal/platform/file_test.go
+++ b/internal/platform/file_test.go
@@ -37,17 +37,14 @@ 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) {
@@ -57,7 +54,7 @@ func TestFsFileSetNonblock(t *testing.T) {
 	defer r.Close()
 	defer w.Close()
 
-	rF := NewFsFile(readFile, syscall.O_RDONLY, r)
+	rF := NewFsFile(wazeroFile, syscall.O_RDONLY, r)
 
 	errno := rF.SetNonblock(true)
 	require.EqualErrno(t, 0, errno)
@@ -85,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)
@@ -102,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)
@@ -131,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)
@@ -166,7 +163,7 @@ func TestFsFilePollRead(t *testing.T) {
 	defer r.Close()
 	defer w.Close()
 
-	rF := NewFsFile(readFile, syscall.O_RDONLY, r)
+	rF := NewFsFile(wazeroFile, syscall.O_RDONLY, r)
 	buf := make([]byte, 10)
 	timeout := time.Duration(0) // return immediately
 
@@ -230,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
@@ -259,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)
@@ -325,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)
@@ -405,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))
@@ -433,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)
@@ -442,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()
 
@@ -536,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()
 
@@ -559,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)
 		})
@@ -927,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()
 
@@ -935,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/sys/fs_test.go b/internal/sys/fs_test.go
index 821c49d3..59839630 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -249,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)
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 ab6394b9..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"
@@ -55,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) {
@@ -99,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)
 		})
 
@@ -110,11 +108,11 @@ func TestNewRootFS(t *testing.T) {
 			require.EqualErrno(t, 0, errno)
 			defer f.Close()
 
-			entries, err := f.File().(fs.ReadDirFile).ReadDir(-1)
-			require.NoError(t, err)
+			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())
+				names = append(names, e.Name)
 			}
 			sort.Strings(names)
 
@@ -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 50631f3c..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},
@@ -97,7 +102,7 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 		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))
 	})
 
@@ -106,32 +111,37 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 		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)
+		_, errno = dirF.Readdir(1)
 		require.EqualErrno(t, 0, errno)
 	})
 
@@ -289,19 +299,23 @@ 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
 }
 
@@ -337,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 12e39860..2f715c4c 100644
--- a/internal/testing/enginetest/enginetest.go
+++ b/internal/testing/enginetest/enginetest.go
@@ -556,7 +556,7 @@ func RunTestModuleEngineBeforeListenerStackIterator(t *testing.T, et EngineTeste
 	}
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []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() {
@@ -567,7 +567,6 @@ func RunTestModuleEngineBeforeListenerStackIterator(t *testing.T, et EngineTeste
 			}
 			require.Equal(t, 0, len(expectedCallstack))
 			expectedCallstacks = expectedCallstacks[1:]
-			return ctx
 		},
 	}
 
@@ -693,7 +692,7 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 	}
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []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 +706,6 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 			}
 
 			expectedGlobals = expectedGlobals[1:]
-			return ctx
 		},
 	}
 
@@ -803,19 +801,19 @@ func RunTestModuleEngineBeforeListenerGlobals(t *testing.T, et EngineTester) {
 }
 
 type fnListener struct {
-	beforeFn func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []uint64, stackIterator experimental.StackIterator) context.Context
-	afterFn  func(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []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, params []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, params, stackIterator)
+		f.beforeFn(ctx, mod, def, params, stackIterator)
 	}
-	return ctx
 }
 
 func (f *fnListener) After(ctx context.Context, mod api.Module, def api.FunctionDefinition, results []uint64) {
@@ -824,6 +822,12 @@ func (f *fnListener) After(ctx context.Context, mod api.Module, def api.Function
 	}
 }
 
+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)
+	}
+}
+
 func RunTestModuleEngineStackIteratorOffset(t *testing.T, et EngineTester) {
 	e := et.NewEngine(api.CoreFeaturesV2)
 
@@ -835,7 +839,7 @@ func RunTestModuleEngineStackIteratorOffset(t *testing.T, et EngineTester) {
 	var tape [][]frame
 
 	fnListener := &fnListener{
-		beforeFn: func(ctx context.Context, mod api.Module, def api.FunctionDefinition, params []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() {
 				fn := si.Function()
@@ -843,7 +847,6 @@ func RunTestModuleEngineStackIteratorOffset(t *testing.T, et EngineTester) {
 				stack = append(stack, frame{fn.Definition(), fn.SourceOffsetForPC(pc)})
 			}
 			tape = append(tape, stack)
-			return ctx
 		},
 	}