Stream: git-wasmtime

Topic: wasmtime / issue #12612 [wasi-sockets] Fresh UDP sockets ...


view this post on Zulip Wasmtime GitHub notifications bot (Feb 17 2026 at 22:33):

dicej opened issue #12612:

I'm (still) working to add WASIp2 support to mio and have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returning error-code::would-block from outgoing-datagram-stream.send (which wasi-libc translates to returning EWOULDBLOCK from send(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for which outgoing-datagram-stream.check-send has returned a non-zero number. It can also happen when poll.poll has returned just a write-ready event for the pollable representing the outgoing-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 see Error: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g. rustc udp.rs && while ./udp; do :; done on Linux, it will succeed indefinitely.

When I dug into this, I found that wasmtime-wasi uses tokio::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 returns would-block to the guest, which indicates that tokio is applying backpressure rather than the OS.

I imagine that in most real-world code, returning spurious would-block errors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes the mio tests 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}_std to temporarily convert between the Tokio version and the std version. I imagine that could have other implications, though.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 17 2026 at 22:33):

dicej added the bug label to Issue #12612.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 17 2026 at 22:33):

dicej added the wasi label to Issue #12612.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 17 2026 at 22:36):

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:

view this post on Zulip Wasmtime GitHub notifications bot (Feb 17 2026 at 22:38):

dicej edited issue #12612:

I'm (still) working to add WASIp2 support to mio and have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returning error-code::would-block from outgoing-datagram-stream.send (which wasi-libc translates to returning EWOULDBLOCK from send(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for which outgoing-datagram-stream.check-send has returned a non-zero number. It can also happen when poll.poll has just returned a write-ready event for the pollable representing the outgoing-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 see Error: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g. rustc udp.rs && while ./udp; do :; done on Linux, it will succeed indefinitely.

When I dug into this, I found that wasmtime-wasi uses tokio::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 returns would-block to the guest, which indicates that tokio is applying backpressure rather than the OS.

I imagine that in most real-world code, returning spurious would-block errors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes the mio tests 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}_std to temporarily convert between the Tokio version and the std version. I imagine that could have other implications, though.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 18 2026 at 23:18):

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 send is executed the readiness has already been updated to indicate that the socket is writable -- there's a background thread doing epoll_wait which is woken up immediately after the epoll_ctl adding 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:

One 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.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 18 2026 at 23:27):

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 send on a fresh socket without polling it for writability first, which limits our options. I suppose we could kick it off in udp-socket.start-bind and only return success from udp-socket.finish-bind once it's ready.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 19 2026 at 08:49):

badeend commented on issue #12612:

Every I/O operation is allowed to return would-block at 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::new method 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?

view this post on Zulip Wasmtime GitHub notifications bot (Feb 19 2026 at 21:53):

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 with epoll_ctl. That's a good point that calling socket.writable() maybe possible. We'd have to make some methods async that 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 backtrace

Here you can see that on the first iteration the poll_send_ready changed between the print and the poll, but on the next iteration it stayed not ready.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 20 2026 at 18:23):

dicej commented on issue #12612:

Quick update on this: even after following @alexcrichton's advice and modifying the mio test to poll for write readiness before calling send, the test was _still_ flaky. I tracked it down to the Pollable impl for OutgoingDatagramStream which currently assumes that a fresh socket will be writable and not bother asking tokio whether that's true. I've verified that fixing that fixes the (modified) mio test, so I'll post a PR for that and consider that to have fixed this issue.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 20 2026 at 18:23):

dicej edited a comment on issue #12612:

Quick update on this: even after following @alexcrichton's advice and modifying the mio test to poll for write readiness before calling send, the test was _still_ flaky. I tracked it down to the Pollable impl for OutgoingDatagramStream which currently assumes that a fresh socket will be writable and doesn't bother asking tokio whether that's true. I've verified that fixing that fixes the (modified) mio test, so I'll post a PR for that and consider that to have fixed this issue.

view this post on Zulip Wasmtime GitHub notifications bot (Feb 20 2026 at 20:53):

dicej closed issue #12612:

I'm (still) working to add WASIp2 support to mio and have noticed that several of the UDP tests _usually_ pass but sometimes fail. When they fail, it's always due to the host returning error-code::would-block from outgoing-datagram-stream.send (which wasi-libc translates to returning EWOULDBLOCK from send(2)). This can happen even if the socket was just created, has not previously been used to send anything, and for which outgoing-datagram-stream.check-send has returned a non-zero number. It can also happen when poll.poll has just returned a write-ready event for the pollable representing the outgoing-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 see Error: Os { code: 6, kind: WouldBlock, message: "Resource temporarily unavailable" }. However, if you run e.g. rustc udp.rs && while ./udp; do :; done on Linux, it will succeed indefinitely.

When I dug into this, I found that wasmtime-wasi uses tokio::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 returns would-block to the guest, which indicates that tokio is applying backpressure rather than the OS.

I imagine that in most real-world code, returning spurious would-block errors occasionally wouldn't be a problem; the application can just poll and try again. However, it makes the mio tests 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}_std to temporarily convert between the Tokio version and the std version. I imagine that could have other implications, though.


Last updated: Feb 24 2026 at 04:36 UTC