Nils Andreas Svee ca2389174f 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.
2026-08-17 00:23:24 +07:00
2026-08-12 00:47:46 +07:00
2026-05-10 20:56:43 +07:00

irtt-rs

irtt-rs is a Rust implementation of an IRTT-compatible client and server.

It provides command-line applets and reusable Rust libraries for finite or continuous latency probing, plus the server side those probes can run against. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.

This is not the upstream IRTT project. For the original implementation, protocol background, and broader documentation, see heistp/irtt.

Features

  • Finite and continuous probe sessions
  • Concurrent probing of multiple targets
  • Reusable client library with a structured event stream
  • Human-readable table output
  • CSV, TSV, and JSON Lines output for scripts and monitoring systems
  • Selectable output columns
  • Automatic final summaries for eligible table-output runs
  • Optional terminal UI
  • Reusable Tokio-native server library
  • UDP server applet with explicit bind and session policy options
  • Router-friendly multi-applet binary layout

The installed dispatcher is named irtt-rs rather than irtt to avoid conflicting with or being mistaken for upstream IRTT.

Installation

The build requires Rust 1.88 or newer.

From a local checkout:

git clone https://github.com/Lochnair/irtt-rs.git
cd irtt-rs
cargo install --path crates/irtt-cli

This installs:

  • irtt-rs, the canonical multi-applet dispatcher
  • irtt-cli, the stream and text client
  • irtt-server, the UDP server

To also install the optional terminal UI:

cargo install --path crates/irtt-cli --features tui

Quick start

Probe a server using the default settings:

irtt-cli netperf-eu.bufferbloat.net:2112

Set the test duration and probe interval:

irtt-cli netperf-eu.bufferbloat.net:2112 \
    --duration 30s \
    --interval 100ms

Run continuously until interrupted:

irtt-cli netperf-eu.bufferbloat.net:2112 --duration 0

Use Ctrl-C to stop gracefully.

Multiple targets

Probe several targets concurrently:

irtt-cli host-a:2112 host-b:2112

Every target argument accepts [LABEL=]TARGET. An optional LABEL= prefix assigns the logical target name used in output and the TUI; without it, the target string itself is used. Assign stable labels to targets:

irtt-cli ams=ams.example.com:2112 sg=sg.example.com:2112

Labeled and unlabeled targets can be freely mixed in one argument list:

irtt-cli host-a:2112 ams=ams.example.com:2112

Multi-target sessions use staggered pacing by default. To send each target's probes together:

irtt-cli host-a:2112 host-b:2112 --pacing burst

Target labels are included in the default multi-target output.

Output formats

The CLI supports four event-row formats:

  • table: human-readable terminal output
  • csv: comma-separated output
  • tsv: tab-separated output
  • jsonl: one JSON object per event

Examples:

irtt-cli <server> --format table
irtt-cli <server> --format jsonl
irtt-cli <server> --format csv \
    --columns event,seq,remote,effective_rtt_us

For a stream containing only effective RTT values in microseconds:

irtt-cli <server> \
    --format tsv \
    --columns effective_rtt_us \
    --header never

List all available columns with:

irtt-cli --list-columns

Useful measurement fields include:

  • raw_rtt_us: client-observed send-to-receive RTT
  • adjusted_rtt_us: RTT adjusted for server processing when available
  • effective_rtt_us: adjusted RTT when available, otherwise raw RTT
  • sd_us and rd_us: send and receive one-way delay estimates
  • ipdv_us: inter-packet delay variation
  • server_processing_us: time spent processing the packet at the server

Adjusted RTT can be negative when server processing exceeds the measured raw RTT. One-way delay estimates can be negative because of clock skew between the client and server.

Default table output prints a final summary after completed finite runs and interrupted continuous runs when the run policy permits it. CSV, TSV, and JSON Lines output do not print this summary.

A peer closing a finite CLI or TUI run is accepted as a terminal outcome. In continuous mode, peer closure exits nonzero unless the user requested interruption, allowing a supervisor to restart the client.

Terminal UI

When built with the tui feature, irtt-tui provides a live cumulative dashboard:

irtt-tui <server>

It runs continuously by default. A finite duration can be selected explicitly:

irtt-tui <server> --duration 30s

Multiple targets and pacing options work the same way as in irtt-cli:

irtt-tui host-a:2112 host-b:2112 --pacing burst

Quit with q or Ctrl-C.

Server

The server applet requires at least one explicit bind address:

irtt-server --bind 127.0.0.1:2112

The same applet is reachable through the dispatcher:

irtt-rs server --bind 192.0.2.10:2112

Repeat --bind to serve several addresses from one process, in the order given:

irtt-server \
    --bind 0.0.0.0:2112 \
    --bind [::]:2112

Every invocation runs through the same multi-listener path, so one address is an ordinary set of one rather than a separate mode. The policy options below are applied to every listener, but the listeners are otherwise independent: each has its own sessions and tokens, so a session belongs to the address it was opened on and --max-sessions bounds each listener rather than the process. Binding is all or nothing — if any address cannot be bound, none are served — and a port of 0 selects an unused port per listener. If a listener fails while running, the others are shut down with it rather than leaving a service configured for two families answering on one.

An explicit interface address works on every supported system. A wildcard bind such as --bind 0.0.0.0:2112 reads each request's local destination from packet metadata and sends that request's reply from the same address, so a client on a multi-homed host is answered from the endpoint it contacted. That is implemented on Linux, macOS and FreeBSD; elsewhere a wildcard bind is refused rather than served from a routing-table source address a client would discard. Wildcard IPv4 and IPv6 listeners may share one port, as above.

Each bound endpoint is printed on startup once every listener is up, which also resolves a port of 0. Ctrl-C stops the server gracefully.

Session policy is set with options that map directly onto the server library's configuration; anything left unset keeps the library default:

irtt-server \
    --bind 192.0.2.10:2112 \
    --max-sessions 512 \
    --idle-timeout 30s

Two optional controls restrict what the server will provide a session, and both are off unless asked for:

irtt-server \
    --bind 192.0.2.10:2112 \
    --timestamp-allowance single \
    --no-dscp

--timestamp-allowance takes dual (the default, honoring every requested placement), single, which provides at most one timestamp instant and answers a request for both receive and send timestamps with the midpoint, or none, which provides no timestamps. The requested clock source is never changed, so a single instant is still reported once per requested clock. --no-dscp negotiates any requested DSCP to zero, so clients are told their echo replies will be unmarked, and they are sent unmarked.

To require authentication, pass the shared key both sides use:

irtt-server --bind 192.0.2.10:2112 --hmac secret

For the full option list:

irtt-server --help

Library usage

irtt-client exposes the client session and event layer independently of CLI formatting and statistics.

Add it from a local checkout:

[dependencies]
irtt-client = { path = "crates/irtt-client", features = ["tokio"] }

Minimal managed-client example for a synchronous frontend:

use std::time::Duration;

use irtt_client::managed::{
    BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy,
    ManagedTargetConfig, TargetId,
};
use irtt_client::ClientConfig;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = ManagedClientConfig {
        client: ClientConfig {
            duration: Some(Duration::from_secs(10)),
            interval: Duration::from_secs(1),
            ..ClientConfig::default()
        },
        completion: ManagedCompletionPolicy::FinishWhenQuiescent,
        ..ManagedClientConfig::default()
    };
    let targets = vec![ManagedTargetConfig::new(
        TargetId::from("edge"),
        "netperf-eu.bufferbloat.net:2112",
    )];
    let owner = BlockingManagedClient::start(config, targets)?;
    let outcome = owner.join()?;
    println!("session ended: {:?}", outcome.end_reason);

    Ok(())
}

For presentation events, use BlockingManagedClient::start_with_subscription, ManagedEvent, and ManagedEventTryRecvError, while consulting the durable handle.status() and final outcome for authoritative state. Presentation events are bounded and lossy, not reliable history. Dynamic desired-target updates are available through ManagedClientHandle::update_targets.

BlockingManagedClient owns a dedicated Tokio runtime and thread for synchronous callers. Callers that already own Tokio can drive ManagedClientTask directly.

Binaries and features

Build Binaries
--no-default-features irtt-rs
--no-default-features --features server irtt-rs, irtt-server
Default features irtt-rs, irtt-cli, irtt-server
--features tui irtt-rs, irtt-cli, irtt-server, irtt-tui
--all-features irtt-rs, irtt-cli, irtt-server, irtt-tui

irtt-cli requires the client feature, irtt-server the server feature, and irtt-tui the tui feature. The irtt-rs dispatcher is always built, and reports which applets the build actually contains.

For available command-line options:

irtt-cli --help
irtt-server --help
irtt-tui --help

Development

Common verification commands:

cargo fmt --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo build -p irtt-cli --all-features --release

Project status

The client, event stream, machine-readable output, multi-target execution, local statistics, and optional TUI are implemented.

The server library and the server applet are implemented: open negotiation, session state, echo processing, per-session rate limiting, idle expiry, the maximum-duration close, HMAC authentication, the negotiated per-session reply traffic class, which is applied on sockets that support it, server fill, wildcard reply-source selection on Linux, macOS and FreeBSD, the optional timestamp and DSCP restriction controls, and serving several listeners from one process with independent sessions per listener.

Server replies fill their payload with the negotiated ServerFill mode: none, which is zero-filled, rand, or pattern: with a repeating hexadecimal pattern. Every valid descriptor is honored; a descriptor the server cannot parse, and a client that expresses no fill preference at all, get the default pattern:69727474.

S
Description
No description provided
Readme Apache-2.0
8 MiB
0 Stars 2 Watchers 0 Forks
2026-08-20 15:42:21 +00:00
Languages
Rust 95.6%
Python 4.3%