fix(io): retry interrupted receives

Real signal delivery on Linux (observed via x86_64-musl workspace
testing) can interrupt blocking and nonblocking recv/recvmsg calls
with EINTR. Several production and test receive paths treated that as
a fatal error rather than a transient interruption:

- Client::open's blocking receive loop and Client::recv_once/
  recv_available's blocking receive returned ClientError::Socket on
  Interrupted instead of retrying within their existing timeout
  budget.
- AsyncClient::poll_open and poll_recv's nonblocking recvmsg path
  (Linux ancillary) failed the same way instead of retrying through
  the existing readiness/work-budget machinery.
- The server's ancillary receive loop could terminate the listener on
  an interrupted nonblocking recvmsg.
- The client's MSG_ERRQUEUE TX-timestamp drain turned an interrupted
  read of optional metadata into a fatal ClientError::Socket.
- Fake-server test helpers panicked or misclassified EINTR as a
  timeout instead of retrying it.

Interrupted is now retried transparently everywhere a receive can
observe it, while preserving each path's existing timing contract:

- Blocking Open reuses its already-absolute per-attempt deadline, so
  interruptions resume within the same attempt without retransmitting
  or extending the deadline.
- recv_once/recv_available compute one absolute deadline per logical
  receive (when a timeout is configured) so repeated interruptions
  cannot extend it past the configured timeout, and restore the
  socket's configured read timeout afterward so a later call in the
  same recv_available budget is unaffected.
- AsyncClient's poll_open already bounds its retry loop with
  OPEN_POLL_WORK_BUDGET; poll_recv gains an equivalent
  RECV_POLL_WORK_BUDGET, since unlike WouldBlock, Interrupted does not
  clear Tokio's readiness state, so an unbounded retry loop was
  otherwise possible under sustained signal delivery.
- The server's receive loop retries Interrupted the same way it
  already retries WouldBlock, yielding to the runtime after a bounded
  number of consecutive interruptions for the same reason.
- The error-queue drain treats an interrupted read as no record
  obtained this drain (deferring to the next natural drain point)
  rather than a socket error, matching its existing EWOULDBLOCK
  handling.

Deterministic regression tests use a small test-only injection hook
(mirroring existing send-injection test hooks) rather than real signal
timing, covering: Open completing despite interruptions with no
retransmit, Open's deadline not extending under repeated
interruptions, recv_once classifying a reply normally despite
interruptions, recv_once's timeout not extending under repeated
interruptions, and the AsyncClient equivalents for open and recv.

No protocol, session, or timeout-classification behavior changes; no
new dependencies; send-side EINTR handling is unchanged and out of
scope.
This commit is contained in:
Nils Andreas Svee committed 2026-08-17 00:23:24 +07:00
1 parent e3815fdeed
commit ca2389174f
10 files changed
+448 -32

No files matched your search

+20 -5
View File
@@ -4,6 +4,7 @@ mod in_tree_server;
mod real_irtt;
use std::{
io::ErrorKind,
net::{SocketAddr, UdpSocket},
process::Command,
sync::mpsc,
@@ -447,17 +448,31 @@ where
FakeServer { addr, rx, done }
}
/// A fake server's `recv_from`, retrying an interrupted syscall
/// (`EINTR`/`ErrorKind::Interrupted`) instead of panicking on it.
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
let mut buf = [0_u8; 8192];
let (size, peer) = socket.recv_from(&mut buf).unwrap();
(buf[..size].to_vec(), peer)
loop {
match socket.recv_from(&mut buf) {
Ok((size, peer)) => return (buf[..size].to_vec(), peer),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) => panic!("fake server recv_from failed: {err}"),
}
}
}
/// Like [`recv_request`], but any non-interrupting error (including a
/// configured read timeout) means "nothing more to receive" rather than a
/// failure. An interrupted syscall carries no such information and is
/// retried instead of being misclassified as quiet.
fn recv_request_timeout(socket: &UdpSocket) -> Option<Vec<u8>> {
let mut buf = [0_u8; 8192];
match socket.recv_from(&mut buf) {
Ok((size, _)) => Some(buf[..size].to_vec()),
Err(_) => None,
loop {
match socket.recv_from(&mut buf) {
Ok((size, _)) => return Some(buf[..size].to_vec()),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(_) => return None,
}
}
}