Skip to content

Commit 8b2d36b

Browse files
authored
fix(netwatch): ignore transient recv errors (#166)
## Description On Windows, `UdpSocket::recv` can return an error if the previous *send* operation on that socket failed with an ICMP error. The error either surfaces as `WSAECONNRESET`, which the Rust standard library converts into a `io::ErrorKind::ConnectionReset`, or as a `WSAENETRESET`, which becomes `ErrorKind::Uncategorized`. `WSAECONNRESET` is emitted if the previous send failed due to a *ICMP port unreachable*, and `WSAENETRESET` if the previous send got a TTL expired (I think, not sure exactly). Both of these errors are transient, because they are issued max. once per sent datagram. The next recv on the socket then returns either a datagram, a different error, or WouldBlock. This PR changes the behavior such that we ignore these errors. It comes with a regression test (first commit). I confirmed that it fails on windows without the fix in the second commit. Fixes n0-computer/iroh#4297 Fixes n0-computer/iroh-gossip#149 Also tested in n0-computer/iroh#4348 ## Breaking Changes None ## Notes & open questions We can and likely should set the `SIO_UDP_CONNRESET` socket option on the underlying socket, which supresses at least the `WSAECONNRESET` error altogether. This would need to happen in `noq_udp` though. And even if we did it, it's still better to also ignore these errors here IMO. ## Change checklist - [x] Self-review. - [x] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [x] Tests if relevant. - [x] All breaking changes documented.
1 parent 59a1a38 commit 8b2d36b

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

netwatch/src/udp.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ impl UdpSocket {
222222
self.mark_broken();
223223
None
224224
}
225+
// A transient receive error leaves the socket healthy with the next datagram still queued,
226+
// so we drop the error poll again rather than surface a spurious failure.
227+
_ if is_transient_read_error(&error) => None,
225228
_ => Some(error),
226229
}
227230
}
@@ -489,6 +492,43 @@ impl UdpSocket {
489492
}
490493
}
491494

495+
/// `WSAENETRESET` (Winsock error 10052).
496+
///
497+
/// On a UDP socket Windows returns this from a recv when a previously sent datagram
498+
/// could not be delivered because its TTL expired in transit, which the network
499+
/// reports back as an ICMP Time Exceeded message. It describes the fate of that one
500+
/// datagram, not the state of the socket: the socket stays usable and the error is
501+
/// cleared once read, so the next recv proceeds normally. The Rust standard library
502+
/// does not map this code to an [`io::ErrorKind`] (unlike `WSAECONNRESET`), so we match
503+
/// it by its raw OS value.
504+
#[cfg(windows)]
505+
const WSAENETRESET: i32 = 10052;
506+
507+
/// Whether a read error is a transient condition that should be retried rather than
508+
/// surfaced to the caller as a failure.
509+
///
510+
/// On Windows the stack reports the fate of a *previously sent* datagram against the
511+
/// next recv on the same socket, so an ICMP reply surfaces as a recv error even though
512+
/// the socket is healthy. `WSAECONNRESET` reports an ICMP Port Unreachable, meaning the
513+
/// destination had no listener. `WSAENETRESET` reports an ICMP Time Exceeded, meaning a
514+
/// datagram's TTL expired in transit. Both are transient for the same reason: each
515+
/// describes a single datagram and is delivered exactly once, so reading it clears the
516+
/// condition and the following recv returns real data.
517+
///
518+
/// We treat `ConnectionReset` as transient on every platform, not only Windows:
519+
/// ECONNRESET is undefined in QUIC and can be injected by an attacker, so
520+
/// it must never tear down the receive path.
521+
fn is_transient_read_error(error: &io::Error) -> bool {
522+
if error.kind() == io::ErrorKind::ConnectionReset {
523+
return true;
524+
}
525+
#[cfg(windows)]
526+
if error.raw_os_error() == Some(WSAENETRESET) {
527+
return true;
528+
}
529+
false
530+
}
531+
492532
/// Receive future
493533
#[derive(Debug)]
494534
pub struct RecvFut<'a, 'b> {
@@ -1121,4 +1161,58 @@ mod tests {
11211161
handle.await?;
11221162
Ok(())
11231163
}
1164+
1165+
/// Regression test for the Windows behavior handled by [`is_transient_read_error`].
1166+
///
1167+
/// A recv call must survive an ICMP error caused by an earlier send on the same socket.
1168+
///
1169+
/// On Windows, sending a UDP datagram to a port with no listener draws an ICMP
1170+
/// port-unreachable, and the OS reports it against the *next* recv on that socket as
1171+
/// WSAECONNRESET. Before the fix our recv loop surfaced that error, so a perfectly
1172+
/// good datagram waiting behind it was lost and the recv failed. After the fix the
1173+
/// error is ignored and the real datagram is delivered.
1174+
///
1175+
/// This only exercises the bug on Windows. Other platforms do not deliver the ICMP
1176+
/// error to an unconnected recv, so the recv just returns the datagram and the test
1177+
/// passes whether or not the fix is present.
1178+
#[tokio::test]
1179+
async fn test_recv_survives_icmp_unreachable_from_prior_send() -> TestResult {
1180+
use std::time::Duration;
1181+
1182+
// The socket under test, plus a legitimate peer to deliver a real datagram.
1183+
let receiver = UdpSocket::bind_local(IpFamily::V4, 0)?;
1184+
let receiver_addr = receiver.local_addr()?;
1185+
let sender = UdpSocket::bind_local(IpFamily::V4, 0)?;
1186+
let sender_addr = sender.local_addr()?;
1187+
1188+
// A definitely-closed port: bind a socket, take its address, then close it.
1189+
let closed_addr = {
1190+
let tmp = UdpSocket::bind_local(IpFamily::V4, 0)?;
1191+
let addr = tmp.local_addr()?;
1192+
tmp.close().await;
1193+
addr
1194+
};
1195+
1196+
// The receiver pokes the closed port. On Windows the ICMP port-unreachable that
1197+
// comes back arms WSAECONNRESET against the receiver's next recv.
1198+
receiver.send_to(b"void", closed_addr).await?;
1199+
1200+
// Give the ICMP reply time to arrive, so the error is pending before the real
1201+
// datagram and the recv.
1202+
tokio::time::sleep(Duration::from_millis(100)).await;
1203+
1204+
// The peer sends a real datagram that the receiver must deliver.
1205+
sender.send_to(b"hello", receiver_addr).await?;
1206+
1207+
// Before the fix this recv returns WSAECONNRESET on Windows instead of "hello".
1208+
let mut buf = [0u8; 16];
1209+
let (n, from) = tokio::time::timeout(Duration::from_secs(5), receiver.recv_from(&mut buf))
1210+
.await
1211+
.expect("recv must not hang")?;
1212+
1213+
assert_eq!(&buf[..n], b"hello");
1214+
assert_eq!(from, sender_addr);
1215+
1216+
Ok(())
1217+
}
11241218
}

0 commit comments

Comments
 (0)