Skip to content

Commit

Permalink
wasi: nonblocking I/O for sockets and pipes on Windows (#1579)
Browse files Browse the repository at this point in the history
Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com>
  • Loading branch information
evacchi authored Jul 18, 2023
1 parent 1cdb72d commit 1e0c73d
Show file tree
Hide file tree
Showing 14 changed files with 875 additions and 174 deletions.
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 {
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

0 comments on commit 1e0c73d

Please sign in to comment.