Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wasi: nonblocking I/O for sockets and pipes on Windows #1579

Merged
merged 11 commits into from
Jul 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions RATIONALE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,7 @@ However, if the reader is detected to read from `os.Stdin`,
a special code path is followed, invoking `platform.Select()`.

`platform.Select()` is a wrapper for `select(2)` on POSIX systems,
and it is mocked for a handful of cases also on Windows.
and it is emulated on Windows.

### Select on POSIX

Expand Down Expand Up @@ -1303,25 +1303,35 @@ unless data becomes available on `Stdin` itself.

### Select on Windows

On Windows the `platform.Select()` is much more straightforward,
and it really just replicates the behavior found in the general cases
for `FdRead` subscriptions: in other words, the subscription to `Stdin`
is immediately acknowledged.
On Windows `platform.Select()` cannot be delegated to a single
syscall, because there is no single syscall to handle sockets,
pipes and regular files.

The implementation also support a timeout, but in this case
it relies on `time.Sleep()`, which notably, as compared to the POSIX
case, interruptible and compatible with goroutines.
Instead, we emulate its behavior for the cases that are currently
of interest.

However, because `Stdin` subscriptions are always acknowledged
without wait and because this code path is always followed only
when at least one `Stdin` subscription is present, then the
timeout is effectively always handled externally.
- For regular files, we _always_ report them as ready, as
[most operating systems do anyway][async-io-windows].

In any case, the behavior of `platform.Select` on Windows
is sensibly different from the behavior on POSIX platforms;
we plan to refine and further align it in semantics in the future.
- For pipes, we iterate on the given `readfds`
and we invoke [`PeekNamedPipe`][peeknamedpipe]. We currently ignore
`writefds` and `exceptfds` for pipes. In particular,
`Stdin`, when present, is set to the `readfds` FdSet.

- Notably, we include also support for sockets using the [WinSock
implementation of `select`][winsock-select], but instead
of relying on the timeout argument of the `select` function,
we set a 0-duration timeout so that it behaves like a peek.

This way, we can check for regular files all at once,
at the beginning of the function, then we poll pipes and
sockets periodically using a cancellable `time.Tick`,
which plays nicely with the rest of the Go runtime.

[poll_oneoff]: https://github.com/WebAssembly/wasi-poll#why-is-the-function-called-poll_oneoff
[async-io-windows]: https://tinyclouds.org/iocp_links
[peeknamedpipe]: https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-peeknamedpipe
[winsock-select]: https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select

## Signed encoding of integer global constant initializers

Expand Down
6 changes: 5 additions & 1 deletion experimental/sys/syscall_errno_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const (
// _ERROR_DIRECTORY is a Windows error returned by syscall.Rmdir
// instead of syscall.ENOTDIR
_ERROR_DIRECTORY = syscall.Errno(0x10B)

// _ERROR_INVALID_SOCKET is a Windows error returned by winsock_select
// when a given handle is not a socket.
_ERROR_INVALID_SOCKET = syscall.Errno(0x2736)
)

func errorToErrno(err error) Errno {
Expand All @@ -39,7 +43,7 @@ func errorToErrno(err error) Errno {
return ENOTEMPTY
case syscall.ERROR_FILE_EXISTS:
return EEXIST
case _ERROR_INVALID_HANDLE:
case _ERROR_INVALID_HANDLE, _ERROR_INVALID_SOCKET:
return EBADF
case syscall.ERROR_ACCESS_DENIED:
// POSIX read and write functions expect EBADF, not EACCES when not
Expand Down
7 changes: 2 additions & 5 deletions imports/wasi_snapshot_preview1/wasi_stdlib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"os"
"os/exec"
"path"
"runtime"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -478,13 +477,11 @@ func testSock(t *testing.T, bin []byte) {
console := <-ch
require.NotEqual(t, 0, n)
require.NoError(t, err)
require.Equal(t, "wazero\n", console)
// Nonblocking connections may contain error logging, we ignore those.
require.Equal(t, "wazero\n", console[len(console)-7:])
}

func Test_HTTP(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fsapi.Nonblocking() is not supported on wasip1+windows.")
}
toolchains := map[string][]byte{}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
Expand Down
2 changes: 2 additions & 0 deletions internal/platform/fdset.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !windows

package platform

// Set adds the given fd to the set.
Expand Down
7 changes: 2 additions & 5 deletions internal/platform/fdset_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
//go:build !windows

package platform

import (
"runtime"
"testing"

"github.com/tetratelabs/wazero/internal/testing/require"
)

func TestFdSet(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("not supported")
}

allBitsSetAtIndex0 := FdSet{}
allBitsSetAtIndex0.Bits[0] = -1

Expand Down
2 changes: 1 addition & 1 deletion internal/platform/fdset_unsupported.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build !darwin && !linux
//go:build !darwin && !linux && !windows

package platform

Expand Down
239 changes: 239 additions & 0 deletions internal/platform/fdset_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package platform

import (
"syscall"
"unsafe"
)

var procGetNamedPipeInfo = kernel32.NewProc("GetNamedPipeInfo")

// Maximum number of fds in a WinSockFdSet.
const _FD_SETSIZE = 64

// WinSockFdSet implements the FdSet representation that is used internally by WinSock.
//
// Note: this representation is quite different from the one used in most POSIX implementations
// where a bitfield is usually implemented; instead on Windows we have a simpler array+count pair.
// Notice that because it keeps a count of the inserted handles, the first argument of select
// in WinSock is actually ignored.
//
// The implementation of the Set, Clear, IsSet, Zero, methods follows exactly
// the real implementation found in WinSock2.h, e.g. see:
// https://github.com/microsoft/win32metadata/blob/ef7725c75c6b39adfdc13ba26fb1d89ac954449a/generation/WinSDK/RecompiledIdlHeaders/um/WinSock2.h#L124-L175
type WinSockFdSet struct {
// count is the number of used slots used in the handles slice.
count uint64
// handles is the array of handles. This is called "array" in the WinSock implementation
// and it has a fixed length of _FD_SETSIZE.
handles [_FD_SETSIZE]syscall.Handle
}

// FdSet implements the same methods provided on other plaforms.
//
// Note: the implementation is very different from POSIX; Windows provides
// POSIX select only for sockets. We emulate a select for other APIs in the sysfs
// package, but we still want to use the "real" select in the case of sockets.
// So, we keep separate FdSets for sockets, pipes and regular files, so that we can
// handle them separately. For instance sockets can be used directly in winsock select.
type FdSet struct {
sockets WinSockFdSet
pipes WinSockFdSet
regular WinSockFdSet
}

// SetAll overwrites all the fields in f with the fields in g.
func (f *FdSet) SetAll(g *FdSet) {
if f == nil {
return
}
f.sockets = g.sockets
f.pipes = g.pipes
f.regular = g.regular
}

// Sockets returns a WinSockFdSet containing the handles in this FdSet that are sockets.
func (f *FdSet) Sockets() *WinSockFdSet {
if f == nil {
return nil
}
return &f.sockets
}

// Regular returns a WinSockFdSet containing the handles in this FdSet that are regular files.
func (f *FdSet) Regular() *WinSockFdSet {
if f == nil {
return nil
}
return &f.regular
}

// Pipes returns a WinSockFdSet containing the handles in this FdSet that are pipes.
func (f *FdSet) Pipes() *WinSockFdSet {
if f == nil {
return nil
}
return &f.pipes
}

// getFdSetFor returns a pointer to the right fd set for the given fd.
// It checks the type for fd and returns the field for pipes, regular or sockets
// to simplify code.
//
// For instance, if fd is a socket and it must be set if f.pipes, instead
// of writing:
//
// if isSocket(fd) {
// f.sockets.Set(fd)
// }
//
// It is possible to write:
//
// f.getFdSetFor(fd).Set(fd)
func (f *FdSet) getFdSetFor(fd int) *WinSockFdSet {
h := syscall.Handle(fd)
t, err := syscall.GetFileType(h)
if err != nil {
return nil
}
switch t {
case syscall.FILE_TYPE_CHAR, syscall.FILE_TYPE_DISK:
return &f.regular
case syscall.FILE_TYPE_PIPE:
if isSocket(h) {
return &f.sockets
} else {
return &f.pipes
}
default:
return nil
}
}

// Set adds the given fd to the set.
func (f *FdSet) Set(fd int) {
if s := f.getFdSetFor(fd); s != nil {
s.Set(fd)
}
}

// Clear removes the given fd from the set.
func (f *FdSet) Clear(fd int) {
if s := f.getFdSetFor(fd); s != nil {
s.Clear(fd)
}
}

// IsSet returns true when fd is in the set.
func (f *FdSet) IsSet(fd int) bool {
if s := f.getFdSetFor(fd); s != nil {
return s.IsSet(fd)
}
return false
}

// Copy returns a copy of this FdSet. It returns nil, if the FdSet is nil.
func (f *FdSet) Copy() *FdSet {
if f == nil {
return nil
}
return &FdSet{
sockets: f.sockets,
pipes: f.pipes,
regular: f.regular,
}
}

// Zero clears the set. It returns 0 if the FdSet is nil.
func (f *FdSet) Count() int {
if f == nil {
return 0
}
return f.sockets.Count() + f.regular.Count() + f.pipes.Count()
}

// Zero clears the set.
func (f *FdSet) Zero() {
if f == nil {
return
}
f.sockets.Zero()
f.regular.Zero()
f.pipes.Zero()
}

// Set adds the given fd to the set.
func (f *WinSockFdSet) Set(fd int) {
if f.count < _FD_SETSIZE {
f.handles[f.count] = syscall.Handle(fd)
f.count++
}
}

// Clear removes the given fd from the set.
func (f *WinSockFdSet) Clear(fd int) {
h := syscall.Handle(fd)
for i := uint64(0); i < f.count; i++ {
if f.handles[i] == h {
for ; i < f.count-1; i++ {
f.handles[i] = f.handles[i+1]
}
f.count--
break
}
}
}

// IsSet returns true when fd is in the set.
func (f *WinSockFdSet) IsSet(fd int) bool {
h := syscall.Handle(fd)
for i := uint64(0); i < f.count; i++ {
if f.handles[i] == h {
return true
}
}
return false
}

// Zero clears the set.
func (f *WinSockFdSet) Zero() {
if f == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless we use things as nil (later re-assign the pointer) I would leave out the receiver == nil thing for internal types

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the receiver can be nil in many places because select accepts fdreads, fdwrites, fdexcepts to be nil. By allowing the receiver to be nil, some error checks can be avoided e.g. f.Count() returning 0 when f == nil, or in this case, zeroing out a nil receiver, which is also useful in select. To be fair, maybe select can be rewritten in a different way so that the nil checks are centralized at the top of the function (maybe replacing nils with empty structs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what made me look was not all functions check nil ;) Anyway it is cool to know at least some of it is helpful!

return
}
f.handles = [64]syscall.Handle{}
f.count = 0
}

// Count returns the number of values that are set in the fd set.
func (f *WinSockFdSet) Count() int {
if f == nil {
return 0
}
return int(f.count)
}

// Copy returns a copy of the fd set or nil if it is nil.
func (f *WinSockFdSet) Copy() *WinSockFdSet {
if f == nil {
return nil
}
copy := *f
return &copy
}

// Get returns the handle at the given index.
func (f *WinSockFdSet) Get(index int) syscall.Handle {
return f.handles[index]
}

// isSocket returns true if the given file handle
// is a pipe.
func isSocket(fd syscall.Handle) bool {
r, _, errno := syscall.SyscallN(
procGetNamedPipeInfo.Addr(),
uintptr(fd),
uintptr(unsafe.Pointer(nil)),
uintptr(unsafe.Pointer(nil)),
uintptr(unsafe.Pointer(nil)),
uintptr(unsafe.Pointer(nil)))
return r == 0 || errno != 0
}
Loading