Repository: tetratelabs/wazero
Pull request #1447 (2023-05-09 07:28:04)

Adds IsNonblock SetNonblock PollRead to platform.File
This adds `IsNonblock`, `SetNonblock` and `PollRead` to `platform.File`
allowing us to implement stdio files with the `platform.File` type
instead of alternate implementations.

codefromthecrypt: `TestShorthash` hangs locally not sure why
codefromthecrypt: looks like I need to return fake stat for stdio, even when a real file, to pass this test https://github.com/WebAssembly/wasi-testsuite/blob/main/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
codefromthecrypt: looks like stdout is causing the hang on TestShortHash (trial-error commenting things out and running with cmd/wazero)
codefromthecrypt: TODO: scaffold tests for the new functions in platform/file_test.go as they are currently tested really later (wasi)
codefromthecrypt: > Does it break if stdin/stdout/stderr are closed and the file descriptor resused?

I'll switch to make the stdio files explicitly pre-opens (they are, just not in a way we can check).

Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 9226298f..83794eba 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -201,21 +201,12 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) syscall.E
 		return syscall.EBADF
 	} else if st, errno = f.Stat(); errno != 0 {
 		return errno
-	} else {
-		var nonblock bool
-		switch file := f.File.File().(type) {
-		case *sys.StdioFileReader:
-			nonblock = file.IsNonblock()
-		case *sys.StdioFileWriter:
-			nonblock = file.IsNonblock()
-		default:
-			if f.File.AccessMode() != syscall.O_RDONLY {
-				fdflags |= wasip1.FD_APPEND
-			}
-		}
-		if nonblock {
+	} else if isPreopenedStdio(fd, f) {
+		if f.File.IsNonblock() {
 			fdflags |= wasip1.FD_NONBLOCK
 		}
+	} else if f.File.AccessMode() != syscall.O_RDONLY {
+		fdflags |= wasip1.FD_APPEND
 	}
 
 	var fsRightsBase uint32
@@ -236,6 +227,23 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) syscall.E
 	return 0
 }
 
+// isPreopenedStdio returns true if the FD is sys.FdStdin, sys.FdStdout or
+// sys.FdStderr and pre-opened. This double check is needed in case the guest
+// closes stdin and re-opens it with a random alternative file.
+//
+// Currently, we only support non-blocking mode for standard I/O streams.
+// Non-blocking mode is rarely supported for regular files, and we don't
+// yet have support for sockets, so we make a special case.
+//
+// Note: this to get or set FD_NONBLOCK, but skip FD_APPEND. Our current
+// implementation can't set FD_APPEND, without re-opening files. As stdio are
+// pre-opened, we don't know how to re-open them, neither should we close the
+// underlying file. Later, we could add support for setting FD_APPEND, similar
+// to SetNonblock.
+func isPreopenedStdio(fd int32, f *sys.FileEntry) bool {
+	return fd <= sys.FdStderr && f.IsPreopen
+}
+
 const fileRightsBase = wasip1.RIGHT_FD_DATASYNC |
 	wasip1.RIGHT_FD_READ |
 	wasip1.RIGHT_FD_SEEK |
@@ -293,20 +301,14 @@ func fdFdstatSetFlagsFn(_ context.Context, mod api.Module, params []uint64) sysc
 		return syscall.EINVAL
 	}
 
-	// Currently we only support non-blocking mode for standard I/O streams.
-	// Non-blocking mode is rarely supported for regular files, and we don't
-	// yet have support for sockets so we make a special case.
-	f, ok := fsc.LookupFile(fd)
-	if ok {
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return syscall.EBADF
+	} else if isPreopenedStdio(fd, f) {
 		nonblock := wasip1.FD_NONBLOCK&wasiFlag != 0
-		switch file := f.File.File().(type) {
-		case *sys.StdioFileReader:
-			return platform.UnwrapOSError(file.SetNonblock(nonblock))
-		case *sys.StdioFileWriter:
-			return platform.UnwrapOSError(file.SetNonblock(nonblock))
-		}
+		return f.File.SetNonblock(nonblock)
 	}
 
+	// For normal files, proceed to apply an append flag.
 	var flag int
 	if wasip1.FD_APPEND&wasiFlag != 0 {
 		flag = syscall.O_APPEND
@@ -1738,6 +1740,10 @@ func preopenPath(fsc *sys.FSContext, fd int32) (string, syscall.Errno) {
 		return "", syscall.EBADF // closed
 	} else if !f.IsPreopen {
 		return "", syscall.EBADF
+	} else if isDir, errno := f.File.IsDir(); errno != 0 || !isDir {
+		// In wasip1, only directories can be returned by fd_prestat_get as
+		// there are no prestat types defined for files or sockets.
+		return "", errno
 	} else {
 		return f.Name, 0
 	}
diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go
index 37e6a776..c3097277 100644
--- a/imports/wasi_snapshot_preview1/poll.go
+++ b/imports/wasi_snapshot_preview1/poll.go
@@ -6,7 +6,6 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/api"
-	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasip1"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -50,7 +49,7 @@ type event struct {
 	outOffset uint32
 }
 
-func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall.Errno {
+func pollOneoffFn(_ context.Context, mod api.Module, params []uint64) syscall.Errno {
 	in := uint32(params[0])
 	out := uint32(params[1])
 	nsubscriptions := uint32(params[2])
@@ -162,11 +161,14 @@ func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall.
 
 	// If there are stdin subscribers, check for data with given timeout.
 	if len(stdinSubs) > 0 {
-		reader := getStdioFileReader(mod)
+		stdin, ok := fsc.LookupFile(internalsys.FdStdin)
+		if !ok {
+			return syscall.EBADF
+		}
 		// Wait for the timeout to expire, or for some data to become available on Stdin.
-		stdinReady, err := reader.Poll(timeout)
-		if err != nil {
-			return platform.UnwrapOSError(err)
+		stdinReady, errno := stdin.File.PollRead(&timeout)
+		if errno != 0 {
+			return errno
 		}
 		if stdinReady {
 			// stdin has data ready to for reading, write back all the events
@@ -249,18 +251,3 @@ func writeEvent(outBuf []byte, evt *event) {
 	le.PutUint32(outBuf[evt.outOffset+10:], uint32(evt.eventType))
 	// TODO: When FD events are supported, write outOffset+16
 }
-
-// getStdioFileReader extracts a StdioFileReader for FdStdin from the given api.Module instance.
-// and panics if this is not possible.
-func getStdioFileReader(mod api.Module) *internalsys.StdioFileReader {
-	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
-	if file, ok := fsc.LookupFile(internalsys.FdStdin); ok {
-		// TODO: Make a new StdioFile which implements platform.File similar
-		// to lazyDir. This can add an additional function needed here, so we'd
-		// end up casting file.File not file.File.File()
-		if reader, typeOk := file.File.File().(*internalsys.StdioFileReader); typeOk {
-			return reader
-		}
-	}
-	panic("unexpected error: Stdin must always be a StdioFileReader")
-}
diff --git a/internal/engine/compiler/engine.go b/internal/engine/compiler/engine.go
index 77e4dc98..c848c2c0 100644
--- a/internal/engine/compiler/engine.go
+++ b/internal/engine/compiler/engine.go
@@ -1136,20 +1136,17 @@ func (si *stackIterator) Next() bool {
 	return si.fn != nil
 }
 
-// ProgramCounter implements the same method as documented on
-// experimental.StackIterator.
+// ProgramCounter implements the same method as documented on experimental.StackIterator.
 func (si *stackIterator) ProgramCounter() experimental.ProgramCounter {
 	return experimental.ProgramCounter(si.pc)
 }
 
-// Function implements the same method as documented on
-// experimental.StackIterator.
+// Function implements the same method as documented on experimental.StackIterator.
 func (si *stackIterator) Function() experimental.InternalFunction {
 	return internalFunction{si.fn}
 }
 
-// Parameters implements the same method as documented on
-// experimental.StackIterator.
+// Parameters implements the same method as documented on experimental.StackIterator.
 func (si *stackIterator) Parameters() []uint64 {
 	return si.stack[si.base : si.base+si.fn.funcType.ParamNumInUint64]
 }
@@ -1157,14 +1154,12 @@ func (si *stackIterator) Parameters() []uint64 {
 // internalFunction implements experimental.InternalFunction.
 type internalFunction struct{ *function }
 
-// Definition implements the same method as documented on
-// experimental.InternalFunction.
+// Definition implements the same method as documented on experimental.InternalFunction.
 func (f internalFunction) Definition() api.FunctionDefinition {
 	return f.definition()
 }
 
-// SourceOffsetForPC implements the same method as documented on
-// experimental.InternalFunction.
+// SourceOffsetForPC implements the same method as documented on experimental.InternalFunction.
 func (f internalFunction) SourceOffsetForPC(pc experimental.ProgramCounter) uint64 {
 	p := f.parent
 	if len(p.sourceOffsetMap.irOperationSourceOffsetsInWasmBinary) == 0 {
diff --git a/internal/platform/file.go b/internal/platform/file.go
index 2dee892b..01ca0ab3 100644
--- a/internal/platform/file.go
+++ b/internal/platform/file.go
@@ -4,6 +4,7 @@ import (
 	"io"
 	"io/fs"
 	"syscall"
+	"time"
 )
 
 // File is a writeable fs.File bridge backed by syscall functions needed for ABI
@@ -41,6 +42,30 @@ type File interface {
 	//   - syscall.O_RDWR: read-write, e.g. os.CreateTemp
 	AccessMode() int
 
+	// IsNonblock returns true if SetNonblock was successfully enabled on this
+	// file.
+	//
+	// # Notes
+	//
+	//   - This may not match the underlying state of the file descriptor if it
+	//     was opened (OpenFile) in non-blocking mode.
+	IsNonblock() bool
+	// ^-- TODO: We should be able to cache the open flag and remove this note.
+
+	// SetNonblock toggles the non-blocking mode of this file.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EBADF: the file or directory was closed.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.SetNonblock and `fcntl` with `O_NONBLOCK` in
+	//     POSIX. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/fcntl.html
+	SetNonblock(enable bool) syscall.Errno
+
 	// Stat is similar to syscall.Fstat.
 	//
 	// # Errors
@@ -135,6 +160,26 @@ type File interface {
 	//     of io.Seeker. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/fseek.html
 	Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno)
 
+	// PollRead returns if the file has data ready to be read or an error.
+	//
+	// # Parameters
+	//
+	// The `timeout` parameter when nil blocks up to forever.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//
+	// # Notes
+	//
+	//   - This is like `poll` in POSIX, for a single file.
+	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/poll.html
+	//   - No-op files, such as those which read from /dev/null, should return
+	//     immediately true to avoid hangs (because data will never become
+	//     available).
+	PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno)
+
 	// Write attempts to write all bytes in `p` to the file, and returns the
 	// count written even on error.
 	//
@@ -292,6 +337,16 @@ type File interface {
 // This should be embedded to have forward compatible implementations.
 type UnimplementedFile struct{}
 
+// IsNonblock implements File.IsNonblock
+func (UnimplementedFile) IsNonblock() bool {
+	return false
+}
+
+// SetNonblock implements File.SetNonblock
+func (UnimplementedFile) SetNonblock(bool) syscall.Errno {
+	return syscall.ENOSYS
+}
+
 // Stat implements File.Stat
 func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
 	return Stat_t{}, syscall.ENOSYS
@@ -317,6 +372,11 @@ func (UnimplementedFile) Seek(int64, int) (int64, syscall.Errno) {
 	return 0, syscall.ENOSYS
 }
 
+// PollRead implements File.PollRead
+func (UnimplementedFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return false, syscall.ENOSYS
+}
+
 // Write implements File.Write
 func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
 	return 0, syscall.ENOSYS
@@ -357,6 +417,28 @@ func (UnimplementedFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.ENOSYS
 }
 
+func NewStdioFile(stdin bool, f fs.File) (File, error) {
+	// Return constant stat, which has fake times, but keep the underlying
+	// file mode. Fake times are needed to pass wasi-testsuite.
+	// https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+	var mode fs.FileMode
+	if st, err := f.Stat(); err != nil {
+		return nil, err
+	} else {
+		mode = st.Mode()
+	}
+	var accessMode int
+	if stdin {
+		accessMode = syscall.O_RDONLY
+	} else {
+		accessMode = syscall.O_WRONLY
+	}
+	return &stdioFile{
+		fsFile: fsFile{accessMode: accessMode, file: f},
+		st:     Stat_t{Mode: mode, Nlink: 1},
+	}, nil
+}
+
 func NewFsFile(openPath string, openFlag int, f fs.File) File {
 	return &fsFile{
 		path:       openPath,
@@ -365,11 +447,33 @@ func NewFsFile(openPath string, openFlag int, f fs.File) File {
 	}
 }
 
+type stdioFile struct {
+	fsFile
+	st Stat_t
+}
+
+// IsDir implements File.IsDir
+func (f *stdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
+
+// Stat implements File.Stat
+func (f *stdioFile) Stat() (Stat_t, syscall.Errno) {
+	return f.st, 0
+}
+
+// Close implements File.Close
+func (f *stdioFile) Close() syscall.Errno {
+	return 0
+}
+
 type fsFile struct {
 	path       string
 	accessMode int
 	file       fs.File
 
+	nonblock bool
+
 	// cachedStat includes fields that won't change while a file is open.
 	cachedSt *cachedStat
 }
@@ -400,6 +504,23 @@ func (f *fsFile) AccessMode() int {
 	return f.accessMode
 }
 
+// IsNonblock implements File.IsNonblock
+func (f *fsFile) IsNonblock() bool {
+	return f.nonblock
+}
+
+// SetNonblock implements File.SetNonblock
+func (f *fsFile) SetNonblock(enable bool) syscall.Errno {
+	if fd, ok := f.file.(fdFile); ok {
+		if err := setNonblock(fd.Fd(), enable); err != nil {
+			return UnwrapOSError(err)
+		}
+		f.nonblock = enable
+		return 0
+	}
+	return syscall.ENOSYS
+}
+
 // IsDir implements File.IsDir
 func (f *fsFile) IsDir() (bool, syscall.Errno) {
 	if ft, errno := f.cachedStat(); errno != 0 {
@@ -499,6 +620,19 @@ func (f *fsFile) Seek(offset int64, whence int) (int64, syscall.Errno) {
 	return 0, syscall.ENOSYS
 }
 
+// PollRead implements File.PollRead
+func (f *fsFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
+	if f, ok := f.file.(fdFile); ok {
+		fdSet := FdSet{}
+		fd := int(f.Fd())
+		fdSet.Set(fd)
+		nfds := fd + 1 // See https://man7.org/linux/man-pages/man2/select.2.html#:~:text=condition%20has%20occurred.-,nfds,-This%20argument%20should
+		count, err := _select(nfds, &fdSet, nil, nil, timeout)
+		return count > 0, UnwrapOSError(err)
+	}
+	return false, syscall.ENOSYS
+}
+
 // Write implements File.Write
 func (f *fsFile) Write(p []byte) (n int, errno syscall.Errno) {
 	if errno = f.isDirErrno(); errno != 0 {
diff --git a/internal/platform/nonblock_unix.go b/internal/platform/nonblock_unix.go
index d022f025..0af1471b 100644
--- a/internal/platform/nonblock_unix.go
+++ b/internal/platform/nonblock_unix.go
@@ -4,6 +4,6 @@ package platform
 
 import "syscall"
 
-func SetNonblock(fd uintptr, enable bool) error {
+func setNonblock(fd uintptr, enable bool) error {
 	return syscall.SetNonblock(int(fd), enable)
 }
diff --git a/internal/platform/nonblock_windows.go b/internal/platform/nonblock_windows.go
index 8d0b669b..0bac6918 100644
--- a/internal/platform/nonblock_windows.go
+++ b/internal/platform/nonblock_windows.go
@@ -4,6 +4,6 @@ package platform
 
 import "syscall"
 
-func SetNonblock(fd uintptr, enable bool) error {
+func setNonblock(fd uintptr, enable bool) error {
 	return syscall.SetNonblock(syscall.Handle(fd), enable)
 }
diff --git a/internal/platform/select.go b/internal/platform/select.go
index 752f4eaa..7325b4b0 100644
--- a/internal/platform/select.go
+++ b/internal/platform/select.go
@@ -2,7 +2,8 @@ package platform
 
 import "time"
 
-// Select exposes the select(2) syscall.
+// _select exposes the select(2) syscall. This is named as such to avoid
+// colliding with they keyword select while not exporting the function.
 //
 // # Notes on Parameters
 //
@@ -26,6 +27,6 @@ import "time"
 //	e.g. the read-end of a pipe or an eventfd on Linux.
 //	When the context is canceled, we may unblock a Select call by writing to the fd, causing it to return immediately.
 //	This however requires to do a bit of housekeeping to hide the "special" FD from the end-user.
-func Select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func _select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
 	return syscall_select(n, r, w, e, timeout)
 }
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 69c69e3d..33a49809 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -1,7 +1,6 @@
 package sys
 
 import (
-	"fmt"
 	"io"
 	"io/fs"
 	"os"
@@ -31,173 +30,99 @@ const (
 	FdPreopen
 )
 
-const modeDevice = uint32(fs.ModeDevice | 0o640)
+const modeDevice = fs.ModeDevice | 0o640
 
-type StdioFileWriter struct {
-	w io.Writer
-	s fs.FileInfo
-	// Known state of the non-blocking mode. Defaults to false which may not
-	// match the underlying state of the file descriptor if it was opened in
-	// non-blocking mode whe instantiating the module.
-	nonblock bool
+// StdinFile is a fs.ModeDevice file for use implementing FdStdin.
+// This is safer than reading from os.DevNull as it can never overrun
+// operating system file descriptors.
+type StdinFile struct {
+	noopStdinFile
+	io.Reader
 }
 
-// Stat implements fs.File
-func (w *StdioFileWriter) Stat() (fs.FileInfo, error) { return w.s, nil }
-
-// Read implements fs.File
-func (w *StdioFileWriter) Read([]byte) (n int, err error) {
-	return // emulate os.Stdout which returns zero
+// Read implements the same method as documented on platform.File
+func (f *StdinFile) Read(buf []byte) (int, syscall.Errno) {
+	n, err := f.Reader.Read(buf)
+	return n, platform.UnwrapOSError(err)
 }
 
-// Write implements io.Writer
-func (w *StdioFileWriter) Write(p []byte) (n int, err error) {
-	return w.w.Write(p)
-}
+type writerFile struct {
+	noopStdoutFile
 
-// Close implements fs.File
-func (w *StdioFileWriter) Close() error {
-	// Don't actually close the underlying file, as we didn't open it!
-	return nil
+	w io.Writer
 }
 
-// IsNonblock returns the current known state of non-blocking mode on the
-// underlying file.
-func (w *StdioFileWriter) IsNonblock() bool {
-	return w.nonblock
+// Write implements the same method as documented on platform.File
+func (f *writerFile) Write(buf []byte) (int, syscall.Errno) {
+	n, err := f.w.Write(buf)
+	return n, platform.UnwrapOSError(err)
 }
 
-// SetNonblock sets the file to non-blocking mode if the reader has access to
-// the underlying file descriptor.
-func (w *StdioFileWriter) SetNonblock(enable bool) error {
-	if f, ok := w.w.(*os.File); ok {
-		if err := platform.SetNonblock(f.Fd(), enable); err != nil {
-			return err
-		}
-		w.nonblock = enable
-		return nil
-	}
-	return syscall.ENOSYS
+// noopStdinFile is a fs.ModeDevice file for use implementing FdStdin. This is
+// safer than reading from os.DevNull as it can never overrun operating system
+// file descriptors.
+type noopStdinFile struct {
+	noopStdioFile
 }
 
-// StdioFilePoller is a strategy for polling a StdioFileReader for a given duration.
-// It returns true if the reader has data ready to be read, false and/or an error otherwise.
-type StdioFilePoller interface {
-	Poll(duration time.Duration) (bool, error)
+// AccessMode implements the same method as documented on platform.File
+func (noopStdinFile) AccessMode() int {
+	return syscall.O_RDONLY
 }
 
-// PollerDefaultStdin is a poller that checks standard input.
-var PollerDefaultStdin = &pollerDefaultStdin{}
-
-type pollerDefaultStdin struct{}
-
-// Poll implements StdioFilePoller for pollerDefaultStdin.
-func (*pollerDefaultStdin) Poll(duration time.Duration) (bool, error) {
-	fdSet := platform.FdSet{}
-	fdSet.Set(int(FdStdin))
-	count, err := platform.Select(int(FdStdin+1), &fdSet, nil, nil, &duration)
-	return count > 0, err
+// Read implements the same method as documented on platform.File
+func (noopStdinFile) Read([]byte) (int, syscall.Errno) {
+	return 0, 0 // Always EOF
 }
 
-// PollerAlwaysReady is a poller that ignores the given timeout, and it returns true and no error.
-var PollerAlwaysReady = &pollerAlwaysReady{}
-
-type pollerAlwaysReady struct{}
-
-// Poll implements StdioFilePoller for pollerAlwaysReady.
-func (*pollerAlwaysReady) Poll(time.Duration) (bool, error) { return true, nil }
-
-// PollerNeverReady is a poller that waits for the given duration, and it always returns false and no error.
-var PollerNeverReady = &pollerNeverReady{}
-
-type pollerNeverReady struct{}
-
-// Poll implements StdioFilePoller for pollerNeverReady.
-func (*pollerNeverReady) Poll(d time.Duration) (bool, error) { time.Sleep(d); return false, nil }
-
-// StdioFileReader implements io.Reader for stdio files.
-type StdioFileReader struct {
-	r    io.Reader
-	s    fs.FileInfo
-	poll StdioFilePoller
-	// See StdioFileWriter.
-	nonblock bool
+// PollRead implements the same method as documented on platform.File
+func (noopStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return true, 0 // always ready to read nothing
 }
 
-// NewStdioFileReader is a constructor for StdioFileReader.
-func NewStdioFileReader(reader io.Reader, fileInfo fs.FileInfo, poll StdioFilePoller) *StdioFileReader {
-	return &StdioFileReader{
-		r:    reader,
-		s:    fileInfo,
-		poll: poll,
-	}
+// noopStdoutFile is a fs.ModeDevice file for use implementing FdStdout and
+// FdStderr.
+type noopStdoutFile struct {
+	noopStdioFile
 }
 
-// Poll invokes the StdioFilePoller that was given at the NewStdioFileReader constructor.
-func (r *StdioFileReader) Poll(duration time.Duration) (bool, error) {
-	return r.poll.Poll(duration)
+// AccessMode implements the same method as documented on platform.File
+func (noopStdoutFile) AccessMode() int {
+	return syscall.O_WRONLY
 }
 
-// Stat implements fs.File
-func (r *StdioFileReader) Stat() (fs.FileInfo, error) { return r.s, nil }
-
-// Read implements fs.File
-func (r *StdioFileReader) Read(p []byte) (n int, err error) {
-	return r.r.Read(p)
+// Write implements the same method as documented on platform.File
+func (noopStdoutFile) Write(p []byte) (int, syscall.Errno) {
+	return len(p), 0 // same as io.Discard
 }
 
-// Close implements fs.File
-func (r *StdioFileReader) Close() error {
-	// Don't actually close the underlying file, as we didn't open it!
-	return nil
+type noopStdioFile struct {
+	platform.UnimplementedFile
 }
 
-// IsNonblock returns the current known state of non-blocking mode on the
-// underlying file.
-func (r *StdioFileReader) IsNonblock() bool {
-	return r.nonblock
+// Path implements the same method as documented on platform.File
+func (noopStdioFile) Path() string {
+	return ""
 }
 
-// SetNonblock sets the file to non-blocking mode if the reader has access to
-// the underlying file descriptor.
-func (r *StdioFileReader) SetNonblock(enable bool) error {
-	if f, ok := r.r.(*os.File); ok {
-		if err := platform.SetNonblock(f.Fd(), enable); err != nil {
-			return err
-		}
-		r.nonblock = enable
-		return nil
-	}
-	return syscall.ENOSYS
+// Stat implements the same method as documented on platform.File
+func (noopStdioFile) Stat() (platform.Stat_t, syscall.Errno) {
+	return platform.Stat_t{Mode: modeDevice, Nlink: 1}, 0
 }
 
-var (
-	noopStdinStat  = stdioFileInfo{0, modeDevice}
-	noopStdoutStat = stdioFileInfo{1, modeDevice}
-	noopStderrStat = stdioFileInfo{2, modeDevice}
-)
+// IsDir implements the same method as documented on platform.File
+func (noopStdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
 
-// stdioFileInfo implements fs.FileInfo where index zero is the FD and one is the mode.
-type stdioFileInfo [2]uint32
+// Close implements the same method as documented on platform.File
+func (noopStdioFile) Close() (errno syscall.Errno) { return }
 
-func (s stdioFileInfo) Name() string {
-	switch s[0] {
-	case 0:
-		return "stdin"
-	case 1:
-		return "stdout"
-	case 2:
-		return "stderr"
-	default:
-		panic(fmt.Errorf("BUG: incorrect FD %d", s[0]))
-	}
-}
+// Once File.File is removed, it will be possible to implement NoopFile.
+func (noopStdioFile) File() fs.File { panic("noop") }
 
-func (stdioFileInfo) Size() int64         { return 0 }
-func (s stdioFileInfo) Mode() fs.FileMode { return fs.FileMode(s[1]) }
-func (stdioFileInfo) ModTime() time.Time  { return time.Unix(0, 0) }
-func (stdioFileInfo) IsDir() bool         { return false }
-func (stdioFileInfo) Sys() interface{}    { return nil }
+// compile-time check to ensure lazyDir implements platform.File.
+var _ platform.File = (*lazyDir)(nil)
 
 type lazyDir struct {
 	fs sysfs.FS
@@ -214,6 +139,16 @@ 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
@@ -243,6 +178,11 @@ 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
@@ -436,17 +376,17 @@ type FileTable = descriptor.Table[int32, *FileEntry]
 // the file descriptor table as FdPreopen.
 func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS sysfs.FS) (err error) {
 	c.fsc.rootFS = rootFS
-	inReader, err := stdinReader(stdin)
+	inFile, err := stdinFile(stdin)
 	if err != nil {
 		return err
 	}
-	c.fsc.openedFiles.Insert(inReader)
-	outWriter, err := stdioWriter(stdout, noopStdoutStat)
+	c.fsc.openedFiles.Insert(inFile)
+	outWriter, err := stdioWriterFile("stdout", stdout)
 	if err != nil {
 		return err
 	}
 	c.fsc.openedFiles.Insert(outWriter)
-	errWriter, err := stdioWriter(stderr, noopStderrStat)
+	errWriter, err := stdioWriterFile("stderr", stderr)
 	if err != nil {
 		return err
 	}
@@ -478,48 +418,32 @@ func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS
 	return nil
 }
 
-func stdinReader(r io.Reader) (*FileEntry, error) {
+func stdinFile(r io.Reader) (*FileEntry, error) {
 	if r == nil {
-		r = eofReader{}
-	}
-	var freader *StdioFileReader
-	if stdioFileReader, ok := r.(*StdioFileReader); ok {
-		freader = stdioFileReader
-	} else {
-		s, err := stdioStat(r, noopStdinStat)
-		if err != nil {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &noopStdinFile{}}, nil
+	} else if f, ok := r.(*os.File); ok {
+		if f, err := platform.NewStdioFile(true, f); err != nil {
 			return nil, err
+		} else {
+			return &FileEntry{Name: "stdin", IsPreopen: true, File: f}, nil
 		}
-		freader = NewStdioFileReader(r, s, PollerDefaultStdin)
+	} else {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &StdinFile{Reader: r}}, nil
 	}
-	return &FileEntry{
-		Name: noopStdinStat.Name(), File: platform.NewFsFile("", syscall.O_RDONLY, freader),
-	}, nil
 }
 
-func stdioWriter(w io.Writer, defaultStat stdioFileInfo) (*FileEntry, error) {
+func stdioWriterFile(name string, w io.Writer) (*FileEntry, error) {
 	if w == nil {
-		w = io.Discard
-	}
-	s, err := stdioStat(w, defaultStat)
-	if err != nil {
-		return nil, err
-	}
-	return &FileEntry{
-		Name: s.Name(), File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: w, s: s}),
-	}, nil
-}
-
-func stdioStat(f interface{}, defaultStat stdioFileInfo) (fs.FileInfo, error) {
-	if f, ok := f.(*os.File); ok {
-		if st, err := f.Stat(); err == nil {
-			mode := uint32(st.Mode() & fs.ModeType)
-			return stdioFileInfo{defaultStat[0], mode}, nil
-		} else {
+		return &FileEntry{Name: name, IsPreopen: true, File: &noopStdoutFile{}}, nil
+	} else if f, ok := w.(*os.File); ok {
+		if f, err := platform.NewStdioFile(false, f); err != nil {
 			return nil, err
+		} else {
+			return &FileEntry{Name: name, IsPreopen: true, File: f}, nil
 		}
+	} else {
+		return &FileEntry{Name: name, IsPreopen: true, File: &writerFile{w: w}}, nil
 	}
-	return defaultStat, nil
 }
 
 // RootFS returns the underlying filesystem. Any files that should be added to
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index a0b5dbd5..cdf05802 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -108,15 +108,6 @@ func (c *Context) RandSource() io.Reader {
 	return c.randSource
 }
 
-// eofReader is safer than reading from os.DevNull as it can never overrun operating system file descriptors.
-type eofReader struct{}
-
-// Read implements io.Reader
-// Note: This doesn't use a pointer reference as it has no state and an empty struct doesn't allocate.
-func (eofReader) Read([]byte) (int, error) {
-	return 0, io.EOF
-}
-
 // DefaultContext returns Context with no values set except a possible nil fs.FS
 //
 // This is only used for testing.


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index bf892a31..5fa4e716 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -363,3 +363,70 @@ func Test_getWasiFiletype_DevNull(t *testing.T) {
 	// Should be a character device, and not contain permissions
 	require.Equal(t, wasip1.FILETYPE_CHARACTER_DEVICE, ft)
 }
+
+func Test_isPreopenedStdio(t *testing.T) {
+	tests := []struct {
+		name     string
+		fd       int32
+		f        *sys.FileEntry
+		expected bool
+	}{
+		{
+			name:     "stdin",
+			fd:       sys.FdStdin,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stdin re-opened",
+			fd:       sys.FdStdin,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "stdout",
+			fd:       sys.FdStdout,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stdout re-opened",
+			fd:       sys.FdStdout,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "stderr",
+			fd:       sys.FdStderr,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: true,
+		},
+		{
+			name:     "stderr re-opened",
+			fd:       sys.FdStderr,
+			f:        &sys.FileEntry{IsPreopen: false},
+			expected: false,
+		},
+		{
+			name:     "not stdio pre-open",
+			fd:       sys.FdPreopen,
+			f:        &sys.FileEntry{IsPreopen: true},
+			expected: false,
+		},
+		{
+			name:     "random file",
+			fd:       42,
+			f:        &sys.FileEntry{},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			ok := isPreopenedStdio(tc.fd, tc.f)
+			require.Equal(t, tc.expected, ok)
+		})
+	}
+}
diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go
index d3d90bee..8b2f0279 100644
--- a/imports/wasi_snapshot_preview1/poll_test.go
+++ b/imports/wasi_snapshot_preview1/poll_test.go
@@ -3,10 +3,13 @@ package wasi_snapshot_preview1_test
 import (
 	"io/fs"
 	"strings"
+	"syscall"
 	"testing"
 	"time"
 
 	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasip1"
@@ -151,7 +154,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 		name                                   string
 		in, out, nsubscriptions, resultNevents uint32
 		mem                                    []byte // at offset in
-		stdioReader                            *sys.StdioFileReader
+		stdin                                  platform.File
 		expectedErrno                          wasip1.Errno
 		expectedMem                            []byte // at offset out
 		expectedLog                            string
@@ -161,14 +164,11 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "Read without explicit timeout (no tty)",
 			nsubscriptions:  1,
 			expectedNevents: 1,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady), // isatty
-			mem:           fdReadSub,
-			expectedErrno: wasip1.ErrnoSuccess,
-			out:           128, // past in
-			resultNevents: 512, // past out
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
+			mem:             fdReadSub,
+			expectedErrno:   wasip1.ErrnoSuccess,
+			out:             128, // past in
+			resultNevents:   512, // past out
 			expectedMem: []byte{
 				0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata
 				byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit
@@ -188,10 +188,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "20ms timeout, fdread on tty (buffer ready): both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerAlwaysReady), // isatty
+			stdin:           &ttyStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -226,10 +223,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "0ns timeout, fdread on tty (buffer ready): both are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerAlwaysReady), // isatty
+			stdin:           &ttyStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -264,10 +258,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "0ns timeout, fdread on regular file: both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady),
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -302,10 +293,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "1ns timeout, fdread on regular file: both events are written",
 			nsubscriptions:  2,
 			expectedNevents: 2,
-			stdioReader: sys.NewStdioFileReader(
-				strings.NewReader("test"),
-				stdinFileInfo(0o640),
-				sys.PollerAlwaysReady),
+			stdin:           &sys.StdinFile{Reader: strings.NewReader("test")},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -340,10 +328,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 			name:            "20ms timeout, fdread on blocked tty: only clock event is written",
 			nsubscriptions:  2,
 			expectedNevents: 1,
-			stdioReader: sys.NewStdioFileReader(
-				newBlockingReader(t),
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				sys.PollerNeverReady),
+			stdin:           &neverReadyTtyStdinFile{StdinFile: sys.StdinFile{Reader: newBlockingReader(t)}},
 			mem: concat(
 				clockNsSub(20*1000*1000),
 				fdReadSub,
@@ -379,13 +364,13 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			tconfig := wazero.NewModuleConfig().WithStdin(tc.stdioReader)
-			mod, r, log := requireProxyModule(t, tconfig)
+			mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
 			defer r.Close(testCtx)
 			defer log.Reset()
 
-			maskMemory(t, mod, 1024)
+			setStdin(t, mod, tc.stdin)
 
+			maskMemory(t, mod, 1024)
 			if tc.mem != nil {
 				mod.Memory().Write(tc.in, tc.mem)
 			}
@@ -409,18 +394,22 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 	}
 }
 
-func Test_pollOneoff_Zero(t *testing.T) {
-	poller := &poller{ready: true}
+func setStdin(t *testing.T, mod api.Module, stdin platform.File) {
+	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
+	f, ok := fsc.LookupFile(sys.FdStdin)
+	require.True(t, ok)
+	f.File = stdin
+}
 
-	tconfig := wazero.NewModuleConfig().WithStdin(sys.NewStdioFileReader(
-		strings.NewReader("test"),
-		stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-		poller))
+func Test_pollOneoff_Zero(t *testing.T) {
+	poller := &pollStdinFile{StdinFile: sys.StdinFile{Reader: strings.NewReader("test")}, ready: true}
 
-	mod, r, log := requireProxyModule(t, tconfig)
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig())
 	defer r.Close(testCtx)
 	defer log.Reset()
 
+	setStdin(t, mod, poller)
+
 	maskMemory(t, mod, 1024)
 
 	out := uint32(128)
@@ -539,14 +528,42 @@ func fdReadSubFd(fd byte) []byte {
 // subscription for an EventTypeFdRead on stdin
 var fdReadSub = fdReadSubFd(byte(sys.FdStdin))
 
-type poller struct {
+// ttyStat returns fs.ModeCharDevice as an approximation for isatty.
+// See go-isatty for a more specific approach:
+// https://github.com/mattn/go-isatty/blob/v0.0.18/isatty_tcgets.go#LL11C1-L12C1
+type ttyStat struct{}
+
+// Stat implements the same method as documented on platform.File
+func (ttyStat) Stat() (platform.Stat_t, syscall.Errno) {
+	return platform.Stat_t{
+		Mode:  fs.ModeDevice | fs.ModeCharDevice,
+		Nlink: 1,
+	}, 0
+}
+
+type ttyStdinFile struct {
+	sys.StdinFile
+	ttyStat
+}
+
+type neverReadyTtyStdinFile struct {
+	sys.StdinFile
+	ttyStat
+}
+
+// PollRead implements the same method as documented on platform.File
+func (neverReadyTtyStdinFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
+	time.Sleep(*timeout)
+	return false, 0
+}
+
+type pollStdinFile struct {
+	sys.StdinFile
+	ttyStat
 	ready bool
 }
 
-func (p *poller) Poll(d time.Duration) (bool, error) {
-	if p.ready {
-		return true, nil
-	} else {
-		return false, nil
-	}
+// PollRead implements the same method as documented on platform.File
+func (p *pollStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return p.ready, 0
 }
diff --git a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
index f2488f22..462a6e09 100644
--- a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/sys"
@@ -197,6 +198,10 @@ func testPreopen(t *testing.T, bin []byte) {
 }
 
 func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (console string) {
+	return compileAndRunWithStdin(t, config, bin, nil)
+}
+
+func compileAndRunWithStdin(t *testing.T, config wazero.ModuleConfig, bin []byte, stdin platform.File) (console string) {
 	// same for console and stderr as sometimes the stack trace is in one or the other.
 	var consoleBuf bytes.Buffer
 
@@ -206,8 +211,17 @@ func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (consol
 	_, err := wasi_snapshot_preview1.Instantiate(testCtx, r)
 	require.NoError(t, err)
 
-	_, err = r.InstantiateWithConfig(testCtx, bin,
-		config.WithStdout(&consoleBuf).WithStderr(&consoleBuf))
+	mod, err := r.InstantiateWithConfig(testCtx, bin, config.
+		WithStdout(&consoleBuf).
+		WithStderr(&consoleBuf).
+		WithStartFunctions()) // clear
+	require.NoError(t, err)
+
+	if stdin != nil {
+		setStdin(t, mod, stdin)
+	}
+
+	_, err = mod.ExportedFunction("_start").Call(testCtx)
 	if exitErr, ok := err.(*sys.ExitError); ok {
 		require.Zero(t, exitErr.ExitCode(), consoleBuf.String())
 	} else {
@@ -225,57 +239,42 @@ func Test_Poll(t *testing.T) {
 	tests := []struct {
 		name            string
 		args            []string
-		stdin           io.Reader
+		stdin           platform.File
 		expectedOutput  string
 		expectedTimeout time.Duration
 	}{
 		{
-			name: "custom reader, data ready, not tty",
-			args: []string{"wasi", "poll"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, not tty",
+			args:            []string{"wasi", "poll"},
+			stdin:           &internalsys.StdinFile{Reader: strings.NewReader("test")},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom reader, data ready, not tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, not tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &internalsys.StdinFile{Reader: strings.NewReader("test")},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom reader, data ready, tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				strings.NewReader("test"), // input ready
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "custom reader, data ready, tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: strings.NewReader("test")}},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
 		{
-			name: "custom, blocking reader, no data, tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				newBlockingReader(t), // simulate waiting for input
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerNeverReady),
+			name:            "custom, blocking reader, no data, tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &neverReadyTtyStdinFile{StdinFile: internalsys.StdinFile{Reader: newBlockingReader(t)}},
 			expectedOutput:  "NOINPUT",
 			expectedTimeout: 500 * time.Millisecond, // always timeouts
 		},
 		{
-			name: "eofReader, not tty, .5sec",
-			args: []string{"wasi", "poll", "0", "500"},
-			stdin: internalsys.NewStdioFileReader(
-				eofReader{}, // simulate waiting for input
-				stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640),
-				internalsys.PollerAlwaysReady),
+			name:            "eofReader, not tty, .5sec",
+			args:            []string{"wasi", "poll", "0", "500"},
+			stdin:           &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: eofReader{}}},
 			expectedOutput:  "STDIN",
 			expectedTimeout: 0 * time.Millisecond,
 		},
@@ -284,10 +283,8 @@ func Test_Poll(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			moduleConfig := wazero.NewModuleConfig().WithArgs(tc.args...).
-				WithStdin(tc.stdin)
 			start := time.Now()
-			console := compileAndRun(t, moduleConfig, wasmZigCc)
+			console := compileAndRunWithStdin(t, wazero.NewModuleConfig().WithArgs(tc.args...), wasmZigCc, tc.stdin)
 			elapsed := time.Since(start)
 			require.True(t, elapsed >= tc.expectedTimeout)
 			require.Equal(t, tc.expectedOutput+"\n", console)
diff --git a/imports/wasi_snapshot_preview1/wasi_test.go b/imports/wasi_snapshot_preview1/wasi_test.go
index a5b59b52..4b90867a 100644
--- a/imports/wasi_snapshot_preview1/wasi_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_test.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	_ "embed"
-	"io/fs"
 	"testing"
 	"time"
 
@@ -167,13 +166,3 @@ func (b blockingReader) Read(p []byte) (n int, err error) {
 	<-b.ctx.Done()
 	return 0, nil
 }
-
-// stdinFileInfo implements fs.FileInfo: it is only representing the mode because it is always stdin
-type stdinFileInfo uint32
-
-func (stdinFileInfo) Name() string        { return "stdin" }
-func (stdinFileInfo) Size() int64         { return 0 }
-func (s stdinFileInfo) Mode() fs.FileMode { return fs.FileMode(s) }
-func (stdinFileInfo) ModTime() time.Time  { return time.Unix(0, 0) }
-func (stdinFileInfo) IsDir() bool         { return false }
-func (stdinFileInfo) Sys() interface{}    { return nil }
diff --git a/internal/platform/file_test.go b/internal/platform/file_test.go
index bad8fed1..5ae32377 100644
--- a/internal/platform/file_test.go
+++ b/internal/platform/file_test.go
@@ -10,6 +10,7 @@ import (
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
+	"time"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -49,6 +50,24 @@ var (
 	emptyFile = "empty.txt"
 )
 
+func TestFsFileSetNonblock(t *testing.T) {
+	// Test using os.Pipe as it is known to support non-blocking reads.
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	defer r.Close()
+	defer w.Close()
+
+	rF := NewFsFile(readFile, syscall.O_RDONLY, r)
+
+	errno := rF.SetNonblock(true)
+	require.EqualErrno(t, 0, errno)
+	require.True(t, rF.IsNonblock())
+
+	errno = rF.SetNonblock(false)
+	require.EqualErrno(t, 0, errno)
+	require.False(t, rF.IsNonblock())
+}
+
 func TestFsFileIsDir(t *testing.T) {
 	dirFS, embedFS, mapFS := dirEmbedMapFS(t, t.TempDir())
 
@@ -140,6 +159,43 @@ func TestFsFileReadAndPread(t *testing.T) {
 	}
 }
 
+func TestFsFilePollRead(t *testing.T) {
+	// Test using os.Pipe as it is known to support poll.
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	defer r.Close()
+	defer w.Close()
+
+	rF := NewFsFile(readFile, syscall.O_RDONLY, r)
+	buf := make([]byte, 10)
+	timeout := time.Duration(0) // return immediately
+
+	// When there's nothing in the pipe, it isn't ready.
+	ready, errno := rF.PollRead(&timeout)
+	if runtime.GOOS == "windows" {
+		require.EqualErrno(t, syscall.ENOSYS, errno)
+		t.Skip("TODO: windows File.PollRead")
+	}
+	require.EqualErrno(t, 0, errno)
+	require.False(t, ready)
+
+	// Write to the pipe to make the data available
+	expected := []byte("wazero")
+	_, err = w.Write([]byte("wazero"))
+	require.NoError(t, err)
+
+	// We should now be able to poll ready
+	ready, errno = rF.PollRead(&timeout)
+	require.EqualErrno(t, 0, errno)
+	require.True(t, ready)
+
+	// We should now be able to read from the pipe
+	n, errno := rF.Read(buf)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, len(expected), n)
+	require.Equal(t, expected, buf[:len(expected)])
+}
+
 func requireRead(t *testing.T, f File, buf []byte) {
 	n, errno := f.Read(buf)
 	require.EqualErrno(t, 0, errno)
@@ -591,7 +647,7 @@ func testSync_NoError(t *testing.T, sync func(File) syscall.Errno) {
 	for _, tt := range tests {
 		tc := tt
 
-		t.Run(tc.name, func(b *testing.T) {
+		t.Run(tc.name, func(t *testing.T) {
 			require.EqualErrno(t, 0, sync(tc.f))
 		})
 	}
@@ -744,6 +800,85 @@ func TestFsFileUtimens(t *testing.T) {
 	})
 }
 
+func TestNewStdioFile(t *testing.T) {
+	// simulate regular file attached to stdin
+	f, err := os.CreateTemp(t.TempDir(), "somefile")
+	require.NoError(t, err)
+	defer f.Close()
+
+	stdin, err := NewStdioFile(true, os.Stdin)
+	require.NoError(t, err)
+	stdinStat, err := os.Stdin.Stat()
+	require.NoError(t, err)
+
+	stdinFile, err := NewStdioFile(true, f)
+	require.NoError(t, err)
+
+	stdout, err := NewStdioFile(false, os.Stdout)
+	require.NoError(t, err)
+	stdoutStat, err := os.Stdout.Stat()
+	require.NoError(t, err)
+
+	stdoutFile, err := NewStdioFile(false, f)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name string
+		f    File
+		// Depending on how the tests run, os.Stdin won't necessarily be a char
+		// device. We compare against an os.File, to account for this.
+		expectedType fs.FileMode
+	}{
+		{
+			name:         "stdin",
+			f:            stdin,
+			expectedType: stdinStat.Mode().Type(),
+		},
+		{
+			name:         "stdin file",
+			f:            stdinFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stdout",
+			f:            stdout,
+			expectedType: stdoutStat.Mode().Type(),
+		},
+		{
+			name:         "stdout file",
+			f:            stdoutFile,
+			expectedType: 0, // normal file
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name+" Stat", func(t *testing.T) {
+			st, errno := tc.f.Stat()
+			require.EqualErrno(t, 0, errno)
+			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
+			require.Equal(t, uint64(1), st.Nlink)
+
+			// Fake times are needed to pass wasi-testsuite.
+			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+			require.Zero(t, st.Ctim)
+			require.Zero(t, st.Mtim)
+			require.Zero(t, st.Atim)
+		})
+
+		t.Run(tc.name+" AccessMode", func(t *testing.T) {
+			accessMode := tc.f.AccessMode()
+			switch tc.f {
+			case stdin, stdinFile:
+				require.Equal(t, syscall.O_RDONLY, accessMode)
+			case stdout, stdoutFile:
+				require.Equal(t, syscall.O_WRONLY, accessMode)
+			}
+		})
+	}
+}
+
 func testEBADFIfDirClosed(t *testing.T, fn func(File) syscall.Errno) bool {
 	return t.Run("EBADF if dir closed", func(t *testing.T) {
 		d := openFsFile(t, t.TempDir(), syscall.O_RDONLY, 0o755)
diff --git a/internal/platform/select_test.go b/internal/platform/select_test.go
index 4a1ebe7e..fff86461 100644
--- a/internal/platform/select_test.go
+++ b/internal/platform/select_test.go
@@ -14,7 +14,7 @@ func TestSelect(t *testing.T) {
 	t.Run("should return immediately with no fds and duration 0", func(t *testing.T) {
 		for {
 			dur := time.Duration(0)
-			n, err := Select(0, nil, nil, nil, &dur)
+			n, err := _select(0, nil, nil, nil, &dur)
 			if err == syscall.EINTR {
 				t.Logf("Select interrupted")
 				continue
@@ -33,7 +33,7 @@ func TestSelect(t *testing.T) {
 			// updated by select(2). We are not accounting for this
 			// in our implementation.
 			start := time.Now()
-			n, err := Select(0, nil, nil, nil, &dur)
+			n, err := _select(0, nil, nil, nil, &dur)
 			took = time.Since(start)
 			if err == syscall.EINTR {
 				t.Logf("Select interrupted after %v", took)
@@ -73,7 +73,7 @@ func TestSelect(t *testing.T) {
 		rFdSet.Set(fd)
 
 		for {
-			n, err := Select(fd+1, rFdSet, nil, nil, nil)
+			n, err := _select(fd+1, rFdSet, nil, nil, nil)
 			if runtime.GOOS == "windows" {
 				// Not implemented for fds != wasiFdStdin
 				require.ErrorIs(t, err, syscall.ENOSYS)
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index d4d34beb..821c49d3 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -3,9 +3,7 @@ package sys
 import (
 	"embed"
 	"errors"
-	"io"
 	"io/fs"
-	"log"
 	"os"
 	"path"
 	"syscall"
@@ -18,12 +16,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-var (
-	noopStdin  = &FileEntry{Name: "stdin", File: platform.NewFsFile("", syscall.O_RDONLY, NewStdioFileReader(eofReader{}, noopStdinStat, PollerDefaultStdin))}
-	noopStdout = &FileEntry{Name: "stdout", File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: io.Discard, s: noopStdoutStat})}
-	noopStderr = &FileEntry{Name: "stderr", File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: io.Discard, s: noopStderrStat})}
-)
-
 //go:embed testdata
 var testdata embed.FS
 
@@ -61,7 +53,7 @@ func TestNewFSContext(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 
-		t.Run(tc.name, func(b *testing.T) {
+		t.Run(tc.name, func(t *testing.T) {
 			c := Context{}
 			err := c.NewFSContext(nil, nil, nil, tc.fs)
 			require.NoError(t, err)
@@ -139,8 +131,11 @@ func TestUnimplementedFSContext(t *testing.T) {
 	require.NoError(t, err)
 
 	expected := &FSContext{rootFS: sysfs.UnimplementedFS{}}
+	noopStdin, _ := stdinFile(nil)
 	expected.openedFiles.Insert(noopStdin)
+	noopStdout, _ := stdioWriterFile("stdout", nil)
 	expected.openedFiles.Insert(noopStdout)
+	noopStderr, _ := stdioWriterFile("stderr", nil)
 	expected.openedFiles.Insert(noopStderr)
 
 	t.Run("Close closes", func(t *testing.T) {
@@ -367,55 +362,131 @@ func TestFSContext_ChangeOpenFlag(t *testing.T) {
 	require.Equal(t, f2.openFlag&syscall.O_APPEND, 0)
 }
 
-func TestStdioStat(t *testing.T) {
-	stat, err := stdioStat(os.Stdin, noopStdinStat)
+func TestStdio(t *testing.T) {
+	// simulate regular file attached to stdin
+	f, err := os.CreateTemp(t.TempDir(), "somefile")
 	require.NoError(t, err)
-	stdinStatMode := stat.Mode()
-	// ensure we are consistent with sys stdin
-	osStdinStat, _ := os.Stdin.Stat()
-	osStdinMode := osStdinStat.Mode().Type()
-	require.Equal(t, osStdinMode&fs.ModeDevice, stdinStatMode&fs.ModeDevice)
-	require.Equal(t, osStdinMode&fs.ModeCharDevice, stdinStatMode&fs.ModeCharDevice)
-
-	stat, err = stdioStat(os.Stdout, noopStdoutStat)
-	stdoutStatMode := stat.Mode()
+	defer f.Close()
+
+	stdin, err := stdinFile(os.Stdin)
+	require.NoError(t, err)
+	stdinStat, err := os.Stdin.Stat()
 	require.NoError(t, err)
-	// ensure we are consistent with sys stdout
-	osStdoutStat, _ := os.Stdout.Stat()
-	osStdoutMode := osStdoutStat.Mode().Type()
-	require.Equal(t, osStdoutMode&fs.ModeDevice, stdoutStatMode&fs.ModeDevice)
-	require.Equal(t, osStdoutMode&fs.ModeCharDevice, stdoutStatMode&fs.ModeCharDevice)
 
-	stat, err = stdioStat(os.Stderr, noopStderrStat)
+	stdinNil, err := stdinFile(nil)
 	require.NoError(t, err)
-	stderrStatMode := stat.Mode()
-	// ensure we are consistent with sys stderr
-	osStderrStat, _ := os.Stderr.Stat()
-	osStderrMode := osStderrStat.Mode().Type()
-	require.Equal(t, osStderrMode&fs.ModeDevice, stderrStatMode&fs.ModeDevice)
-	require.Equal(t, osStderrMode&fs.ModeCharDevice, stderrStatMode&fs.ModeCharDevice)
 
-	// simulate regular file attached to stdin
-	f, err := os.CreateTemp("", "somefile")
-	if err != nil {
-		log.Fatal(err)
-	}
-	defer os.Remove(f.Name()) // clean up
-	stat, err = stdioStat(f, noopStdinStat)
+	stdinFile, err := stdinFile(f)
 	require.NoError(t, err)
-	fStat := stat.Mode()
-	osFStat, _ := f.Stat()
-	osFStatMode := osFStat.Mode()
-	require.Equal(t, osFStatMode&fs.ModeDevice, fStat&fs.ModeDevice)
-	require.Equal(t, osFStatMode&fs.ModeCharDevice, fStat&fs.ModeCharDevice)
-
-	// interface{} returns default
-	stat, err = stdioStat("whatevs", noopStdinStat)
+
+	stdout, err := stdioWriterFile("stdout", os.Stdout)
+	require.NoError(t, err)
+	stdoutStat, err := os.Stdout.Stat()
+	require.NoError(t, err)
+
+	stdoutNil, err := stdioWriterFile("stdout", nil)
+	require.NoError(t, err)
+
+	stdoutFile, err := stdioWriterFile("stdout", f)
 	require.NoError(t, err)
-	require.Equal(t, noopStdinStat, stat)
 
-	// nil *File returns err
-	var nilFile *os.File
-	_, err = stdioStat(nilFile, noopStdinStat)
-	require.Error(t, err)
+	stderr, err := stdioWriterFile("stderr", os.Stderr)
+	require.NoError(t, err)
+	stderrStat, err := os.Stderr.Stat()
+	require.NoError(t, err)
+
+	stderrNil, err := stdioWriterFile("stderr", nil)
+	require.NoError(t, err)
+
+	stderrFile, err := stdioWriterFile("stderr", f)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name string
+		f    *FileEntry
+		// Depending on how the tests run, os.Stdin won't necessarily be a char
+		// device. We compare against an os.File, to account for this.
+		expectedType fs.FileMode
+	}{
+		{
+			name:         "stdin",
+			f:            stdin,
+			expectedType: stdinStat.Mode().Type(),
+		},
+		{
+			name:         "stdin noop",
+			f:            stdinNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdin file",
+			f:            stdinFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stdout",
+			f:            stdout,
+			expectedType: stdoutStat.Mode().Type(),
+		},
+		{
+			name:         "stdout noop",
+			f:            stdoutNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdout file",
+			f:            stdoutFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stderr",
+			f:            stderr,
+			expectedType: stderrStat.Mode().Type(),
+		},
+		{
+			name:         "stderr noop",
+			f:            stderrNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stderr file",
+			f:            stderrFile,
+			expectedType: 0, // normal file
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name+" Stat", func(t *testing.T) {
+			st, errno := tc.f.Stat()
+			require.EqualErrno(t, 0, errno)
+			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
+			require.Equal(t, uint64(1), st.Nlink)
+
+			// Fake times are needed to pass wasi-testsuite.
+			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+			require.Zero(t, st.Ctim)
+			require.Zero(t, st.Mtim)
+			require.Zero(t, st.Atim)
+		})
+
+		buf := make([]byte, 5)
+		switch tc.f {
+		case stdinNil:
+			t.Run(tc.name+" returns zero on Read", func(t *testing.T) {
+				n, errno := tc.f.File.Read(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Zero(t, n) // like reading io.EOF
+			})
+		case stdoutNil, stderrNil:
+			// This is important because some code will loop forever attempting
+			// to write data. This happened in TestShortHash.
+			t.Run(tc.name+" returns length on Write", func(t *testing.T) {
+				n, errno := tc.f.File.Write(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Equal(t, len(buf), n) // like io.Discard
+			})
+		}
+	}
 }
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index 27e30e6b..24119687 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -53,17 +53,20 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, platform.FakeNanosleep, sysCtx.nanosleep)
 	require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource())
 
-	expectedOpenedFiles := FileTable{}
-	expectedOpenedFiles.Insert(noopStdin)
-	expectedOpenedFiles.Insert(noopStdout)
-	expectedOpenedFiles.Insert(noopStderr)
-	expectedOpenedFiles.Insert(&FileEntry{
+	expected := FileTable{}
+	noopStdin, _ := stdinFile(nil)
+	expected.Insert(noopStdin)
+	noopStdout, _ := stdioWriterFile("stdout", nil)
+	expected.Insert(noopStdout)
+	noopStderr, _ := stdioWriterFile("stderr", nil)
+	expected.Insert(noopStderr)
+	expected.Insert(&FileEntry{
 		IsPreopen: true,
 		Name:      "/",
 		FS:        testFS,
 		File:      &lazyDir{fs: testFS},
 	})
-	require.Equal(t, expectedOpenedFiles, sysCtx.FS().openedFiles)
+	require.Equal(t, expected, sysCtx.FS().openedFiles)
 }
 
 func TestFileEntryInode(t *testing.T) {