Repository: tetratelabs/wazero
Pull request #1599 (2023-07-29 02:53:23)

fsapi: migrates PollRead to Poll with Pflag
This migrates PollRead to Poll so that it can support read, write or a combination via flags. This should allow `poll_oneoff` to be implemented even if internally it may need to use a deadline decrementing approach to cycle through each file instead of via multiple at the same time.

The benefit of doing things this way is we don't need to top-level a concept of file descriptor, which complicates the design of `fsapi.FS` which is far easier to implement if there are no "multi-file" apis on it.

This intentionally doesn't implement the approach at the same time, as the goal is to create the api to implement. Doing so also puts a lot less pressure on the task to complete migration off syscall types.

evacchi: fixed the Windows build. 

Without a lower-level concept of fd/handle, we improve encapsulation/portability but we won't be able to multiplex I/O (e.g. in this specific case, we won't be able to `Poll([]{fd_1,...fd_n})`. Probably being concerned with performance is not worth it at this time (we are not really benchmarking it), but it's worth noting. Maybe something to add to `RATIONALE.md` (e.g. "why we encapsulate FDs" or something?)
codefromthecrypt: @evacchi I think by "why we encapsulate FDs" what you are asking is why do we expose a replacement for `fs.FS/fs.File`?

The primary use case of this abstraction is to be able to write delete, etc files. Currently, we continually break portability and if we don't stop doing that we can never expose it. I'll make notes on this.
codefromthecrypt: Also, it would be helpful when discussing what won't work, is to be very concrete. For example, some existing case (real not forged, like test we are failing) that cannot be solved as a poll loop or as a goroutine externally. We have a tendency to over-solve a lot and by making failing tests it helps conserve energy.
codefromthecrypt: @mathetake can you take a look? I had planned to do code and still have time today. I spent the last 3 hours explaining why our abstraction doesn't need to up front interfaces for every niche features, using multi-poll as an example of that.

Patch:
diff --git a/RATIONALE.md b/RATIONALE.md
index a0d07341..d8a1d12e 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -616,6 +616,135 @@ act differently and document `ModuleConfig` is more about emulating, not necessa
 
 ## File systems
 
+### Motivation on `sys.FS`
+
+The `sys.FS` abstraction in wazero was created because of limitations in
+`fs.FS`, and `fs.File` in Go. Compilers targeting `wasip1` may access
+functionality that writes new files. The ability to overcome this was requested
+even before wazero was named this, via issue #21 in March 2021.
+
+A month later, golang/go#45757 was raised by someone else on the same topic. As
+of July 2023, this has not resolved to a writeable file system abstraction.
+
+Over the next year more use cases accumulated, consolidated in March 2022 into
+#390. This closed in January 2023 with a milestone of providing more
+functionality, limited to users giving a real directory. This didn't yet expose
+a file abstraction for general purpose use. Internally, this used `os.File`.
+However, a wasm module instance is a virtual machine. Only supporting `os.File`
+breaks sand-boxing use cases. Moreover, `os.File` is not an interface. Even
+though this abstracts functionality, it does allow interception use cases.
+
+Hence, a few days later in January 2023, we had more issues asking to expose an
+abstraction, #1013 and later #1532, on use cases like masking access to files.
+In other words, the use case requests never stopped, and aren't solved by
+exposing only real files.
+
+In summary, the primary motivation for exposing a replacement for `fs.FS` and
+`fs.File` was around repetitive use case requests for years, around
+interception and the ability to create new files, both virtual and real files.
+While some use cases are solved with real files, not all are. Regardless, an
+interface approach is necessary to ensure users can intercept I/O operations.
+
+### Why doesn't `sys.File` have a `Fd()` method?
+
+There are many features we could expose. We could make File expose underlying
+file descriptors in case they are supported, for integration of system calls
+that accept multiple ones, namely `poll` for multiplexing. This special case is
+described in a subsequent section.
+
+As noted above, users have been asking for a file abstraction for over two
+years, and a common answer was to wait. Making users wait is a problem,
+especially so long. Good reasons to make people wait are stabilization. Edge
+case features are not a great reason to hold abstractions from users.
+
+Another reason is implementation difficulty. Go did not attempt to abstract
+file descriptors. For example, unlike `fs.ReadFile` there is no `fs.FdFile`
+interface. Most likely, this is because file descriptors are an implementation
+detail of common features. Programming languages, including Go, do not require
+end users to know about file descriptors. Types such as `fs.File` can be used
+without any knowledge of them. Implementations may or may not have file
+descriptors. For example, in Go, `os.DirFS` has underlying file descriptors
+while `embed.FS` does not.
+
+Despite this, some may want to expose a non-standard interface because
+`os.File` has `Fd() uintptr` to return a file descriptor. Mainly, this is
+handy to integrate with `syscall` package functions (on `GOOS` values that
+declare them). Notice, though that `uintptr` is unsafe and not an abstraction.
+Close inspection will find some `os.File` types internally use `poll.FD`
+instead, yet this is not possible to use abstractly because that type is not
+exposed. For example, `plan9` uses a different type than `poll.FD`. In other
+words, even in real files, `Fd()` is not wholly portable, despite it being
+useful on many operating systems with the `syscall` package.
+
+The reasons above, why Go doesn't abstract `FdFile` interface are a subset of
+reasons why `sys.File` does not. If we exposed `File.Fd()` we not only would
+have to declare all the edge cases that Go describes including impact of
+finalizers, we would have to describe these in terms of virtualized files.
+Then, we would have to reason with this value vs our existing virtualized
+`sys.FileTable`, mapping whatever type we return to keys in that table, also
+in consideration of garbage collection impact. The combination of issues like
+this could lead down a path of not implementing a file system abstraction at
+all, and instead a weak key mapped abstraction of the `syscall` package. Once
+we finished with all the edge cases, we would have lost context of the original
+reason why we started.. simply to allow file write access!
+
+When wazero attempts to do more than what the Go programming language team, it
+has to be carefully evaluated, to:
+* Be possible to implement at least for `os.File` backed files
+* Not be confusing or cognitively hard for virtual file systems and normal use.
+* Affordable: custom code is solely the responsible by the core team, a much
+  smaller group of individuals than who maintain the Go programming language.
+
+Due to problems well known in Go, consideration of the end users who constantly
+ask for basic file system functionality, and the difficulty virtualizing file
+descriptors at multiple levels, we don't expose `Fd()` and likely won't ever
+expose `Fd()` on `sys.File`.
+
+### Why does `sys.File` have a `Poll()` method, while `sys.FS` does not?
+
+wazero exposes `File.Poll` which allows one-at-a-time poll use cases,
+requested by multiple users. This not only includes abstract tests such as
+Go 1.21 `GOOS=wasip1`, but real use cases including python and container2wasm
+repls, as well listen sockets. The main use cases is non-blocking poll on a
+single file. Being a single file, this has no risk of problems such as
+head-of-line blocking, even when emulated.
+
+The main use case of multi-poll are bidirectional network services, something
+not used in `GOOS=wasip1` standard libraries, but could be in the future.
+Moving forward without a multi-poller allows wazero to expose its file system
+abstraction instead of continuing to hold back it back for edge cases. We'll
+continue discussion below regardless, as rationale was requested.
+
+You can loop through multiple `sys.File`, using `File.Poll` to see if an event
+is ready, but there is a head-of-line blocking problem. If a long timeout is
+used, bad luck could have a file that has nothing to read or write before one
+that does. This could cause more blocking than necessary, even if you could
+poll the others just after with a zero timeout. What's worse than this is if
+unlimited blocking was used (`timeout=-1`). The host implementations could use
+goroutines to avoid this, but interrupting a "forever" poll is problematic. All
+of these are reasons to consider a multi-poll API, but do not require exporting
+`File.Fd()`.
+
+Should multi-poll becomes critical, `sys.FS` could expose a `Poll` function
+like below, despite it being the non-portable, complicated if possible to
+implement on all platforms and virtual file systems.
+```go
+ready, errno := fs.Poll([]sys.PollFile{{f1, sys.POLLIN}, {f2, sys.POLLOUT}}, timeoutMillis)
+```
+
+A real filesystem could handle this by using an approach like the internal
+`unix.Poll` function in Go, passing file descriptors on unix platforms, or
+returning `sys.ENOSYS` for unsupported operating systems. Implementation for
+virtual files could have a strategy around timeout to avoid the worst case of
+head-of-line blocking (unlimited timeout).
+
+Let's remember that when designing abstractions, it is not best to add an
+interface for everything. Certainly, Go doesn't, as evidenced by them not
+exposing `poll.FD` in `os.File`! Such a multi-poll could be limited to
+built-in filesystems in the wazero repository, avoiding complexity of trying to
+support and test this abstractly. This would still permit multiplexing for CLI
+users, and also permit single file polling as exists now.
+
 ### Why doesn't wazero implement the working directory?
 
 An early design of wazero's API included a `WithWorkDirFS` which allowed
diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go
index b6dc758e..3249809d 100644
--- a/imports/wasi_snapshot_preview1/poll.go
+++ b/imports/wasi_snapshot_preview1/poll.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental/sys"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasip1"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -174,7 +175,7 @@ func pollOneoffFn(_ context.Context, mod api.Module, params []uint64) sys.Errno
 			return sys.EBADF
 		}
 		// Wait for the timeout to expire, or for some data to become available on Stdin.
-		stdinReady, errno := stdin.File.PollRead(int32(timeout.Milliseconds()))
+		stdinReady, errno := stdin.File.Poll(fsapi.POLLIN, int32(timeout.Milliseconds()))
 		if errno != 0 {
 			return errno
 		}
diff --git a/internal/fsapi/dir.go b/internal/fsapi/dir.go
index a68a3c47..32cfc56f 100644
--- a/internal/fsapi/dir.go
+++ b/internal/fsapi/dir.go
@@ -87,8 +87,8 @@ func (DirFile) Pread([]byte, int64) (int, experimentalsys.Errno) {
 	return 0, experimentalsys.EISDIR
 }
 
-// PollRead implements File.PollRead
-func (DirFile) PollRead(int32) (ready bool, errno experimentalsys.Errno) {
+// Poll implements File.Poll
+func (DirFile) Poll(Pflag, int32) (ready bool, errno experimentalsys.Errno) {
 	return false, experimentalsys.ENOSYS
 }
 
diff --git a/internal/fsapi/file.go b/internal/fsapi/file.go
index dd97a731..cd0ccfba 100644
--- a/internal/fsapi/file.go
+++ b/internal/fsapi/file.go
@@ -205,22 +205,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 experimentalsys.Errno)
 
-	// PollRead returns if the file has data ready to be read or an error.
+	// Poll returns if the file has data ready to be read or written.
 	//
 	// # Parameters
 	//
-	// The `timeoutMillis` parameter is how long to block for data to become
-	// readable, or interrupted, in milliseconds. There are two special values:
+	// The `flag` parameter determines which event to await, such as POLLIN,
+	// POLLOUT, or a combination like `POLLIN|POLLOUT`.
+	//
+	// The `timeoutMillis` parameter is how long to block for an event, or
+	// interrupted, in milliseconds. There are two special values:
 	//   - zero returns immediately
 	//   - any negative value blocks any amount of time
 	//
 	// # Results
 	//
-	// `ready` means there was data ready to read or false if not or when
-	// `errno` is not zero.
+	// `ready` means there was data ready to read or written. False can mean no
+	// event was ready or `errno` is not zero.
 	//
 	// A zero `errno` is success. The below are expected otherwise:
 	//   - sys.ENOSYS: the implementation does not support this function.
+	//   - sys.ENOTSUP: the implementation does not the flag combination.
 	//   - sys.EINTR: the call was interrupted prior to an event.
 	//
 	// # Notes
@@ -228,9 +232,9 @@ type File interface {
 	//   - 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, as data will never become readable.
+	//     immediately true, as data will never become available.
 	//   - See /RATIONALE.md for detailed notes including impact of blocking.
-	PollRead(timeoutMillis int32) (ready bool, errno experimentalsys.Errno)
+	Poll(flag Pflag, timeoutMillis int32) (ready bool, errno experimentalsys.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
diff --git a/internal/fsapi/poll.go b/internal/fsapi/poll.go
new file mode 100644
index 00000000..25f7c571
--- /dev/null
+++ b/internal/fsapi/poll.go
@@ -0,0 +1,20 @@
+package fsapi
+
+// Pflag are bit flags used for File.Poll. Values, including zero, should not
+// be interpreted numerically. Instead, use by constants prefixed with 'POLL'.
+//
+// # Notes
+//
+//   - This is like `pollfd.events` flags for `poll` in POSIX. See
+//     https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/poll.h.html
+type Pflag uint32
+
+// Only define bitflags we support and are needed by `poll_oneoff` in wasip1
+// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#eventrwflags
+const (
+	// POLLIN is a read event.
+	POLLIN Pflag = 1 << iota
+
+	// POLLOUT is a write event.
+	POLLOUT
+)
diff --git a/internal/fsapi/unimplemented.go b/internal/fsapi/unimplemented.go
index 883463dd..77567a24 100644
--- a/internal/fsapi/unimplemented.go
+++ b/internal/fsapi/unimplemented.go
@@ -153,8 +153,8 @@ func (UnimplementedFile) Readdir(int) (dirents []Dirent, errno experimentalsys.E
 	return nil, experimentalsys.ENOSYS
 }
 
-// PollRead implements File.PollRead
-func (UnimplementedFile) PollRead(int32) (ready bool, errno experimentalsys.Errno) {
+// Poll implements File.Poll
+func (UnimplementedFile) Poll(Pflag, int32) (ready bool, errno experimentalsys.Errno) {
 	return false, experimentalsys.ENOSYS
 }
 
diff --git a/internal/sys/stdio.go b/internal/sys/stdio.go
index 0d80e155..84ebb06c 100644
--- a/internal/sys/stdio.go
+++ b/internal/sys/stdio.go
@@ -48,8 +48,11 @@ func (noopStdinFile) Read([]byte) (int, experimentalsys.Errno) {
 	return 0, 0 // Always EOF
 }
 
-// PollRead implements the same method as documented on fsapi.File
-func (noopStdinFile) PollRead(int32) (ready bool, errno experimentalsys.Errno) {
+// Poll implements the same method as documented on fsapi.File
+func (noopStdinFile) Poll(flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
+	if flag != fsapi.POLLIN {
+		return false, experimentalsys.ENOTSUP
+	}
 	return true, 0 // always ready to read nothing
 }
 
diff --git a/internal/sysfs/osfile.go b/internal/sysfs/osfile.go
index d7f13983..410a4d0e 100644
--- a/internal/sysfs/osfile.go
+++ b/internal/sysfs/osfile.go
@@ -180,9 +180,9 @@ func (f *osFile) Seek(offset int64, whence int) (newOffset int64, errno experime
 	return
 }
 
-// PollRead implements the same method as documented on fsapi.File
-func (f *osFile) PollRead(timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
-	return pollRead(f.fd, timeoutMillis)
+// Poll implements the same method as documented on fsapi.File
+func (f *osFile) Poll(flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
+	return poll(f.fd, flag, timeoutMillis)
 }
 
 // Readdir implements File.Readdir. Notably, this uses "Readdir", not
diff --git a/internal/sysfs/poll.go b/internal/sysfs/poll.go
index c5db6b9f..09c14b9a 100644
--- a/internal/sysfs/poll.go
+++ b/internal/sysfs/poll.go
@@ -2,12 +2,17 @@
 
 package sysfs
 
-import "github.com/tetratelabs/wazero/experimental/sys"
+import (
+	"github.com/tetratelabs/wazero/experimental/sys"
+	"github.com/tetratelabs/wazero/internal/fsapi"
+)
 
-// pollRead implements `PollRead` as documented on fsapi.File via a file
-// descriptor.
-func pollRead(fd uintptr, timeoutMillis int32) (ready bool, errno sys.Errno) {
+// poll implements `Poll` as documented on fsapi.File via a file descriptor.
+func poll(fd uintptr, flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno sys.Errno) {
+	if flag != fsapi.POLLIN {
+		return false, sys.ENOTSUP
+	}
 	fds := []pollFd{newPollFd(fd, _POLLIN, 0)}
-	count, errno := poll(fds, timeoutMillis)
+	count, errno := _poll(fds, timeoutMillis)
 	return count > 0, errno
 }
diff --git a/internal/sysfs/poll_darwin.go b/internal/sysfs/poll_darwin.go
index 3f076d41..1f7f8909 100644
--- a/internal/sysfs/poll_darwin.go
+++ b/internal/sysfs/poll_darwin.go
@@ -24,8 +24,8 @@ func newPollFd(fd uintptr, events, revents int16) pollFd {
 // _POLLIN subscribes a notification when any readable data is available.
 const _POLLIN = 0x0001
 
-// poll implements poll on Darwin via the corresponding libc function.
-func poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
+// _poll implements poll on Darwin via the corresponding libc function.
+func _poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
 	var fdptr *pollFd
 	nfds := len(fds)
 	if nfds > 0 {
diff --git a/internal/sysfs/poll_linux.go b/internal/sysfs/poll_linux.go
index 17fe4534..dab7bb2c 100644
--- a/internal/sysfs/poll_linux.go
+++ b/internal/sysfs/poll_linux.go
@@ -26,8 +26,8 @@ func newPollFd(fd uintptr, events, revents int16) pollFd {
 // _POLLIN subscribes a notification when any readable data is available.
 const _POLLIN = 0x0001
 
-// poll implements poll on Linux via ppoll.
-func poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
+// _poll implements poll on Linux via ppoll.
+func _poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
 	var ts syscall.Timespec
 	if timeoutMillis >= 0 {
 		ts = syscall.NsecToTimespec(int64(time.Duration(timeoutMillis) * time.Millisecond))
diff --git a/internal/sysfs/poll_unsupported.go b/internal/sysfs/poll_unsupported.go
index 3ba430bc..0d07492b 100644
--- a/internal/sysfs/poll_unsupported.go
+++ b/internal/sysfs/poll_unsupported.go
@@ -2,10 +2,12 @@
 
 package sysfs
 
-import "github.com/tetratelabs/wazero/experimental/sys"
+import (
+	"github.com/tetratelabs/wazero/experimental/sys"
+	"github.com/tetratelabs/wazero/internal/fsapi"
+)
 
-// pollRead implements `PollRead` as documented on fsapi.File via a file
-// descriptor.
-func pollRead(fd uintptr, timeoutMillis int32) (ready bool, errno sys.Errno) {
+// poll implements `Poll` as documented on fsapi.File via a file descriptor.
+func poll(fd uintptr, flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno sys.Errno) {
 	return false, sys.ENOSYS
 }
diff --git a/internal/sysfs/poll_windows.go b/internal/sysfs/poll_windows.go
index 4cc85f21..9b1cab9f 100644
--- a/internal/sysfs/poll_windows.go
+++ b/internal/sysfs/poll_windows.go
@@ -40,7 +40,7 @@ func newPollFd(fd uintptr, events, revents int16) pollFd {
 // pollInterval is the interval between each calls to peekNamedPipe in selectAllHandles
 const pollInterval = 100 * time.Millisecond
 
-// poll implements poll on Windows, for a subset of cases.
+// _poll implements poll on Windows, for a subset of cases.
 //
 // pollWithContext emulates the behavior of POSIX poll(2) on Windows, for a subset of cases,
 // and it supports context cancellation.
@@ -58,7 +58,7 @@ const pollInterval = 100 * time.Millisecond
 //
 // The duration may be negative, in which case it will wait indefinitely. The given ctx is
 // used to allow for cancellation, and it is currently used only in tests.
-func poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
+func _poll(fds []pollFd, timeoutMillis int32) (n int, errno sys.Errno) {
 	if fds == nil {
 		return -1, sys.ENOSYS
 	}
diff --git a/internal/sysfs/sock_windows.go b/internal/sysfs/sock_windows.go
index efc7be53..db8c6ac6 100644
--- a/internal/sysfs/sock_windows.go
+++ b/internal/sysfs/sock_windows.go
@@ -118,7 +118,7 @@ type winTcpListenerFile struct {
 func (f *winTcpListenerFile) Accept() (socketapi.TCPConn, sys.Errno) {
 	// Ensure we have an incoming connection using winsock_select.
 	n, errno := syscallConnControl(f.tl, func(fd uintptr) (int, sys.Errno) {
-		return poll([]pollFd{newPollFd(fd, _POLLIN, 0)}, 0)
+		return _poll([]pollFd{newPollFd(fd, _POLLIN, 0)}, 0)
 	})
 
 	// Otherwise return immediately.


Test patch:
diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go
index bb942f66..07fa6d11 100644
--- a/imports/wasi_snapshot_preview1/poll_test.go
+++ b/imports/wasi_snapshot_preview1/poll_test.go
@@ -554,8 +554,11 @@ type neverReadyTtyStdinFile struct {
 	ttyStat
 }
 
-// PollRead implements the same method as documented on fsapi.File
-func (neverReadyTtyStdinFile) PollRead(timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
+// Poll implements the same method as documented on fsapi.File
+func (neverReadyTtyStdinFile) Poll(flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
+	if flag != fsapi.POLLIN {
+		return false, experimentalsys.ENOTSUP
+	}
 	switch {
 	case timeoutMillis <= 0:
 		return
@@ -570,7 +573,10 @@ type pollStdinFile struct {
 	ready bool
 }
 
-// PollRead implements the same method as documented on fsapi.File
-func (p *pollStdinFile) PollRead(int32) (ready bool, errno experimentalsys.Errno) {
+// Poll implements the same method as documented on fsapi.File
+func (p *pollStdinFile) Poll(flag fsapi.Pflag, timeoutMillis int32) (ready bool, errno experimentalsys.Errno) {
+	if flag != fsapi.POLLIN {
+		return false, experimentalsys.ENOTSUP
+	}
 	return p.ready, 0
 }
diff --git a/internal/sysfs/file_test.go b/internal/sysfs/file_test.go
index a6a36642..0c1d35be 100644
--- a/internal/sysfs/file_test.go
+++ b/internal/sysfs/file_test.go
@@ -319,7 +319,9 @@ func TestFileReadAndPread(t *testing.T) {
 	}
 }
 
-func TestFilePollRead(t *testing.T) {
+func TestFilePoll_POLLIN(t *testing.T) {
+	pflag := fsapi.POLLIN
+
 	// Test using os.Pipe as it is known to support poll.
 	r, w, err := os.Pipe()
 	require.NoError(t, err)
@@ -332,7 +334,7 @@ func TestFilePollRead(t *testing.T) {
 	timeout := int32(0) // return immediately
 
 	// When there's nothing in the pipe, it isn't ready.
-	ready, errno := rF.PollRead(timeout)
+	ready, errno := rF.Poll(pflag, timeout)
 	require.EqualErrno(t, 0, errno)
 	require.False(t, ready)
 
@@ -342,7 +344,7 @@ func TestFilePollRead(t *testing.T) {
 	require.NoError(t, err)
 
 	// We should now be able to poll ready
-	ready, errno = rF.PollRead(timeout)
+	ready, errno = rF.Poll(pflag, timeout)
 	require.EqualErrno(t, 0, errno)
 	require.True(t, ready)
 
@@ -353,6 +355,25 @@ func TestFilePollRead(t *testing.T) {
 	require.Equal(t, expected, buf[:len(expected)])
 }
 
+func TestFilePoll_POLLOUT(t *testing.T) {
+	pflag := fsapi.POLLOUT
+
+	// 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()
+
+	wF, err := NewStdioFile(false, w)
+	require.NoError(t, err)
+	timeout := int32(0) // return immediately
+
+	// We don't yet implement write blocking.
+	ready, errno := wF.Poll(pflag, timeout)
+	require.EqualErrno(t, experimentalsys.ENOTSUP, errno)
+	require.False(t, ready)
+}
+
 func requireRead(t *testing.T, f fsapi.File, buf []byte) {
 	n, errno := f.Read(buf)
 	require.EqualErrno(t, 0, errno)
diff --git a/internal/sysfs/poll_test.go b/internal/sysfs/poll_test.go
index 3d0189a4..192b7b0e 100644
--- a/internal/sysfs/poll_test.go
+++ b/internal/sysfs/poll_test.go
@@ -1,3 +1,5 @@
+//go:build windows || linux || darwin
+
 package sysfs
 
 import (
@@ -9,9 +11,9 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func TestPoll(t *testing.T) {
+func Test_poll(t *testing.T) {
 	t.Run("should return immediately with no fds and duration 0", func(t *testing.T) {
-		n, err := poll([]pollFd{}, 0)
+		n, err := _poll([]pollFd{}, 0)
 		require.EqualErrno(t, 0, err)
 		require.Equal(t, 0, n)
 	})
@@ -23,7 +25,7 @@ func TestPoll(t *testing.T) {
 		// updated by select(2). We are not accounting for this
 		// in our implementation.
 		start := time.Now()
-		n, err := poll([]pollFd{}, dur)
+		n, err := _poll([]pollFd{}, dur)
 		took = time.Since(start)
 		require.EqualErrno(t, 0, err)
 		require.Equal(t, 0, n)
@@ -53,7 +55,7 @@ func TestPoll(t *testing.T) {
 		require.NoError(t, err)
 
 		fds := []pollFd{newPollFd(rr.Fd(), _POLLIN, 0)}
-		n, err := poll(fds, 0)
+		n, err := _poll(fds, 0)
 		require.EqualErrno(t, 0, err)
 		require.Equal(t, 1, n)
 	})
diff --git a/internal/sysfs/poll_windows_test.go b/internal/sysfs/poll_windows_test.go
index e7c7f15a..d137d6f8 100644
--- a/internal/sysfs/poll_windows_test.go
+++ b/internal/sysfs/poll_windows_test.go
@@ -20,13 +20,13 @@ func TestPoll_Windows(t *testing.T) {
 	pollToChannel := func(fd uintptr, timeoutMillis int32, ch chan result) {
 		r := result{}
 		fds := []pollFd{{fd: fd, events: _POLLIN}}
-		r.n, r.err = poll(fds, timeoutMillis)
+		r.n, r.err = _poll(fds, timeoutMillis)
 		ch <- r
 		close(ch)
 	}
 
 	t.Run("poll returns sys.ENOSYS when n == 0 and timeoutMillis is negative", func(t *testing.T) {
-		n, errno := poll(nil, -1)
+		n, errno := _poll(nil, -1)
 		require.Equal(t, -1, n)
 		require.EqualErrno(t, sys.ENOSYS, errno)
 	})
@@ -73,7 +73,7 @@ func TestPoll_Windows(t *testing.T) {
 
 		fds := []pollFd{{fd: f.Fd()}}
 
-		n, errno := poll(fds, 0)
+		n, errno := _poll(fds, 0)
 		require.Zero(t, errno)
 		require.Equal(t, 1, n)
 	})
@@ -135,7 +135,7 @@ func TestPoll_Windows(t *testing.T) {
 		r, _, err := os.Pipe()
 		require.NoError(t, err)
 		fds := []pollFd{{fd: r.Fd(), events: _POLLIN}}
-		n, err := poll(fds, 0)
+		n, err := _poll(fds, 0)
 		require.Zero(t, err)
 		require.Zero(t, n)
 	})
@@ -153,7 +153,7 @@ func TestPoll_Windows(t *testing.T) {
 		require.NoError(t, err)
 
 		// Verify that the write is reported.
-		n, err := poll(fds, 0)
+		n, err := _poll(fds, 0)
 		require.Zero(t, err)
 		require.Equal(t, 1, n)
 	})
@@ -269,7 +269,7 @@ func TestPoll_Windows(t *testing.T) {
 		defer f.Close()
 		require.NoError(t, err)
 		fds := []pollFd{{fd: f.Fd(), events: _POLLIN}}
-		n, errno := poll(fds, 0)
+		n, errno := _poll(fds, 0)
 		require.Zero(t, errno)
 		require.Equal(t, 1, n)
 	})