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

+25 -1
View File
@@ -152,6 +152,18 @@ pub(super) async fn receive_bound(
receive_ancillary(socket, buffer, false).await
}
/// Consecutive interrupted (`EINTR`) nonblocking receives tolerated before
/// this receive loop explicitly yields to the runtime.
///
/// `try_io` only clears the reactor's readiness state on `WouldBlock`; an
/// `Interrupted` result leaves it untouched, so `socket.readable().await`
/// can resolve immediately again without a genuine new readiness edge. Under
/// a sustained signal delivery rate that could otherwise retry in a tight
/// loop without ever suspending this task. Yielding periodically bounds that
/// without treating the interruption as any kind of failure or terminating
/// the listener.
const INTERRUPTED_YIELD_THRESHOLD: u32 = 64;
/// `require_destination` is the listener's own wildcard-ness. It decides both
/// what this receive must recover and how severe a truncated control buffer is.
async fn receive_ancillary(
@@ -159,12 +171,24 @@ async fn receive_ancillary(
buffer: &mut [u8],
require_destination: bool,
) -> io::Result<Option<ReceivedDatagram>> {
let mut consecutive_interrupted = 0_u32;
loop {
socket.readable().await?;
match socket.try_io(Interest::READABLE, || {
receive_once(socket, buffer, require_destination)
}) {
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
consecutive_interrupted = 0;
continue;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {
consecutive_interrupted += 1;
if consecutive_interrupted >= INTERRUPTED_YIELD_THRESHOLD {
consecutive_interrupted = 0;
tokio::task::yield_now().await;
}
continue;
}
result => return result,
}
}