dicej edited issue #12589:
I've been adding WASIp2 support to
mioand triaging failures in its test suite. This test is failing, which I tracked down towasmtime-wasispuriously changing the local address of a UDP socket whenwasi:sockets/udp#udp-socket.streamis called a second time on the same socket (equivalent to callingconnect(2)a second time in POSIX). The test fails because it uses the old local address, not realizing the socket suddenly has a new local address.Here's a minimal test case:
// udp.rs use std::{error::Error, net::UdpSocket}; fn main() -> Result<(), Box<dyn Error>> { let socket1 = UdpSocket::bind("127.0.0.1:0")?; let socket2 = UdpSocket::bind("127.0.0.1:0")?; let address1 = socket1.local_addr()?; let address2 = socket2.local_addr()?; println!( "before:\n \ socket1 local address: {address1:?}\n \ socket2 local address: {address2:?}" ); socket1.connect(address1)?; socket1.connect(address2)?; socket2.connect(address1)?; println!( "after:\n \ socket1 local address: {address1:?} remote address: {remote1:?}\n \ socket2 local address: {address2:?} remote address: {remote2:?}", address1 = socket1.local_addr()?, address2 = socket2.local_addr()?, remote1 = socket1.peer_addr()?, remote2 = socket2.peer_addr()? ); let data = b"foobar"; socket1.send(data)?; let mut buffer = [0u8; 20]; let count = socket2.recv(&mut buffer)?; assert_eq!(data, &buffer[..count]); println!("success!"); Ok(()) }If you build and run that natively on e.g. Linux using
rustc udp.rs && ./udp, you'll see it succeed, withsocket1's local address remaining unchanged before and after theUdpSocket::connectcalls.If you build and run for WASIp2 using
rustc --target=wasm32-wasip2 udp.rs && wasmtime run -Sudp,inherit-network udp.wasm, you'll see it hang indefinitely due tosocket1's local address changing due to the secondUdpSocket::connectcall.While digging into this, I found that
wasmtime-wasiproactively disconnects the socket by callingrustix::net::connect_unspecprior to reconnecting. When I comment out that code, the issue is fixed:diff --git a/crates/wasi/src/p2/host/udp.rs b/crates/wasi/src/p2/host/udp.rs index 1ef8350399..5dd426e736 100644 --- a/crates/wasi/src/p2/host/udp.rs +++ b/crates/wasi/src/p2/host/udp.rs @@ -71,9 +71,9 @@ impl udp::HostUdpSocket for WasiSocketsCtxView<'_> { // if there isn't a disconnect in between. // Step #1: Disconnect - if socket.is_connected() { - socket.disconnect()?; - } + // if socket.is_connected() { + // socket.disconnect()?; + // } // Step #2: (Re)connect if let Some(connect_addr) = remote_address { diff --git a/crates/wasi/src/sockets/udp.rs b/crates/wasi/src/sockets/udp.rs index 482c6aa090..ba1e93f18d 100644 --- a/crates/wasi/src/sockets/udp.rs +++ b/crates/wasi/src/sockets/udp.rs @@ -162,10 +162,10 @@ impl UdpSocket { // if there isn't a disconnect in between. // Step #1: Disconnect - if let UdpState::Connected(..) = self.udp_state { - udp_disconnect(&self.socket)?; - self.udp_state = UdpState::Bound; - } + // if let UdpState::Connected(..) = self.udp_state { + // udp_disconnect(&self.socket)?; + // self.udp_state = UdpState::Bound; + // } // Step #2: (Re)connect connect(&self.socket, &addr).map_err(|error| match error { Errno::AFNOSUPPORT => ErrorCode::InvalidArgument, // See `udp_bind` implementation.However, per the surrounding comments, that code is there for a reason, so I assume that removing it isn't the right solution. @badeend this seems to be related to https://github.com/bytecodealliance/wasmtime/pull/7411, so perhaps you have thoughts?
dicej added the bug label to Issue #12589.
badeend commented on issue #12589:
Every call to
udp-socket::connectis allowed to change the local socket address. This is documented on the method itself: https://github.com/WebAssembly/WASI/blob/41d0de898ec7568e032129156a3bc567d7ebea5d/proposals/sockets/wit-0.3.0-draft/types.wit#L547-L550AFAIK, this is standard POSIX behavior. Are you sure you're testing exactly the same code as the mio test? Because the mio test you linked reads: (no local address rebinding)
socket1.connect(address2).unwrap(); socket2.connect(address1).unwrap(); socket3.connect(address1).unwrap();whereas the code snippet you posted rebinds
socket1:socket1.connect(address1)?; socket1.connect(address2)?; socket2.connect(address1)?;
dicej commented on issue #12589:
Are you sure you're testing exactly the same code as the mio test?
No, it's not exactly the same; I reduced the
miotest to a minimal reproduction. Note that themiotest _does_ callconnecttwice onsocket1: scroll down to https://github.com/tokio-rs/mio/blob/66ac9fab79bf191218488c4f35c99d13935b7e12/tests/udp_socket.rs#L442
dicej commented on issue #12589:
BTW, if POSIX allows
connectto change the local address, then themiotest is just wrong, and I can submit a PR to fix it (e.g. by callingsocket3.connect(socket1.local_addr().unwrap()).unwrap()after thesocket1.connect(address3).unwrap()line). It would help to have a document to reference to justify that, though, since apparently WASI is the only platform where the test currently fails.
dicej commented on issue #12589:
(e.g. by calling
socket3.connect(socket1.local_addr().unwrap()).unwrap()after thesocket1.connect(address3).unwrap()line)Oof, actually not sure that would work, because then
socket3's local address could change. So I guess I'm not even sure how I would fix it, since every call toconnectwould invalidate the local address previously used.
alexcrichton commented on issue #12589:
@badeend do you have further links for where the clause you linked in the WASI docs come from? Naively to me that seems pretty surprising that
connectcan arbitrarily change the locally bound address, so I'd be curious to read up more there. Is this something, for example, where platforms disagree on behavior and WASI is specifying a sort of least-common-denominator semantics?
dicej edited a comment on issue #12589:
(e.g. by calling
socket3.connect(socket1.local_addr().unwrap()).unwrap()after thesocket1.connect(address3).unwrap()line)Oof, actually not sure that would work, because then
socket3's local address could change. So I guess I'm not even sure how I would fix it, since every call toconnectcould invalidate the local address previously used.
badeend commented on issue #12589:
to me that seems pretty surprising that connect can arbitrarily change the locally bound address
Yes, it is surprising, and Yes it does happen :P But only if the socket wasn't bound to a specific address/port.
Let's say, the first connect is to
127.0.0.1. The socket's local address will become some address on the loopback interface. Most likely, also127.0.0.1.Then, the same socket is reused to connect to
123.234.12.34. The loopback interface has no network path to that remote, so the local binding has to change in order to do something useful. On my machine (example below) that is192.168.178.13(windows),172.22.83.10(linux)
I was not able to find any authoritative documentation describing this in detail, or: at all. The current WASI spec is purely based on observed behavior.
To make sure I'm not going mad, I just ran this test twice on Windows and Linux; once with wasmtime as-is (_with_ the explicit disconnect), and once with the disconnect commented out:
Windows _with_ disconnect
#1. connect( 127.0.0.1:4321) changed local address to: 127.0.0.1:64916 #2. connect( 127.0.0.1:4322) changed local address to: 127.0.0.1:64916 #3. connect(123.234.12.34:4323) changed local address to: 192.168.178.13:64916Windows _without_ disconnect
#1. connect( 127.0.0.1:4321) changed local address to: 127.0.0.1:61198 #2. connect( 127.0.0.1:4322) changed local address to: 127.0.0.1:61198 #3. connect(123.234.12.34:4323) changed local address to: 192.168.178.13:61198Linux _with_ disconnect
#1. connect( 127.0.0.1:4321) changed local address to: 127.0.0.1:37851 #2. connect( 127.0.0.1:4322) changed local address to: 127.0.0.1:44532 #3. connect(123.234.12.34:4323) changed local address to: 172.22.83.10:49662Linux _without_ disconnect
#1. connect( 127.0.0.1:4321) changed local address to: 127.0.0.1:32810 #2. connect( 127.0.0.1:4322) changed local address to: 127.0.0.1:32810 #3. <<connect fails with EINVAL>>Notes:
- Even without an explicit disconnect, Windows automatically adjusts the local binding when the remote requires a different interface.
- Windows preserves the local port across disconnects.
- Windows does not require the explicit disconnect; behavior is identical either way.
- From recollection, MacOS behaves the same as Windows. But I don't have a Mac machine to verify.
- Linux allocates a new ephemeral port when disconnecting.
- Linux returns
EINVALif the existing local binding is not suitable for the new remote. This appears to be Linux-specific and is the reason wasmtime currently performs the explicit disconnect.
if POSIX allows connect to change the local address, then the mio test is just wrong,
Their test consistently passes on major platforms, so it is not obviously incorrect. However, it does rely on behavior that is not strictly guaranteed. With the specific parameters they use, that behavior just happens to be stable across current platforms.
My takeaways:
- The wasi:sockets documentation is indeed correct. But now we know for sure :P
- If a program depends on a stable local socket address, they should explicitly
bindthe socket instead of relying on undocumented behavior. That sidesteps all these problems.- This problem surfaces in wasmtime because we pre-emptively disconnect the socket. We could change this to first optimistically attempt the connect syscall, and only if that fails retry with the disconnect/reconnect dance. On Linux this changes the outcome of the test to:
#1. connect( 127.0.0.1:4321) changed local address to: 127.0.0.1:37494 #2. connect( 127.0.0.1:4322) changed local address to: 127.0.0.1:37494 #3. connect(123.234.12.34:4323) changed local address to: 172.22.83.10:33292This likely restores the behavior expected by the mio test while still handling the Linux EINVAL case when necessary.
I've opened https://github.com/bytecodealliance/wasmtime/pull/12595 as a potential fix.
alexcrichton commented on issue #12589:
Oh wow that's fascinating! Thanks so much for taking the time to test and investigate this, and I agree that #12595 is the way to go here. Definitely learn something new every day :)
dicej closed issue #12589:
I've been adding WASIp2 support to
mioand triaging failures in its test suite. This test is failing, which I tracked down towasmtime-wasispuriously changing the local address of a UDP socket whenwasi:sockets/udp#udp-socket.streamis called a second time on the same socket (equivalent to callingconnect(2)a second time in POSIX). The test fails because it uses the old local address, not realizing the socket suddenly has a new local address.Here's a minimal test case:
// udp.rs use std::{error::Error, net::UdpSocket}; fn main() -> Result<(), Box<dyn Error>> { let socket1 = UdpSocket::bind("127.0.0.1:0")?; let socket2 = UdpSocket::bind("127.0.0.1:0")?; let address1 = socket1.local_addr()?; let address2 = socket2.local_addr()?; println!( "before:\n \ socket1 local address: {address1:?}\n \ socket2 local address: {address2:?}" ); socket1.connect(address1)?; socket1.connect(address2)?; socket2.connect(address1)?; println!( "after:\n \ socket1 local address: {address1:?} remote address: {remote1:?}\n \ socket2 local address: {address2:?} remote address: {remote2:?}", address1 = socket1.local_addr()?, address2 = socket2.local_addr()?, remote1 = socket1.peer_addr()?, remote2 = socket2.peer_addr()? ); let data = b"foobar"; socket1.send(data)?; let mut buffer = [0u8; 20]; let count = socket2.recv(&mut buffer)?; assert_eq!(data, &buffer[..count]); println!("success!"); Ok(()) }If you build and run that natively on e.g. Linux using
rustc udp.rs && ./udp, you'll see it succeed, withsocket1's local address remaining unchanged before and after theUdpSocket::connectcalls.If you build and run for WASIp2 using
rustc --target=wasm32-wasip2 udp.rs && wasmtime run -Sudp,inherit-network udp.wasm, you'll see it hang indefinitely due tosocket1's local address changing due to the secondUdpSocket::connectcall.While digging into this, I found that
wasmtime-wasiproactively disconnects the socket by callingrustix::net::connect_unspecprior to reconnecting. When I comment out that code, the issue is fixed:diff --git a/crates/wasi/src/p2/host/udp.rs b/crates/wasi/src/p2/host/udp.rs index 1ef8350399..5dd426e736 100644 --- a/crates/wasi/src/p2/host/udp.rs +++ b/crates/wasi/src/p2/host/udp.rs @@ -71,9 +71,9 @@ impl udp::HostUdpSocket for WasiSocketsCtxView<'_> { // if there isn't a disconnect in between. // Step #1: Disconnect - if socket.is_connected() { - socket.disconnect()?; - } + // if socket.is_connected() { + // socket.disconnect()?; + // } // Step #2: (Re)connect if let Some(connect_addr) = remote_address { diff --git a/crates/wasi/src/sockets/udp.rs b/crates/wasi/src/sockets/udp.rs index 482c6aa090..ba1e93f18d 100644 --- a/crates/wasi/src/sockets/udp.rs +++ b/crates/wasi/src/sockets/udp.rs @@ -162,10 +162,10 @@ impl UdpSocket { // if there isn't a disconnect in between. // Step #1: Disconnect - if let UdpState::Connected(..) = self.udp_state { - udp_disconnect(&self.socket)?; - self.udp_state = UdpState::Bound; - } + // if let UdpState::Connected(..) = self.udp_state { + // udp_disconnect(&self.socket)?; + // self.udp_state = UdpState::Bound; + // } // Step #2: (Re)connect connect(&self.socket, &addr).map_err(|error| match error { Errno::AFNOSUPPORT => ErrorCode::InvalidArgument, // See `udp_bind` implementation.However, per the surrounding comments, that code is there for a reason, so I assume that removing it isn't the right solution. @badeend this seems to be related to https://github.com/bytecodealliance/wasmtime/pull/7411, so perhaps you have thoughts?
badeend commented on issue #12589:
From recollection, MacOS behaves the same as Windows. But I don't have a Mac machine to verify.
For posterity; the PR passed CI without special considerations for MacOS. This implies that it indeed behaves the same as Windows.
badeend commented on issue #12589:
So... fun fact. The same thing happens on TCP sockets as well. :monocle_face:
let client = TcpSocket::create(family).unwrap(); client.bind(IpSocketAddress::new(IpAddress::new_unspecified(family), 0)).unwrap(); println!("local address: {:?}", client.get_local_address().unwrap()); client.connect(listener_address).await.unwrap(); println!("local address: {:?}", client.get_local_address().unwrap());prints:
local address: 0.0.0.0:36431 local address: 127.0.0.1:36431I've opened https://github.com/bytecodealliance/wasmtime/pull/12601 to test this
Last updated: Feb 24 2026 at 04:36 UTC