-
Notifications
You must be signed in to change notification settings - Fork 259
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: use File.Poll for all blocking FDs in poll_oneoff #1606
Conversation
This includes stdin, pipes but also sockets. Updates RATIONALE.md Signed-off-by: Edoardo Vacchi <[email protected]>
I added more test cases, and I realized some issues with file sock on both Windows and POSIX re: setting the nonblocking flag. I also added a wasi test case with zig-cc. Tomorrow I'll try to figure out if I can write another implementation in Rust and/or Go that actually invokes poll with multiple FDs (I am not sure the higher-level APIs actually do it) EDIT: heh, I tried to write a simple example with // mainMixed is an explicit test of a blocking socket + stdin pipe.
func mainMixed() error {
// Get a listener from the pre-opened file descriptor.
// The listener is the first pre-open, with a file-descriptor of 3.
f := os.NewFile(3, "")
l, err := net.FileListener(f)
defer f.Close()
if err != nil {
return err
}
defer l.Close()
ch1 := make(chan error)
ch2 := make(chan error)
go func() {
// Accept a connection
conn, err := l.Accept()
if err != nil {
ch1 <- err
return
}
defer conn.Close()
// Do a blocking read of up to 32 bytes.
// Note: the test should write: "wazero", so that's all we should read.
var buf [32]byte
n, err := conn.Read(buf[:])
if err != nil {
ch1 <- err
return
}
fmt.Println(string(buf[:n]))
close(ch1)
}()
go func() {
b, err := io.ReadAll(os.Stdin)
if err != nil {
ch2 <- err
return
}
os.Stdout.Write(b)
close(ch2)
}()
err1 := <-ch1
err2 := <-ch2
if err1 != nil {
return err1
}
if err2 != nil {
return err2
}
return nil
} |
Signed-off-by: Edoardo Vacchi <[email protected]>
Thanks for digging into the integration tests. This type of code/behavior is hard to pin down and exactly where the extra tests come in: to establish an "implementation quorum" please ping back when you feel things are settled or need a hand from someone else (even if technical over my head ;)) |
Signed-off-by: Edoardo Vacchi <[email protected]>
Signed-off-by: Edoardo Vacchi <[email protected]>
bbb7b33
to
65d5b3c
Compare
Ok, I added a test for gotip, and I have also figured out something for Rust (using tokio-rs/mio).
I think at this point this is ready for review. It may still lack a bit of polish but your feedback is welcome. EDIT: I have updated the top post. |
Signed-off-by: Edoardo Vacchi <[email protected]>
Signed-off-by: Edoardo Vacchi <[email protected]>
// if the fd is Stdin, and it is in non-blocking mode, | ||
// do not ack yet, append to a slice for delayed evaluation. | ||
blockingStdinSubs = append(blockingStdinSubs, evt) | ||
writeEvent(outBuf[outOffset:], evt) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
writeEvents has been simplified, we pass the buffer at the right offset already
// and we don't need to wait for the timeout: clear it. | ||
if readySubs != 0 { | ||
timeout = 0 | ||
sysCtx := mod.(*wasm.ModuleInstance).Sys |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the section below has been reordered for clarity
} else if file.File.IsNonblock() { | ||
writeEvent(outBuf[outOffset:], evt) | ||
nevents++ | ||
} else { | ||
writeEvent(outBuf, evt) | ||
readySubs++ | ||
// If the fd is blocking, do not ack yet, | ||
// append to a slice for delayed evaluation. | ||
fe := &filePollEvent{f: file, e: evt} | ||
blockingSubs = append(blockingSubs, fe) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these have been reordered for clarity; first we avoid the double negation (!IsNonblock()
), second the two immediate writes are in the if + else if
, while else
handles the special case of "delayed" processing.
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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fdReadSubFd
is now being used in other poll tests (see above); e.g. to create multiple records; in order for such records to be valid, we zero-pad the byte slice to the right size (32 bytes)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
0x0, 0x0, 0x0, 0x0, | |
0x0, 0x0, 0x0, 0x0, // pad to record size (32 bytes) |
@@ -47,7 +47,7 @@ func Test_sockAccept(t *testing.T) { | |||
t.Run(tc.name, func(t *testing.T) { | |||
ctx := experimentalsock.WithConfig(testCtx, experimentalsock.NewConfig().WithTCPListener("127.0.0.1", 0)) | |||
|
|||
mod, r, log := requireProxyModuleWithContext(ctx, t, wazero.NewModuleConfig()) | |||
mod, r, log := requireProxyModuleWithContext(ctx, t, wazero.NewModuleConfig().WithSysNanosleep()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
poll_oneoff now respects SysNanosleep
thus, it has to be configured properly
@@ -100,7 +100,6 @@ func syscallConnControl(conn syscall.Conn, fn func(fd uintptr) (int, sys.Errno)) | |||
// because they are sensibly different from Unix's. | |||
func newTCPListenerFile(tl *net.TCPListener) socketapi.TCPSock { | |||
w := &winTcpListenerFile{tl: tl} | |||
_ = w.SetNonblock(true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we no longer default to nonblock on sockets (it's not necessary)
func (f *winTcpListenerFile) Poll(flag sys.Pflag, timeoutMillis int32) (ready bool, errno sys.Errno) { | ||
return _pollSock(f.tl, flag, timeoutMillis) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
implement Poll properly for sockets
if ready, errno := f.Poll(sys.POLLIN, 0); !ready || errno != 0 { | ||
return nil, sys.EAGAIN | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can rewrite this in terms of f.Poll()
oh, I was almost forgetting: while running |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good, just I might be missing a decision point around the poll interval. Right now, we seem to be polling via poll immediate followed by 100ms sleep.
100ms is a very long time, so I would suspect this should be a lot shorter, probably 100us even could be too long). It is worse because external sleep approach guarantees it will take that long.
So, I'm wondering mainly why not use poll with a short timeout isn't used, if it is a defect or we are trying for the native side to use the fake clock.
IMHO I think that since timeout is a parameter of poll, in the Poll api, how timeout is implemented is up to the backend, which may choose to use a real or fake clock to sleep or a native poll. If in any case we are not able to trust the implementation of the poll timeout and avoiding using it for that reason, I would try to make it very clear why not, because in worst case it can feel like "spaghetti around a problem" to do externnal orchestration of a feature defined in the poll documentation (timeout)
go closeChAfter(sysCtx, timeout, timeoutCh) | ||
|
||
pollInterval := 100 * time.Millisecond | ||
ticker := time.NewTicker(pollInterval) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fyi closeChAfter we are intentionally using the context clock, but this will use a real one..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops!
block the carrier thread of the goroutine, preventing any means | ||
to support context cancellation directly. | ||
|
||
We obviate this by invoking `poll` with a 0 timeout repeatedly, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't fully understand this. why not poll with 100ms vs poll zero+sleep? Are you suggesting that the poll implementation blocks too long even if you put 100ms? If so maybe the above paragraph needs to clarify this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, if we put a 100ms timeout, then the syscall will block for 100ms, which means it will also block the underlying OS thread. I will add a clarification.
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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
0x0, 0x0, 0x0, 0x0, | |
0x0, 0x0, 0x0, 0x0, // pad to record size (32 bytes) |
100ms is completely arbitrary, I picked that because that's what I picked in the Windows impl (which now is irrelevant, since it is being invoked here with 0ms). We could certainly use a smaller delay, I am not an expert at all here.
yeah the main issue is that the real poll is actually hogging a real OS thread, that means e.g. if you invoke poll_oneoff with a long delay it won't return control to the Go runtime until the underlying so this has more to do with the interaction between Go and the underlying syscall, than how the syscall is actually implemented at the OS-level. I will add some notes to the RATIONALE the "bonus" is that by using the ctx clock we are also respecting that clock. |
What I'm probably missing here is that this is a timeout. What I'm thinking and could be wrong, but timeout is the worst case. A delay means it is blocked regardless. So say 100us and the "file is ready to write" event happens at +10us. Using a clock sleep approach you have to wait extra 90us anyway. If this is correct then it seems we are trying too hard to not use poll's timeout even when Go uses it. In other words, I feel we are trying too hard to not use it, and in the process force a longer delay than necessary (blocking the worst case even when an event occurs before the worst case). What am I missing? |
My understanding of the problem/solution is this: If you poll with timeout:
If you poll zero and sleep the timeout:
Basically this looks like a choice/balance between giving priority to host or guest resources. For a single guest (like browser) environment, the first one would be the right call, hands down. |
since this is introducing a significant change in how we handle |
#1606 (comment) from @ncruces has basically the concern which was at the crux of this issue. I would say that the code abandoned and also in the description seems to both say that using blocking via syscall timeout is bad, yet I believe this is actually what go does in net poll. It makes me wonder how anyone would be able to choose a good value to block the client. Also why anyone would do this if there was only one blocking event. Like the guessed interval would always be wrong even if a little. You are choosing to wait not long enough or too long, unless there's a constant stream of data. If the project as a whole wants to prevent use of the timeout parameter in the syscall, basically to always block for no time and guess an interval to sleep for (with a syscall each guess).. I feel like the API should change, and remove the ability to give a timeout. (remove the param from I think personally I've said enough on this and I'll leave any decision up to you all, just maybe decide once and for all in a couple months? because we are exposing the filesystem api and it should make sense why poll would be exposed and never use the timeout val here or in a potential multi-poll scenario. |
The thing I believe whatever action to take is, that if we believe wazero should not support integrated syscall pollers, we should be very loud about it. It is a different direction than I expected considering the syscall layer otherwise acts like sys calls. I was expecting a comment like what go has on an emulated thing vs emulating poll behavior and intentionally not allowing native polling, by doing so above where someone has control of the impl (above the fs API) Basically, I would suggest study the topic in go, like here, make a decision and then try to rationalize both in API and also in the RATIONALE why specifically here only in poll we are doing like this, where other places like blocking Read will still block the calling thread etc. I've an idea that folks can figure out a justification, just I don't want to moderate it. |
This includes stdin, pipes but also sockets.
Further refinements are possible (e.g. supporting poll for reading), but most of the work is ok now.
Rationale
Instead of special-casing for stdin, we can now use
File.Poll()
on basically every file. I am limiting to those that reportblocking = true
, because otherwiseRead()
is expected... not to block (i.e. it is allowed to return EAGAIN).Notice that, previously, we knew we only handled stdin, so we always invoked
Poll
only on that one File.event
struct could be written at a wrong offset, because we precomputed the value. If for some reason one of the events was not written back, then we would leave an empty gap; this was hard to notice because of the special treatment reserved only for blocking stdin, and because in many cases we did not test more than one fd at a time (we did not simulate an unreported event).poll
syscall blocks an OS thread until it returns.Now, we iterate on each
File
that has reported as "blocking" to invoke its methodPoll()
. However:sysfs.poll()
with the given timeout.time.After()
andtime.Tick()
but we usesys.Nanosleep()
sysfs.poll()
with a zero timeout, similarly to how this is handled on the Windows side forWSAPoll
, every time the tick goes off until the timeout is reachedsys.Nanosleep()
config instead of relying onpoll
regardless of the config settings (in fact, some test cases were broken because they did not configuresys.Nanosleep()
properly if at all.Alternative ways to do this would be:
File.Poll()
with a given timeout; however, if the given timeout does leveragesysfs.poll()
this in turns issues a blocking call, taking over the underlying OS thread until the timeout is reached (which eventually may consume all resources).poll({fd_1, ..., fd_n})
which however, as already discussed in RATIONALE.md, is not an abstraction at the right level.In fact, for our intents, we may also consider exporting
File.Poll(Flag)
with no timeouts, defaulting to 0ms, avoiding the risk of blocking altogether.Finally, because emulating
poll(2)
on all platforms is not a goal, it might be possible to further refine this by replacing the Windows implementation ofsysfs.poll()
with ad-hoc versions basically remove the wrapper (e.g. without handling an arbitrary timeout, since this would be now handled inpoll_oneoff
and/or for specific file types instead of detecting them automatically insysfs.poll()
; in other words possibly going straight fromWinTcp*File.Poll()
toWSAPoll()
etc.)