dicej opened issue #12612:
I'm (still) working to add WASIp2 support to
mioand have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returningerror-code::would-blockfromoutgoing-datagram-stream.send(whichwasi-libctranslates to returningEWOULDBLOCKfromsend(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for whichoutgoing-datagram-stream.check-sendhas returned a non-zero number. It can also happen whenpoll.pollhas returned just a write-ready event for thepollablerepresenting theoutgoing-datagram-stream.I've simplified the test case somewhat here:
// udp.rs use std::{error::Error, io::ErrorKind, net::UdpSocket}; fn main() -> Result<(), Box<dyn Error>> { for _ in 0..50 { let socket1 = UdpSocket::bind("127.0.0.1:0")?; socket1.set_nonblocking(true)?; let socket2 = UdpSocket::bind("127.0.0.1:0")?; socket2.set_nonblocking(true)?; let address1 = socket1.local_addr()?; let address2 = socket2.local_addr()?; socket1.connect(address2)?; socket2.connect(address1)?; let data = b"foobar"; socket1.send(data)?; let mut buffer = [0u8; 20]; match socket2.recv(&mut buffer) { Ok(n) => { assert_eq!(data, &buffer[..n]); } Err(error) => { assert!(matches!(error.kind(), ErrorKind::WouldBlock)); } } } println!("success!"); Ok(()) }If you run e.g.
rustc --target=wasm32-wasip2 udp.rs && while wasmtime run -Sudp,inherit-network udp.wasm; do :; done, sooner or later you'll seeError: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g.rustc udp.rs && while ./udp; do :; doneon Linux, it will succeed indefinitely.When I dug into this, I found that
wasmtime-wasiusestokio::net::UdpSocket::try_send[_to]to send datagrams, which eventually consults this function from here to determine whether to even bother trying to send. Indeed, when I add debug logging to that function and patch Wasmtime to use it, I see that the "not ready" case corresponds to when the host returnswould-blockto the guest, which indicates thattokiois applying backpressure rather than the OS.I imagine that in most real-world code, returning spurious
would-blockerrors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes themiotests flaky, so I'd like to brainstorm solutions here.One rather heavy-handed approach could be to bypass Tokio for sending and receiving datagrams, using
tokio::net::UdpSocket::{into,from}_stdto temporarily convert between the Tokio version and thestdversion. I imagine that could have other implications, though.
dicej added the bug label to Issue #12612.
dicej added the wasi label to Issue #12612.
dicej commented on issue #12612:
@badeend your reward for being so helpful with the last issue is that I'm going to tag you on this one also :first_place:
dicej edited issue #12612:
I'm (still) working to add WASIp2 support to
mioand have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returningerror-code::would-blockfromoutgoing-datagram-stream.send(whichwasi-libctranslates to returningEWOULDBLOCKfromsend(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for whichoutgoing-datagram-stream.check-sendhas returned a non-zero number. It can also happen whenpoll.pollhas just returned a write-ready event for thepollablerepresenting theoutgoing-datagram-stream.I've simplified the test case somewhat here:
// udp.rs use std::{error::Error, io::ErrorKind, net::UdpSocket}; fn main() -> Result<(), Box<dyn Error>> { for _ in 0..50 { let socket1 = UdpSocket::bind("127.0.0.1:0")?; socket1.set_nonblocking(true)?; let socket2 = UdpSocket::bind("127.0.0.1:0")?; socket2.set_nonblocking(true)?; let address1 = socket1.local_addr()?; let address2 = socket2.local_addr()?; socket1.connect(address2)?; socket2.connect(address1)?; let data = b"foobar"; socket1.send(data)?; let mut buffer = [0u8; 20]; match socket2.recv(&mut buffer) { Ok(n) => { assert_eq!(data, &buffer[..n]); } Err(error) => { assert!(matches!(error.kind(), ErrorKind::WouldBlock)); } } } println!("success!"); Ok(()) }If you run e.g.
rustc --target=wasm32-wasip2 udp.rs && while wasmtime run -Sudp,inherit-network udp.wasm; do :; done, sooner or later you'll seeError: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g.rustc udp.rs && while ./udp; do :; doneon Linux, it will succeed indefinitely.When I dug into this, I found that
wasmtime-wasiusestokio::net::UdpSocket::try_send[_to]to send datagrams, which eventually consults this function from here to determine whether to even bother trying to send. Indeed, when I add debug logging to that function and patch Wasmtime to use it, I see that the "not ready" case corresponds to when the host returnswould-blockto the guest, which indicates thattokiois applying backpressure rather than the OS.I imagine that in most real-world code, returning spurious
would-blockerrors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes themiotests flaky, so I'd like to brainstorm solutions here.One rather heavy-handed approach could be to bypass Tokio for sending and receiving datagrams, using
tokio::net::UdpSocket::{into,from}_stdto temporarily convert between the Tokio version and thestdversion. I imagine that could have other implications, though.
alexcrichton commented on issue #12612:
My understanding of what's happening here is that this is a race within Tokio. Tokio guards all I/O with ensuring that readiness has actually been signaled before actually doing the I/O, and it looks like all I/O readiness starts out as empty. On most iterations of the test by the time the
sendis executed the readiness has already been updated to indicate that the socket is writable -- there's a background thread doingepoll_waitwhich is woken up immediately after theepoll_ctladding the UDP socket to the set. In some iterations, though, this background thread isn't fast enough so the UDP socket's own internal readiness still says "not ready" so the I/O is blocked.The call-stack is:
UdpSocket::try_sendcallstry_iotry_iosees readiness is empty and returns without actually doing anythingOne fix is to wait for writable-ness in the test itself which would resolve this. Whether that's acceptable, I'm not entirely sure. Short of that I'm not sure how best to fix this. Reaching inside and performing operations manually runs the risk of bypassing Tokio's tracking of the readiness state, which can maybe still work but would require some finesse.
dicej commented on issue #12612:
One fix is to wait for writable-ness in the test itself which would resolve this.
Where would that happen? This mio test calls
sendon a fresh socket without polling it for writability first, which limits our options. I suppose we could kick it off inudp-socket.start-bindand only return success fromudp-socket.finish-bindonce it's ready.
badeend commented on issue #12612:
Every I/O operation is allowed to return
would-blockat any time, even if the socket was just created or a previous poll indicated readiness. So strictly speaking, the mio test is somewhat naive. That said, since it works as expected on other platforms, this is likely an edge case that mio just do happens to surface first, but they probably won't be the last. Test suites in specific are often less defensive than production code.If this is easily fixable on our end I think we should.
after the epoll_ctl adding the UDP socket to the set
Do you know when does this happens? Is it at creation time? If so, maybe we can update our
UdpSocket::newmethod to await readiness immediately E.g.let socket = with_ambient_tokio_runtime(|| { tokio::net::UdpSocket::try_from(unsafe { std::net::UdpSocket::from_raw_socketlike(fd.into_raw_socketlike()) }) })?; socket.readable().await?; socket.writable().await?;IIUC, that should _usually_ not actually block, correct?
alexcrichton commented on issue #12612:
One fix is to wait for writable-ness in the test itself which would resolve this.
Where would that happen?
My thinking is that it'd just be added to the test. The test wouldn't send on a socket immediately, it would await for the socket to become writable first (via poll/epoll/etc) and then it would send data. That'd require changing the test itself.
after the epoll_ctl adding the UDP socket to the set
Do you know when does this happens? Is it at creation time?
Creation time yeah. Construction of a
tokio::net::UdpSocket, even from a std-based socket, will register it with the I/O event loop withepoll_ctl. That's a good point that callingsocket.writable()maybe possible. We'd have to make some methodsasyncthat aren't currently async, but that's not the end of the world.Personally though I think the best fix would be to modify the test in question to wait for writable sockets.
As an example, also to confirm my thinking, this program will fail on native for me eventually:
use std::task::{Context, Poll}; #[tokio::main] async fn main() { for i in 0.. { println!("{i}"); let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); dbg!(test(|cx| s.poll_recv_ready(cx)).await); dbg!(test(|cx| s.poll_send_ready(cx)).await); assert!(test(|cx| s.poll_send_ready(cx)).await.is_some()); } } async fn test<T>(mut f: impl FnMut(&mut Context) -> Poll<T>) -> Option<T> { std::future::poll_fn(|cx| match f(cx) { Poll::Ready(r) => Poll::Ready(Some(r)), Poll::Pending => Poll::Ready(None), }) .await }and example output is:
$ cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s Running `target/debug/wat` 0 [src/main.rs:9:9] test(|cx| s.poll_recv_ready(cx)).await = None [src/main.rs:10:9] test(|cx| s.poll_send_ready(cx)).await = None 1 [src/main.rs:9:9] test(|cx| s.poll_recv_ready(cx)).await = None [src/main.rs:10:9] test(|cx| s.poll_send_ready(cx)).await = None thread 'main' (559208) panicked at src/main.rs:11:9: assertion failed: test(|cx| s.poll_send_ready(cx)).await.is_some() note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceHere you can see that on the first iteration the
poll_send_readychanged between the print and the poll, but on the next iteration it stayed not ready.
dicej commented on issue #12612:
Quick update on this: even after following @alexcrichton's advice and modifying the
miotest to poll for write readiness before callingsend, the test was _still_ flaky. I tracked it down to thePollableimpl forOutgoingDatagramStreamwhich currently assumes that a fresh socket will be writable and not bother askingtokiowhether that's true. I've verified that fixing that fixes the (modified)miotest, so I'll post a PR for that and consider that to have fixed this issue.
dicej edited a comment on issue #12612:
Quick update on this: even after following @alexcrichton's advice and modifying the
miotest to poll for write readiness before callingsend, the test was _still_ flaky. I tracked it down to thePollableimpl forOutgoingDatagramStreamwhich currently assumes that a fresh socket will be writable and doesn't bother askingtokiowhether that's true. I've verified that fixing that fixes the (modified)miotest, so I'll post a PR for that and consider that to have fixed this issue.
dicej closed issue #12612:
I'm (still) working to add WASIp2 support to
mioand have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returningerror-code::would-blockfromoutgoing-datagram-stream.send(whichwasi-libctranslates to returningEWOULDBLOCKfromsend(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for whichoutgoing-datagram-stream.check-sendhas returned a non-zero number. It can also happen whenpoll.pollhas just returned a write-ready event for thepollablerepresenting theoutgoing-datagram-stream.I've simplified the test case somewhat here:
// udp.rs use std::{error::Error, io::ErrorKind, net::UdpSocket}; fn main() -> Result<(), Box<dyn Error>> { for _ in 0..50 { let socket1 = UdpSocket::bind("127.0.0.1:0")?; socket1.set_nonblocking(true)?; let socket2 = UdpSocket::bind("127.0.0.1:0")?; socket2.set_nonblocking(true)?; let address1 = socket1.local_addr()?; let address2 = socket2.local_addr()?; socket1.connect(address2)?; socket2.connect(address1)?; let data = b"foobar"; socket1.send(data)?; let mut buffer = [0u8; 20]; match socket2.recv(&mut buffer) { Ok(n) => { assert_eq!(data, &buffer[..n]); } Err(error) => { assert!(matches!(error.kind(), ErrorKind::WouldBlock)); } } } println!("success!"); Ok(()) }If you run e.g.
rustc --target=wasm32-wasip2 udp.rs && while wasmtime run -Sudp,inherit-network udp.wasm; do :; done, sooner or later you'll seeError: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g.rustc udp.rs && while ./udp; do :; doneon Linux, it will succeed indefinitely.When I dug into this, I found that
wasmtime-wasiusestokio::net::UdpSocket::try_send[_to]to send datagrams, which eventually consults this function from here to determine whether to even bother trying to send. Indeed, when I add debug logging to that function and patch Wasmtime to use it, I see that the "not ready" case corresponds to when the host returnswould-blockto the guest, which indicates thattokiois applying backpressure rather than the OS.I imagine that in most real-world code, returning spurious
would-blockerrors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes themiotests flaky, so I'd like to brainstorm solutions here.One rather heavy-handed approach could be to bypass Tokio for sending and receiving datagrams, using
tokio::net::UdpSocket::{into,from}_stdto temporarily convert between the Tokio version and thestdversion. I imagine that could have other implications, though.
Last updated: Feb 24 2026 at 04:36 UTC