Repository: tetratelabs/wazero
Pull request #1443 (2023-05-08 21:58:59)

wasip1: support non-blocking mode on stdio
This PR adds the ability to configure non-blocking mode on standard input and output when the underlying files are of type `*os.File`.

This is going to be needed to enable testing the latest Go changes with wazero, see https://go-review.googlesource.com/c/go/+/493356


Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 98353d31..a96e9dfc 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -202,8 +202,21 @@ 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 if f.File.AccessMode() != syscall.O_RDONLY {
-		fdflags = wasip1.FD_APPEND
+	} 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 {
+			fdflags |= wasip1.FD_NONBLOCK
+		}
 	}
 
 	var fsRightsBase uint32
@@ -276,11 +289,25 @@ func fdFdstatSetFlagsFn(_ context.Context, mod api.Module, params []uint64) sysc
 	fd, wasiFlag := int32(params[0]), uint16(params[1])
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 
-	// We can only support APPEND flag.
-	if wasip1.FD_DSYNC&wasiFlag != 0 || wasip1.FD_NONBLOCK&wasiFlag != 0 || wasip1.FD_RSYNC&wasiFlag != 0 || wasip1.FD_SYNC&wasiFlag != 0 {
+	// Currently we only support APPEND and NONBLOCK.
+	if wasip1.FD_DSYNC&wasiFlag != 0 || wasip1.FD_RSYNC&wasiFlag != 0 || wasip1.FD_SYNC&wasiFlag != 0 {
 		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 {
+		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))
+		}
+	}
+
 	var flag int
 	if wasip1.FD_APPEND&wasiFlag != 0 {
 		flag = syscall.O_APPEND
diff --git a/internal/platform/nonblock_unix.go b/internal/platform/nonblock_unix.go
new file mode 100644
index 00000000..d022f025
--- /dev/null
+++ b/internal/platform/nonblock_unix.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package platform
+
+import "syscall"
+
+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
new file mode 100644
index 00000000..8d0b669b
--- /dev/null
+++ b/internal/platform/nonblock_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package platform
+
+import "syscall"
+
+func SetNonblock(fd uintptr, enable bool) error {
+	return syscall.SetNonblock(syscall.Handle(fd), enable)
+}
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 66122c89..5a61d7ea 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -33,30 +33,53 @@ const (
 
 const modeDevice = uint32(fs.ModeDevice | 0o640)
 
-type stdioFileWriter struct {
+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
 }
 
 // Stat implements fs.File
-func (w *stdioFileWriter) Stat() (fs.FileInfo, error) { return w.s, nil }
+func (w *StdioFileWriter) Stat() (fs.FileInfo, error) { return w.s, nil }
 
 // Read implements fs.File
-func (w *stdioFileWriter) Read([]byte) (n int, err error) {
+func (w *StdioFileWriter) Read([]byte) (n int, err error) {
 	return // emulate os.Stdout which returns zero
 }
 
 // Write implements io.Writer
-func (w *stdioFileWriter) Write(p []byte) (n int, err error) {
+func (w *StdioFileWriter) Write(p []byte) (n int, err error) {
 	return w.w.Write(p)
 }
 
 // Close implements fs.File
-func (w *stdioFileWriter) Close() error {
+func (w *StdioFileWriter) Close() error {
 	// Don't actually close the underlying file, as we didn't open it!
 	return nil
 }
 
+// IsNonblock returns the current known state of non-blocking mode on the
+// underlying file.
+func (w *StdioFileWriter) IsNonblock() bool {
+	return w.nonblock
+}
+
+// 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
+}
+
 // 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 {
@@ -97,6 +120,8 @@ type StdioFileReader struct {
 	r    io.Reader
 	s    fs.FileInfo
 	poll StdioFilePoller
+	// See StdioFileWriter.
+	nonblock bool
 }
 
 // NewStdioFileReader is a constructor for StdioFileReader.
@@ -127,6 +152,25 @@ func (r *StdioFileReader) Close() error {
 	return nil
 }
 
+// IsNonblock returns the current known state of non-blocking mode on the
+// underlying file.
+func (r *StdioFileReader) IsNonblock() bool {
+	return r.nonblock
+}
+
+// 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
+}
+
 var (
 	noopStdinStat  = stdioFileInfo{0, modeDevice}
 	noopStdoutStat = stdioFileInfo{1, modeDevice}
@@ -455,7 +499,7 @@ func stdioWriter(w io.Writer, defaultStat stdioFileInfo) (*FileEntry, error) {
 		return nil, err
 	}
 	return &FileEntry{
-		Name: s.Name(), File: platform.NewFsFile("", syscall.O_WRONLY, &stdioFileWriter{w: w, s: s}),
+		Name: s.Name(), File: platform.NewFsFile("", syscall.O_WRONLY, &StdioFileWriter{w: w, s: s}),
 	}, nil
 }
 


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 02b49ad2..91b681f5 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -218,6 +218,17 @@ func Test_fdDatasync(t *testing.T) {
 	}
 }
 
+func openPipe(t *testing.T) (*os.File, *os.File) {
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	return r, w
+}
+
+func closePipe(r, w *os.File) {
+	r.Close()
+	w.Close()
+}
+
 func Test_fdFdstatGet(t *testing.T) {
 	file, dir := "animals.txt", "sub"
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fstest.FS))
@@ -261,13 +272,13 @@ func Test_fdFdstatGet(t *testing.T) {
 			fd:   sys.FdStdout,
 			expectedMemory: []byte{
 				1, 0, // fs_filetype
-				1, 0, 0, 0, 0, 0, // fs_flags
+				0, 0, 0, 0, 0, 0, // fs_flags
 				0xff, 0x1, 0xe0, 0x8, 0x0, 0x0, 0x0, 0x0, // fs_rights_base
 				0, 0, 0, 0, 0, 0, 0, 0, // fs_rights_inheriting
 			},
 			expectedLog: `
 ==> wasi_snapshot_preview1.fd_fdstat_get(fd=1)
-<== (stat={filetype=BLOCK_DEVICE,fdflags=APPEND,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
+<== (stat={filetype=BLOCK_DEVICE,fdflags=,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
 `,
 		},
 		{
@@ -275,13 +286,13 @@ func Test_fdFdstatGet(t *testing.T) {
 			fd:   sys.FdStderr,
 			expectedMemory: []byte{
 				1, 0, // fs_filetype
-				1, 0, 0, 0, 0, 0, // fs_flags
+				0, 0, 0, 0, 0, 0, // fs_flags
 				0xff, 0x1, 0xe0, 0x8, 0x0, 0x0, 0x0, 0x0, // fs_rights_base
 				0, 0, 0, 0, 0, 0, 0, 0, // fs_rights_inheriting
 			},
 			expectedLog: `
 ==> wasi_snapshot_preview1.fd_fdstat_get(fd=2)
-<== (stat={filetype=BLOCK_DEVICE,fdflags=APPEND,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
+<== (stat={filetype=BLOCK_DEVICE,fdflags=,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
 `,
 		},
 		{
@@ -365,6 +376,98 @@ func Test_fdFdstatGet(t *testing.T) {
 	}
 }
 
+func Test_fdFdstatGet_StdioNonblock(t *testing.T) {
+	stdinR, stdinW := openPipe(t)
+	defer closePipe(stdinR, stdinW)
+
+	stdoutR, stdoutW := openPipe(t)
+	defer closePipe(stdoutR, stdoutW)
+
+	stderrR, stderrW := openPipe(t)
+	defer closePipe(stderrR, stderrW)
+
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().
+		WithStdin(stdinR).
+		WithStdout(stdoutW).
+		WithStderr(stderrW))
+	defer r.Close(testCtx)
+
+	stdin, stdout, stderr := uint64(0), uint64(1), uint64(2)
+	requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stdin, uint64(wasip1.FD_NONBLOCK))
+	requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stdout, uint64(wasip1.FD_NONBLOCK))
+	requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stderr, uint64(wasip1.FD_NONBLOCK))
+	log.Reset()
+
+	tests := []struct {
+		name           string
+		fd             int32
+		resultFdstat   uint32
+		expectedMemory []byte
+		expectedErrno  wasip1.Errno
+		expectedLog    string
+	}{
+		{
+			name: "stdin",
+			fd:   sys.FdStdin,
+			expectedMemory: []byte{
+				0, 0, // fs_filetype
+				4, 0, 0, 0, 0, 0, // fs_flags
+				0xff, 0x1, 0xe0, 0x8, 0x0, 0x0, 0x0, 0x0, // fs_rights_base
+				0, 0, 0, 0, 0, 0, 0, 0, // fs_rights_inheriting
+			},
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_fdstat_get(fd=0)
+<== (stat={filetype=UNKNOWN,fdflags=NONBLOCK,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
+`,
+		},
+		{
+			name: "stdout",
+			fd:   sys.FdStdout,
+			expectedMemory: []byte{
+				0, 0, // fs_filetype
+				4, 0, 0, 0, 0, 0, // fs_flags
+				0xff, 0x1, 0xe0, 0x8, 0x0, 0x0, 0x0, 0x0, // fs_rights_base
+				0, 0, 0, 0, 0, 0, 0, 0, // fs_rights_inheriting
+			},
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_fdstat_get(fd=1)
+<== (stat={filetype=UNKNOWN,fdflags=NONBLOCK,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
+`,
+		},
+		{
+			name: "stderr",
+			fd:   sys.FdStderr,
+			expectedMemory: []byte{
+				0, 0, // fs_filetype
+				4, 0, 0, 0, 0, 0, // fs_flags
+				0xff, 0x1, 0xe0, 0x8, 0x0, 0x0, 0x0, 0x0, // fs_rights_base
+				0, 0, 0, 0, 0, 0, 0, 0, // fs_rights_inheriting
+			},
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_fdstat_get(fd=2)
+<== (stat={filetype=UNKNOWN,fdflags=NONBLOCK,fs_rights_base=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE,fs_rights_inheriting=},errno=ESUCCESS)
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			defer log.Reset()
+
+			maskMemory(t, mod, len(tc.expectedMemory))
+
+			requireErrnoResult(t, tc.expectedErrno, mod, wasip1.FdFdstatGetName, uint64(tc.fd), uint64(tc.resultFdstat))
+			require.Equal(t, tc.expectedLog, "\n"+log.String())
+
+			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
+			require.True(t, ok)
+			require.Equal(t, tc.expectedMemory, actual)
+		})
+	}
+}
+
 func Test_fdFdstatSetFlags(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
 	const fileName = "file.txt"
@@ -373,8 +476,20 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 	realPath := joinPath(tmpDir, fileName)
 	require.NoError(t, os.WriteFile(realPath, []byte("0123456789"), 0o600))
 
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(wazero.NewFSConfig().
-		WithDirMount(tmpDir, "/")))
+	stdinR, stdinW := openPipe(t)
+	defer closePipe(stdinR, stdinW)
+
+	stdoutR, stdoutW := openPipe(t)
+	defer closePipe(stdoutR, stdoutW)
+
+	stderrR, stderrW := openPipe(t)
+	defer closePipe(stderrR, stderrW)
+
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().
+		WithStdin(stdinR).
+		WithStdout(stdoutW).
+		WithStderr(stderrW).
+		WithFSConfig(wazero.NewFSConfig().WithDirMount(tmpDir, "/")))
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 	preopen := fsc.RootFS()
 	defer r.Close(testCtx)
@@ -445,13 +560,20 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 	writeWazero()
 	requireFileContent("wazero6789" + "wazero" + "wazero")
 
+	t.Run("nonblock", func(t *testing.T) {
+		stdin, stdout, stderr := uint64(0), uint64(1), uint64(2)
+		requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stdin, uint64(wasip1.FD_NONBLOCK))
+		requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stdout, uint64(wasip1.FD_NONBLOCK))
+		requireErrnoResult(t, 0, mod, wasip1.FdFdstatSetFlagsName, stderr, uint64(wasip1.FD_NONBLOCK))
+	})
+
 	t.Run("errors", func(t *testing.T) {
 		requireErrnoResult(t, wasip1.ErrnoInval, mod, wasip1.FdFdstatSetFlagsName, uint64(fd), uint64(wasip1.FD_DSYNC))
-		requireErrnoResult(t, wasip1.ErrnoInval, mod, wasip1.FdFdstatSetFlagsName, uint64(fd), uint64(wasip1.FD_NONBLOCK))
 		requireErrnoResult(t, wasip1.ErrnoInval, mod, wasip1.FdFdstatSetFlagsName, uint64(fd), uint64(wasip1.FD_RSYNC))
 		requireErrnoResult(t, wasip1.ErrnoInval, mod, wasip1.FdFdstatSetFlagsName, uint64(fd), uint64(wasip1.FD_SYNC))
 		requireErrnoResult(t, wasip1.ErrnoBadf, mod, wasip1.FdFdstatSetFlagsName, uint64(12345), uint64(wasip1.FD_APPEND))
 		requireErrnoResult(t, wasip1.ErrnoIsdir, mod, wasip1.FdFdstatSetFlagsName, uint64(3) /* preopen */, uint64(wasip1.FD_APPEND))
+		requireErrnoResult(t, wasip1.ErrnoIsdir, mod, wasip1.FdFdstatSetFlagsName, uint64(3), uint64(wasip1.FD_NONBLOCK))
 	})
 }
 
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index fd944013..98df0fe6 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -20,8 +20,8 @@ import (
 
 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})}
+	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