diff --git a/RATIONALE.md b/RATIONALE.md index f73f1fee..e5dff05d 100644 --- a/RATIONALE.md +++ b/RATIONALE.md @@ -907,6 +907,96 @@ See https://github.com/WebAssembly/stack-switching/discussions/38 See https://github.com/WebAssembly/wasi-threads#what-can-be-skipped See https://slinkydeveloper.com/Kubernetes-controllers-A-New-Hope/ +## poll_oneoff + +`poll_oneoff` is a WASI API for waiting for I/O events on multiple handles. +It is conceptually similar to the POSIX `poll(2)` syscall. +The name is not `poll`, because it references [“the fact that this function is not efficient +when used repeatedly with the same large set of handles”][poll_oneoff]. + +We chose to support this API in a handful of cases that work for regular files +and standard input. We currently do not support other types of file descriptors such +as socket handles. + +### Clock Subscriptions + +As detailed above in [sys.Nanosleep](#sysnanosleep), `poll_oneoff` handles +relative clock subscriptions. In our implementation we use `sys.Nanosleep()` +for this purpose in most cases, except when polling for interactive input +from `os.Stdin` (see more details below). + +### FdRead and FdWrite Subscriptions + +When subscribing a file descriptor (except `Stdin`) for reads or writes, +the implementation will generally return immediately with success, unless +the file descriptor is unknown. The file descriptor is not checked further +for new incoming data. Any timeout is cancelled, and the API call is able +to return, unless there are subscriptions to `Stdin`: these are handled +separately. + +### FdRead and FdWrite Subscription to Stdin + +Subscribing `Stdin` for reads (writes make no sense and cause an error), +requires extra care: wazero allows to configure a custom reader for `Stdin`. + +In general, if a custom reader is found, the behavior will be the same +as for regular file descriptors: data is assumed to be present and +a success is written back to the result buffer. + +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. + +### Select on POSIX + +On POSIX systems,`select(2)` allows to wait for incoming data on a file +descriptor, and block until either data becomes available or the timeout +expires. It is not surprising that `select(2)` and `poll(2)` have lot in common: +the main difference is how the file descriptor parameters are passed. + +Usage of `platform.Select()` is only reserved for the standard input case, because + +1. it is really only necessary to handle interactive input: otherwise, + there is no way in Go to peek from Standard Input without actually + reading (and thus consuming) from it; + +2. if `Stdin` is connected to a pipe, it is ok in most cases to return + with success immediately; + +3. `platform.Select()` is currently a blocking call, irrespective of goroutines, + because the underlying syscall is; thus, it is better to limit its usage. + +So, if the subscription is for `os.Stdin` and the handle is detected +to correspond to an interactive session, then `platform.Select()` will be +invoked with a the `Stdin` handle *and* the timeout. + +This also means that in this specific case, the timeout is uninterruptible, +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. + +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. + +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. + +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. + +[poll_oneoff]: https://github.com/WebAssembly/wasi-poll#why-is-the-function-called-poll_oneoff + ## Signed encoding of integer global constant initializers wazero treats integer global constant initializers signed as their interpretation is not known at declaration time. For diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go index 43ca00a4..cb60c679 100644 --- a/imports/wasi_snapshot_preview1/poll.go +++ b/imports/wasi_snapshot_preview1/poll.go @@ -2,10 +2,11 @@ package wasi_snapshot_preview1 import ( "context" - "io/fs" "syscall" + "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" @@ -42,6 +43,13 @@ var pollOneoff = newHostFunc( "in", "out", "nsubscriptions", "result.nevents", ) +type event struct { + eventType byte + userData []byte + errno wasip1.Errno + outOffset uint32 +} + func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall.Errno { in := uint32(params[0]) out := uint32(params[1]) @@ -60,6 +68,11 @@ func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall. return syscall.EFAULT } outBuf, ok := mem.Read(out, nsubscriptions*32) + // zero-out all buffer before writing + for i := range outBuf { + outBuf[i] = 0 + } + if !ok { return syscall.EFAULT } @@ -72,6 +85,17 @@ func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall. // Loop through all subscriptions and write their output. + // Extract FS context, used in the body of the for loop for FS access. + fsc := mod.(*wasm.ModuleInstance).Sys.FS() + // Slice of events that are processed out of the loop (stdin subscribers). + var stdinSubs []*event + // The timeout is initialized at max Duration, the loop will find the minimum. + var timeout time.Duration = 1<<63 - 1 + // Count of all the clock subscribers that have been already written back to outBuf. + clockEvents := uint32(0) + // Count of all the non-clock subscribers that have been already written back to outBuf. + readySubs := uint32(0) + // Layout is subscription_u: Union // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#subscription_u for i := uint32(0); i < nsubscriptions; i++ { @@ -79,81 +103,153 @@ func pollOneoffFn(ctx context.Context, mod api.Module, params []uint64) syscall. outOffset := i * 32 eventType := inBuf[inOffset+8] // +8 past userdata - var errno syscall.Errno // errno for this specific event (1-byte) + // +8 past userdata +8 contents_offset + argBuf := inBuf[inOffset+8+8:] + userData := inBuf[inOffset : inOffset+8] + + evt := &event{ + eventType: eventType, + userData: userData, + errno: wasip1.ErrnoSuccess, + outOffset: outOffset, + } + switch eventType { case wasip1.EventTypeClock: // handle later - // +8 past userdata +8 contents_offset - errno = processClockEvent(ctx, mod, inBuf[inOffset+8+8:]) - case wasip1.EventTypeFdRead, wasip1.EventTypeFdWrite: - // +8 past userdata +8 contents_offset - errno = processFDEvent(mod, eventType, inBuf[inOffset+8+8:]) + clockEvents++ + newTimeout, err := processClockEvent(argBuf) + if err != 0 { + return err + } + // Min timeout. + if newTimeout < timeout { + timeout = newTimeout + } + // Ack the clock event to the outBuf. + writeEvent(outBuf, evt) + case wasip1.EventTypeFdRead: + fd := le.Uint32(argBuf) + if fd == internalsys.FdStdin { + // if the fd is Stdin, do not ack yet, + // append to a slice for delayed evaluation. + stdinSubs = append(stdinSubs, evt) + } else { + evt.errno = processFDEventRead(fsc, fd) + writeEvent(outBuf, evt) + readySubs++ + } + case wasip1.EventTypeFdWrite: + fd := le.Uint32(argBuf) + evt.errno = processFDEventWrite(fsc, fd) + readySubs++ + writeEvent(outBuf, evt) default: return syscall.EINVAL } - - // Write the event corresponding to the processed subscription. - // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-event-struct - copy(outBuf, inBuf[inOffset:inOffset+8]) // userdata - if errno != 0 { - outBuf[outOffset+8] = byte(wasip1.ToErrno(errno)) // uint16, but safe as < 255 - } else { // special case ass ErrnoSuccess is zero - outBuf[outOffset+8] = 0 - } - outBuf[outOffset+9] = 0 - le.PutUint32(outBuf[outOffset+10:], uint32(eventType)) - // TODO: When FD events are supported, write outOffset+16 } + + // If there are subscribers with data ready, we have already written them to outBuf, + // and we don't need to wait for the timeout: clear it. + if readySubs != 0 { + timeout = 0 + } + + // If there are stdin subscribers, check for data with given timeout. + if len(stdinSubs) > 0 { + reader := getStdioFileReader(mod) + // 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) + } + if stdinReady { + // stdin has data ready to for reading, write back all the events + for i := range stdinSubs { + readySubs++ + evt := stdinSubs[i] + evt.errno = 0 + writeEvent(outBuf, evt) + } + } + } else { + // No subscribers, just wait for the given timeout. + sysCtx := mod.(*wasm.ModuleInstance).Sys + sysCtx.Nanosleep(int64(timeout)) + } + + if readySubs != nsubscriptions { + if !mod.Memory().WriteUint32Le(resultNevents, readySubs+clockEvents) { + return syscall.EFAULT + } + } + return 0 } // processClockEvent supports only relative name events, as that's what's used // to implement sleep in various compilers including Rust, Zig and TinyGo. -func processClockEvent(_ context.Context, mod api.Module, inBuf []byte) syscall.Errno { +func processClockEvent(inBuf []byte) (time.Duration, syscall.Errno) { _ /* ID */ = le.Uint32(inBuf[0:8]) // See below timeout := le.Uint64(inBuf[8:16]) // nanos if relative _ /* precision */ = le.Uint64(inBuf[16:24]) // Unused flags := le.Uint16(inBuf[24:32]) + var err syscall.Errno // subclockflags has only one flag defined: subscription_clock_abstime switch flags { case 0: // relative time case 1: // subscription_clock_abstime - return syscall.ENOTSUP + err = syscall.ENOTSUP default: // subclockflags has only one flag defined. - return syscall.EINVAL + err = syscall.EINVAL } - // https://linux.die.net/man/3/clock_settime says relative timers are - // unaffected. Since this function only supports relative timeout, we can - // skip name ID validation and use a single sleep function. + if err != 0 { + return 0, err + } else { + // https://linux.die.net/man/3/clock_settime says relative timers are + // unaffected. Since this function only supports relative timeout, we can + // skip name ID validation and use a single sleep function. - sysCtx := mod.(*wasm.ModuleInstance).Sys - sysCtx.Nanosleep(int64(timeout)) - return 0 + return time.Duration(timeout), 0 + } } -// processFDEvent returns a validation error or syscall.ENOTSUP as file or socket -// subscriptions are not yet supported. -func processFDEvent(mod api.Module, eventType byte, inBuf []byte) syscall.Errno { - fd := le.Uint32(inBuf) +// processFDEventRead returns ErrnoSuccess if the file exists and ErrnoBadf otherwise. +func processFDEventRead(fsc *internalsys.FSContext, fd uint32) wasip1.Errno { + if _, ok := fsc.LookupFile(fd); ok { + return wasip1.ErrnoSuccess + } else { + return wasip1.ErrnoBadf + } +} + +// processFDEventWrite returns ErrnoNotsup if the file exists and ErrnoBadf otherwise. +func processFDEventWrite(fsc *internalsys.FSContext, fd uint32) wasip1.Errno { + if internalsys.WriterForFile(fsc, fd) == nil { + return wasip1.ErrnoBadf + } + return wasip1.ErrnoNotsup +} + +// writeEvent writes the event corresponding to the processed subscription. +// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-event-struct +func writeEvent(outBuf []byte, evt *event) { + copy(outBuf[evt.outOffset:], evt.userData) // userdata + outBuf[evt.outOffset+8] = byte(evt.errno) // uint16, but safe as < 255 + outBuf[evt.outOffset+9] = 0 + 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() - - // Choose the best error, which falls back to unsupported, until we support - // files. - errno := syscall.ENOTSUP - if eventType == wasip1.EventTypeFdRead { - if f, ok := fsc.LookupFile(fd); ok { - st, _ := f.Stat() - // if fd is a pipe, then it is not a char device (a tty) - if st.Mode&fs.ModeCharDevice != 0 { - errno = syscall.EBADF - } - } else { - errno = syscall.EBADF + if file, ok := fsc.LookupFile(internalsys.FdStdin); ok { + if reader, typeOk := file.File.(*internalsys.StdioFileReader); typeOk { + return reader } - } else if eventType == wasip1.EventTypeFdWrite && internalsys.WriterForFile(fsc, fd) == nil { - errno = syscall.EBADF } - - return errno + panic("unexpected error: Stdin must always be a StdioFileReader") } diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go index 69a36c61..d3d90bee 100644 --- a/imports/wasi_snapshot_preview1/poll_test.go +++ b/imports/wasi_snapshot_preview1/poll_test.go @@ -2,8 +2,9 @@ package wasi_snapshot_preview1_test import ( "io/fs" - "os" + "strings" "testing" + "time" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/internal/sys" @@ -30,7 +31,8 @@ func Test_pollOneoff(t *testing.T) { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum - '?', // stopped after encoding + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, '?', // stopped after encoding } in := uint32(0) // past in @@ -61,17 +63,6 @@ func Test_pollOneoff_Errors(t *testing.T) { mod, r, log := requireProxyModule(t, wazero.NewModuleConfig()) defer r.Close(testCtx) - // We aren't guaranteed to have a terminal device for os.Stdin, due to how - // `go test` forks processes. Instead, we test if this is consistent. For - // example, when run in a debugger, this could end up true. - // See also `terminal_test.go`. - expectedFdReadErr := wasip1.ErrnoNotsup - if stat, err := os.Stdin.Stat(); err != nil { - if stat.Mode()&fs.ModeCharDevice != 0 { - expectedFdReadErr = wasip1.ErrnoBadf - } - } - tests := []struct { name string in, out, nsubscriptions, resultNevents uint32 @@ -121,29 +112,6 @@ func Test_pollOneoff_Errors(t *testing.T) { expectedLog: ` ==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=0) <== (nevents=,errno=EINVAL) -`, - }, - { - name: "unsupported EventTypeFdRead", - nsubscriptions: 1, - mem: []byte{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata - wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, - byte(sys.FdStdin), 0x0, 0x0, 0x0, // valid readable FD - '?', // stopped after encoding - }, - expectedErrno: wasip1.ErrnoSuccess, - out: 128, // past in - resultNevents: 512, // past out - expectedMem: []byte{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata - byte(expectedFdReadErr), 0x0, // errno is 16 bit - wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum - '?', // stopped after encoding - }, - expectedLog: ` -==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=1) -<== (nevents=1,errno=ESUCCESS) `, }, } @@ -172,7 +140,413 @@ func Test_pollOneoff_Errors(t *testing.T) { nevents, ok := mod.Memory().ReadUint32Le(tc.resultNevents) require.True(t, ok) require.Equal(t, uint32(1), nevents) + _ = nevents } }) } } + +func Test_pollOneoff_Stdin(t *testing.T) { + tests := []struct { + name string + in, out, nsubscriptions, resultNevents uint32 + mem []byte // at offset in + stdioReader *sys.StdioFileReader + expectedErrno wasip1.Errno + expectedMem []byte // at offset out + expectedLog string + expectedNevents uint32 + }{ + { + 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 + expectedMem: []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=1) +<== (nevents=1,errno=ESUCCESS) +`, + }, + { + 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 + mem: concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + 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 + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=2) +<== (nevents=2,errno=ESUCCESS) +`, + }, + { + 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 + mem: concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + 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 + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=2) +<== (nevents=2,errno=ESUCCESS) +`, + }, + { + 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), + mem: concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + 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 + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=2) +<== (nevents=2,errno=ESUCCESS) +`, + }, + { + 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), + mem: concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + 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 + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=2) +<== (nevents=2,errno=ESUCCESS) +`, + }, + { + 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), + mem: concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + + 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 + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + // 32 empty bytes + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + + '?', // stopped after encoding + }, + expectedLog: ` +==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=2) +<== (nevents=1,errno=ESUCCESS) +`, + }, + } + + 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) + defer r.Close(testCtx) + defer log.Reset() + + maskMemory(t, mod, 1024) + + if tc.mem != nil { + mod.Memory().Write(tc.in, tc.mem) + } + + requireErrnoResult(t, tc.expectedErrno, mod, wasip1.PollOneoffName, uint64(tc.in), uint64(tc.out), + uint64(tc.nsubscriptions), uint64(tc.resultNevents)) + require.Equal(t, tc.expectedLog, "\n"+log.String()) + + out, ok := mod.Memory().Read(tc.out, uint32(len(tc.expectedMem))) + require.True(t, ok) + require.Equal(t, tc.expectedMem, out) + + // Events should be written on success regardless of nested failure. + if tc.expectedErrno == wasip1.ErrnoSuccess { + nevents, ok := mod.Memory().ReadUint32Le(tc.resultNevents) + require.True(t, ok) + require.Equal(t, tc.expectedNevents, nevents) + _ = nevents + } + }) + } +} + +func Test_pollOneoff_Zero(t *testing.T) { + poller := &poller{ready: true} + + tconfig := wazero.NewModuleConfig().WithStdin(sys.NewStdioFileReader( + strings.NewReader("test"), + stdinFileInfo(fs.ModeDevice|fs.ModeCharDevice|0o640), + poller)) + + mod, r, log := requireProxyModule(t, tconfig) + defer r.Close(testCtx) + defer log.Reset() + + maskMemory(t, mod, 1024) + + out := uint32(128) + nsubscriptions := 2 + resultNevents := uint32(512) + + mod.Memory().Write(0, + concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + ) + + expectedMem := []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // pad to 32 + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, + + '?', // stopped after encoding + } + + requireErrnoResult(t, wasip1.ErrnoSuccess, mod, wasip1.PollOneoffName, uint64(0), uint64(out), + uint64(nsubscriptions), uint64(resultNevents)) + + outMem, ok := mod.Memory().Read(out, uint32(len(expectedMem))) + require.True(t, ok) + require.Equal(t, expectedMem, outMem) + + // Events should be written on success regardless of nested failure. + nevents, ok := mod.Memory().ReadUint32Le(resultNevents) + require.True(t, ok) + require.Equal(t, uint32(2), nevents) + + // second run: simulate no more data on the fd + poller.ready = false + + mod.Memory().Write(0, + concat( + clockNsSub(20*1000*1000), + fdReadSub, + singleton('?'), + ), + ) + + expectedMem = []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + byte(wasip1.ErrnoSuccess), 0x0, // errno is 16 bit + wasip1.EventTypeClock, 0x0, 0x0, 0x0, // 4 bytes for type enum + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + + '?', // stopped after encoding + } + + requireErrnoResult(t, wasip1.ErrnoSuccess, mod, wasip1.PollOneoffName, uint64(0), uint64(out), + uint64(nsubscriptions), uint64(resultNevents)) + + outMem, ok = mod.Memory().Read(out, uint32(len(expectedMem))) + require.True(t, ok) + require.Equal(t, expectedMem, outMem) + + nevents, ok = mod.Memory().ReadUint32Le(resultNevents) + require.True(t, ok) + require.Equal(t, uint32(1), nevents) +} + +func singleton(b byte) []byte { + return []byte{b} +} + +func concat(bytes ...[]byte) []byte { + var res []byte + for i := range bytes { + res = append(res, bytes[i]...) + } + return res +} + +// subscription for a given timeout in ns +func clockNsSub(ns uint64) []byte { + return []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + wasip1.EventTypeClock, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // event type and padding + wasip1.ClockIDMonotonic, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + byte(ns), byte(ns >> 8), byte(ns >> 16), byte(ns >> 24), + byte(ns >> 32), byte(ns >> 40), byte(ns >> 48), byte(ns >> 56), // timeout (ns) + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // precision (ns) + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // flags + } +} + +// subscription for an EventTypeFdRead on a given fd +func fdReadSubFd(fd byte) []byte { + return []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // userdata + wasip1.EventTypeFdRead, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + fd, 0x0, 0x0, 0x0, // valid readable FD + } +} + +// subscription for an EventTypeFdRead on stdin +var fdReadSub = fdReadSubFd(byte(sys.FdStdin)) + +type poller struct { + ready bool +} + +func (p *poller) Poll(d time.Duration) (bool, error) { + if p.ready { + return true, nil + } else { + return false, nil + } +} diff --git a/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.c b/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.c index 0f6a3307..65779c70 100644 --- a/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.c +++ b/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.c @@ -5,6 +5,8 @@ #include #include #include +#include +#include #define formatBool(b) ((b) ? "true" : "false") @@ -37,7 +39,7 @@ void main_stat() { printf("/ isatty: %s\n", formatBool(isatty(3))); } -void main_poll() { +void main_poll(int timeout, int millis) { int ret = 0; fd_set rfds; struct timeval tv; @@ -45,8 +47,8 @@ void main_poll() { FD_ZERO(&rfds); FD_SET(0, &rfds); - tv.tv_sec = 0; - tv.tv_usec = 0; + tv.tv_sec = timeout; + tv.tv_usec = millis*1000; ret = select(1, &rfds, NULL, NULL, &tv); if ((ret > 0) && FD_ISSET(0, &rfds)) { printf("STDIN\n"); @@ -55,6 +57,19 @@ void main_poll() { } } +void main_sleepmillis(int millis) { + struct timespec tim, tim2; + tim.tv_sec = 0; + tim.tv_nsec = millis * 1000000; + + if(nanosleep(&tim , &tim2) < 0 ) { + printf("ERR\n"); + return; + } + + printf("OK\n"); +} + int main(int argc, char** argv) { if (strcmp(argv[1],"ls")==0) { bool repeat = false; @@ -65,7 +80,22 @@ int main(int argc, char** argv) { } else if (strcmp(argv[1],"stat")==0) { main_stat(); } else if (strcmp(argv[1],"poll")==0) { - main_poll(); + int timeout = 0; + int usec = 0; + if (argc > 2) { + timeout = atoi(argv[2]); + } + if (argc > 3) { + usec = atoi(argv[3]); + } + main_poll(timeout, usec); + } else if (strcmp(argv[1],"sleepmillis")==0) { + int timeout = 0; + if (argc > 2) { + timeout = atoi(argv[2]); + } + main_sleepmillis(timeout); + } else { fprintf(stderr, "unknown command: %s\n", argv[1]); return 1; diff --git a/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.wasm b/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.wasm index daf05ef2..181bb181 100755 Binary files a/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.wasm and b/imports/wasi_snapshot_preview1/testdata/zig-cc/wasi.wasm differ diff --git a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go index 6875d65c..34126e9d 100644 --- a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go +++ b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go @@ -3,15 +3,17 @@ package wasi_snapshot_preview1_test import ( "bytes" _ "embed" + "io" "io/fs" - "os" "strconv" "strings" "testing" "testing/fstest" + "time" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + internalsys "github.com/tetratelabs/wazero/internal/sys" "github.com/tetratelabs/wazero/internal/testing/require" "github.com/tetratelabs/wazero/sys" ) @@ -217,18 +219,95 @@ func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (consol } func Test_Poll(t *testing.T) { - moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "poll") - console := compileAndRun(t, moduleConfig, wasmZigCc) - // The "real" expected behavior is to return "NOINPUT", - // however the poll API is currently relying on stat'ing the file - // descriptor for stdin which makes the behavior platform-specific - // **during tests** and unfortunately hard to mock. - // For now, we just make sure the result is consistent. - if stat, err := os.Stdin.Stat(); err != nil { - if stat.Mode()&fs.ModeCharDevice != 0 { - require.Equal(t, "NOINPUT\n", console) - return - } + // The following test cases replace Stdin with a custom reader. + // For more precise coverage, see poll_test.go. + + tests := []struct { + name string + args []string + stdin io.Reader + 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), + 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), + 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), + 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), + 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), + expectedOutput: "STDIN", + expectedTimeout: 0 * time.Millisecond, + }, + } + + 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) + elapsed := time.Since(start) + require.True(t, elapsed >= tc.expectedTimeout) + require.Equal(t, tc.expectedOutput+"\n", console) + }) } - require.Equal(t, "STDIN\n", console) +} + +// 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 +} + +func Test_Sleep(t *testing.T) { + moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "sleepmillis", "100").WithSysNanosleep() + start := time.Now() + console := compileAndRun(t, moduleConfig, wasmZigCc) + require.True(t, time.Since(start) >= 100*time.Millisecond) + require.Equal(t, "OK\n", console) } diff --git a/imports/wasi_snapshot_preview1/wasi_test.go b/imports/wasi_snapshot_preview1/wasi_test.go index 63705c67..a5b59b52 100644 --- a/imports/wasi_snapshot_preview1/wasi_test.go +++ b/imports/wasi_snapshot_preview1/wasi_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" _ "embed" + "io/fs" "testing" + "time" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -147,3 +149,31 @@ func requireErrnoResult(t *testing.T, expectedErrno wasip1.Errno, mod api.Closer errno := wasip1.Errno(results[0]) require.Equal(t, expectedErrno, errno, "want %s but have %s", wasip1.ErrnoName(expectedErrno), wasip1.ErrnoName(errno)) } + +func newBlockingReader(t *testing.T) blockingReader { + timeout, cancelFunc := context.WithTimeout(testCtx, 5*time.Second) + t.Cleanup(cancelFunc) + return blockingReader{ctx: timeout} +} + +// blockingReader is an io.Reader that never terminates its read +// unless the embedded context is Done() +type blockingReader struct { + ctx context.Context +} + +// Read implements io.Reader +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/fdset.go b/internal/platform/fdset.go new file mode 100644 index 00000000..0e8a13d5 --- /dev/null +++ b/internal/platform/fdset.go @@ -0,0 +1,23 @@ +package platform + +// Set adds the given fd to the set. +func (f *FdSet) Set(fd int) { + f.Bits[fd/nfdbits] |= (1 << (uintptr(fd) % nfdbits)) +} + +// Clear removes the given fd from the set. +func (f *FdSet) Clear(fd int) { + f.Bits[fd/nfdbits] &^= (1 << (uintptr(fd) % nfdbits)) +} + +// IsSet returns true when fd is in the set. +func (f *FdSet) IsSet(fd int) bool { + return f.Bits[fd/nfdbits]&(1<<(uintptr(fd)%nfdbits)) != 0 +} + +// Zero clears the set. +func (f *FdSet) Zero() { + for i := range f.Bits { + f.Bits[i] = 0 + } +} diff --git a/internal/platform/fdset_darwin.go b/internal/platform/fdset_darwin.go new file mode 100644 index 00000000..da52339c --- /dev/null +++ b/internal/platform/fdset_darwin.go @@ -0,0 +1,8 @@ +package platform + +import "syscall" + +const nfdbits = 0x20 + +// FdSet re-exports syscall.FdSet with utility methods. +type FdSet syscall.FdSet diff --git a/internal/platform/fdset_linux.go b/internal/platform/fdset_linux.go new file mode 100644 index 00000000..f392caf4 --- /dev/null +++ b/internal/platform/fdset_linux.go @@ -0,0 +1,8 @@ +package platform + +import "syscall" + +const nfdbits = 0x40 + +// FdSet re-exports syscall.FdSet with utility methods. +type FdSet syscall.FdSet diff --git a/internal/platform/fdset_test.go b/internal/platform/fdset_test.go new file mode 100644 index 00000000..63fd79a5 --- /dev/null +++ b/internal/platform/fdset_test.go @@ -0,0 +1,109 @@ +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 + + tests := []struct { + name string + init FdSet + exec func(fdSet *FdSet) + expected FdSet + }{ + { + name: "all bits set", + exec: func(fdSet *FdSet) { + for fd := 0; fd < nfdbits; fd++ { + fdSet.Set(fd) + } + }, + expected: allBitsSetAtIndex0, + }, + { + name: "all bits cleared", + init: allBitsSetAtIndex0, + exec: func(fdSet *FdSet) { + for fd := 0; fd < nfdbits; fd++ { + fdSet.Clear(fd) + } + }, + expected: FdSet{}, + }, + { + name: "zero should clear all bits", + init: allBitsSetAtIndex0, + exec: func(fdSet *FdSet) { + fdSet.Zero() + }, + expected: FdSet{}, + }, + { + name: "is-set should return true for all bits", + init: allBitsSetAtIndex0, + exec: func(fdSet *FdSet) { + for i := range fdSet.Bits { + require.True(t, fdSet.IsSet(i)) + } + }, + expected: allBitsSetAtIndex0, + }, + { + name: "is-set should return true for all odd bits", + init: FdSet{}, + exec: func(fdSet *FdSet) { + for fd := 1; fd < nfdbits; fd += 2 { + fdSet.Set(fd) + } + for fd := 0; fd < nfdbits; fd++ { + isSet := fdSet.IsSet(fd) + if fd&0x1 == 0x1 { + require.True(t, isSet) + } else { + require.False(t, isSet) + } + } + fdSet.Zero() + }, + expected: FdSet{}, + }, + { + name: "should clear all even bits", + init: allBitsSetAtIndex0, + exec: func(fdSet *FdSet) { + for fd := 0; fd < nfdbits; fd += 2 { + fdSet.Clear(fd) + } + for fd := 0; fd < nfdbits; fd++ { + isSet := fdSet.IsSet(fd) + if fd&0x1 == 0x1 { + require.True(t, isSet) + } else { + require.False(t, isSet) + } + } + fdSet.Zero() + }, + expected: FdSet{}, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + x := tc.init + tc.exec(&x) + require.Equal(t, tc.expected, x) + }) + } +} diff --git a/internal/platform/fdset_unsupported.go b/internal/platform/fdset_unsupported.go new file mode 100644 index 00000000..b5aa3c15 --- /dev/null +++ b/internal/platform/fdset_unsupported.go @@ -0,0 +1,10 @@ +//go:build !darwin && !linux + +package platform + +const nfdbits = 0x40 + +// FdSet mocks syscall.FdSet on systems that do not support it. +type FdSet struct { + Bits [16]int64 +} diff --git a/internal/platform/futimens_darwin.go b/internal/platform/futimens_darwin.go index 5b57a4b5..1dfd1326 100644 --- a/internal/platform/futimens_darwin.go +++ b/internal/platform/futimens_darwin.go @@ -47,10 +47,3 @@ var libc_futimens_trampoline_addr uintptr // Note: CGO mechanisms are used in darwin regardless of the CGO_ENABLED value // or the "cgo" build flag. See /RATIONALE.md for why. //go:cgo_import_dynamic libc_futimens futimens "/usr/lib/libSystem.B.dylib" - -// syscall_syscall6 is a private symbol that we link below. We need to use this -// instead of syscall.Syscall6 because the public syscall.Syscall6 won't work -// when fn is an address. -// -//go:linkname syscall_syscall6 syscall.syscall6 -func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) diff --git a/internal/platform/select.go b/internal/platform/select.go new file mode 100644 index 00000000..752f4eaa --- /dev/null +++ b/internal/platform/select.go @@ -0,0 +1,31 @@ +package platform + +import "time" + +// Select exposes the select(2) syscall. +// +// # Notes on Parameters +// +// For convenience, we expose a pointer to a time.Duration instead of a pointer to a syscall.Timeval. +// It must be a pointer because `nil` means "wait forever". +// +// However, notice that select(2) may mutate the pointed Timeval on some platforms, +// for instance if the call returns early. +// +// This implementation *will not* update the pointed time.Duration value accordingly. +// +// See also: https://github.com/golang/sys/blob/master/unix/syscall_unix_test.go#L606-L617 +// +// # Notes on the Syscall +// +// Because this is a blocking syscall, it will also block the carrier thread of the goroutine, +// preventing any means to support context cancellation directly. +// +// There are ways to obviate this issue. We outline here one idea, that is however not currently implemented. +// A common approach to support context cancellation is to add a signal file descriptor to the set, +// 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) { + return syscall_select(n, r, w, e, timeout) +} diff --git a/internal/platform/select_darwin.go b/internal/platform/select_darwin.go new file mode 100644 index 00000000..9e29fca7 --- /dev/null +++ b/internal/platform/select_darwin.go @@ -0,0 +1,43 @@ +package platform + +import ( + "syscall" + "time" + "unsafe" +) + +// syscall_select invokes select on Darwin, with the given timeout Duration. +// We implement our own version instead of relying on syscall.Select because the latter +// only returns the error and discards the result. +func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) { + var t *syscall.Timeval + if timeout != nil { + tv := syscall.NsecToTimeval(timeout.Nanoseconds()) + t = &tv + } + result, _, errno := syscall_syscall6( + libc_select_trampoline_addr, + uintptr(n), + uintptr(unsafe.Pointer(r)), + uintptr(unsafe.Pointer(w)), + uintptr(unsafe.Pointer(e)), + uintptr(unsafe.Pointer(t)), + 0) + res := int(result) + if errno == 0 { + return res, nil + } + return res, errno +} + +// libc_select_trampoline_addr is the address of the +// `libc_select_trampoline` symbol, defined in `select_darwin.s`. +// +// We use this to invoke the syscall through syscall_syscall6 imported below. +var libc_select_trampoline_addr uintptr + +// Imports the select symbol from libc as `libc_select`. +// +// Note: CGO mechanisms are used in darwin regardless of the CGO_ENABLED value +// or the "cgo" build flag. See /RATIONALE.md for why. +//go:cgo_import_dynamic libc_select select "/usr/lib/libSystem.B.dylib" diff --git a/internal/platform/select_darwin.s b/internal/platform/select_darwin.s new file mode 100644 index 00000000..16e65e8e --- /dev/null +++ b/internal/platform/select_darwin.s @@ -0,0 +1,8 @@ +// lifted from golang.org/x/sys unix +#include "textflag.h" + +TEXT libc_select_trampoline<>(SB), NOSPLIT, $0-0 + JMP libc_select(SB) + +GLOBL ·libc_select_trampoline_addr(SB), RODATA, $8 +DATA ·libc_select_trampoline_addr(SB)/8, $libc_select_trampoline<>(SB) diff --git a/internal/platform/select_linux.go b/internal/platform/select_linux.go new file mode 100644 index 00000000..8a38bec0 --- /dev/null +++ b/internal/platform/select_linux.go @@ -0,0 +1,16 @@ +package platform + +import ( + "syscall" + "time" +) + +// syscall_select invokes select on Unix (unless Darwin), with the given timeout Duration. +func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) { + var t *syscall.Timeval + if timeout != nil { + tv := syscall.NsecToTimeval(timeout.Nanoseconds()) + t = &tv + } + return syscall.Select(n, (*syscall.FdSet)(r), (*syscall.FdSet)(w), (*syscall.FdSet)(e), t) +} diff --git a/internal/platform/select_test.go b/internal/platform/select_test.go new file mode 100644 index 00000000..4a1ebe7e --- /dev/null +++ b/internal/platform/select_test.go @@ -0,0 +1,92 @@ +package platform + +import ( + "os" + "runtime" + "syscall" + "testing" + "time" + + "github.com/tetratelabs/wazero/internal/testing/require" +) + +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) + if err == syscall.EINTR { + t.Logf("Select interrupted") + continue + } + require.NoError(t, err) + require.Equal(t, 0, n) + break + } + }) + + t.Run("should wait for the given duration", func(t *testing.T) { + dur := 250 * time.Millisecond + var took time.Duration + for { + // On some platforms (e.g. Linux), the passed-in timeval is + // updated by select(2). We are not accounting for this + // in our implementation. + start := time.Now() + n, err := Select(0, nil, nil, nil, &dur) + took = time.Since(start) + if err == syscall.EINTR { + t.Logf("Select interrupted after %v", took) + continue + } + require.NoError(t, err) + require.Equal(t, 0, n) + break + } + + // On some platforms the actual timeout might be arbitrarily + // less than requested. + if took < dur { + if runtime.GOOS == "linux" { + // Linux promises to only return early if a file descriptor + // becomes ready (not applicable here), or the call + // is interrupted by a signal handler (explicitly retried in the loop above), + // or the timeout expires. + t.Errorf("Select: slept for %v, expected %v", took, dur) + } else { + t.Logf("Select: slept for %v, requested %v", took, dur) + } + } + }) + + t.Run("should return 1 if a given FD has data", func(t *testing.T) { + rr, ww, err := os.Pipe() + require.NoError(t, err) + defer rr.Close() + defer ww.Close() + + _, err = ww.Write([]byte("TEST")) + require.NoError(t, err) + + rFdSet := &FdSet{} + fd := int(rr.Fd()) + rFdSet.Set(fd) + + for { + n, err := Select(fd+1, rFdSet, nil, nil, nil) + if runtime.GOOS == "windows" { + // Not implemented for fds != wasiFdStdin + require.ErrorIs(t, err, syscall.ENOSYS) + require.Equal(t, -1, n) + break + } + if err == syscall.EINTR { + t.Log("Select interrupted") + continue + } + require.NoError(t, err) + require.Equal(t, 1, n) + break + } + }) +} diff --git a/internal/platform/select_unsupported.go b/internal/platform/select_unsupported.go new file mode 100644 index 00000000..672ca400 --- /dev/null +++ b/internal/platform/select_unsupported.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux && !windows + +package platform + +import ( + "syscall" + "time" +) + +func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) { + return -1, syscall.ENOSYS +} diff --git a/internal/platform/select_windows.go b/internal/platform/select_windows.go new file mode 100644 index 00000000..164ed17f --- /dev/null +++ b/internal/platform/select_windows.go @@ -0,0 +1,31 @@ +package platform + +import ( + "syscall" + "time" +) + +// wasiFdStdin is the constant value for stdin on Wasi. +// We need this constant because on Windows os.Stdin.Fd() != 0. +const wasiFdStdin = 0 + +// syscall_select emulates the select syscall on Windows for two, well-known cases, returns syscall.ENOSYS for all others. +// If r contains fd 0, then it immediately returns 1 (data ready on stdin) and r will have the fd 0 bit set. +// If n==0 it will wait for the given timeout duration, but it will return syscall.ENOSYS if timeout is nil, +// i.e. it won't block indefinitely. +func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) { + if n == 0 { + // don't block indefinitely + if timeout == nil { + return -1, syscall.ENOSYS + } + time.Sleep(*timeout) + return 0, nil + } + if r.IsSet(wasiFdStdin) { + r.Zero() + r.Set(wasiFdStdin) + return 1, nil + } + return -1, syscall.ENOSYS +} diff --git a/internal/platform/syscall6_darwin.go b/internal/platform/syscall6_darwin.go new file mode 100644 index 00000000..273fb7dd --- /dev/null +++ b/internal/platform/syscall6_darwin.go @@ -0,0 +1,13 @@ +package platform + +import ( + "syscall" + _ "unsafe" +) + +// syscall_syscall6 is a private symbol that we link below. We need to use this +// instead of syscall.Syscall6 because the public syscall.Syscall6 won't work +// when fn is an address. +// +//go:linkname syscall_syscall6 syscall.syscall6 +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) diff --git a/internal/sys/fs.go b/internal/sys/fs.go index 7070e15c..ce0ac1fe 100644 --- a/internal/sys/fs.go +++ b/internal/sys/fs.go @@ -61,21 +61,72 @@ func (w *stdioFileWriter) Close() error { return nil } -type stdioFileReader struct { - r io.Reader - s fs.FileInfo +// 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) +} + +// 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 +} + +// 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 +} + +// 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, + } +} + +// 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) } // Stat implements fs.File -func (r *stdioFileReader) Stat() (fs.FileInfo, error) { return r.s, nil } +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) { +func (r *StdioFileReader) Read(p []byte) (n int, err error) { return r.r.Read(p) } // Close implements fs.File -func (r *stdioFileReader) Close() error { +func (r *StdioFileReader) Close() error { // Don't actually close the underlying file, as we didn't open it! return nil } @@ -300,11 +351,17 @@ func stdinReader(r io.Reader) (*FileEntry, error) { if r == nil { r = eofReader{} } - s, err := stdioStat(r, noopStdinStat) - if err != nil { - return nil, err + var freader *StdioFileReader + if stdioFileReader, ok := r.(*StdioFileReader); ok { + freader = stdioFileReader + } else { + s, err := stdioStat(r, noopStdinStat) + if err != nil { + return nil, err + } + freader = NewStdioFileReader(r, s, PollerDefaultStdin) } - return &FileEntry{Name: noopStdinStat.Name(), File: &stdioFileReader{r: r, s: s}}, nil + return &FileEntry{Name: noopStdinStat.Name(), File: freader}, nil } func stdioWriter(w io.Writer, defaultStat stdioFileInfo) (*FileEntry, error) { diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go index b7ec3514..13780a6c 100644 --- a/internal/sys/fs_test.go +++ b/internal/sys/fs_test.go @@ -23,7 +23,7 @@ import ( var testCtx = context.WithValue(context.Background(), struct{}{}, "arbitrary") var ( - noopStdin = &FileEntry{Name: "stdin", File: &stdioFileReader{r: eofReader{}, s: noopStdinStat}} + noopStdin = &FileEntry{Name: "stdin", File: NewStdioFileReader(eofReader{}, noopStdinStat, PollerDefaultStdin)} noopStdout = &FileEntry{Name: "stdout", File: &stdioFileWriter{w: io.Discard, s: noopStdoutStat}} noopStderr = &FileEntry{Name: "stderr", File: &stdioFileWriter{w: io.Discard, s: noopStderrStat}} )