Server bind defaults + documentation architecture cleanup #128

Merged
Lochnair merged 3 commits from feat/docs-layout-and-server-defaults into main 2026-08-20 15:29:02 +00:00
29 changed files with 1710 additions and 370 deletions

No files matched your search

-1
View File
@@ -15,7 +15,6 @@ license = "Apache-2.0"
rust-version = "1.88"
repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
+111 -343
View File
@@ -1,26 +1,75 @@
# irtt-rs
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Qualify the missing server-fill capability

The project does implement a server-side payload-fill policy: echo replies honor the session's negotiated fill, including the fixed fallback behavior documented later in this change. What is absent is an operator-configurable server fill policy, so listing the unqualified capability among things this project does not implement can mislead readers into thinking the server cannot generate negotiated payload fills at all; qualify this entry as configurable policy.

AGENTS.md reference: AGENTS.md:L60-L60

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Qualify the missing server-fill capability** The project does implement a server-side payload-fill policy: echo replies honor the session's negotiated fill, including the fixed fallback behavior documented later in this change. What is absent is an operator-configurable server fill policy, so listing the unqualified capability among things this project does not implement can mislead readers into thinking the server cannot generate negotiated payload fills at all; qualify this entry as configurable policy. AGENTS.md reference: [AGENTS.md:L60-L60](https://github.com/Lochnair/irtt-rs/blob/68e5f36a3a9815bb20610a5bdbac8964a2d81e24/AGENTS.md#L60-L60) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:21:25 +00:00 (Migrated from github.com)
Review

Fixed: qualified the entry to specifically call out the missing capability as an operator-configurable fill policy (like upstream's --fill/--allow-fills), while noting this project's server does honor negotiated fill requests with a fixed fallback pattern. See 17ef443.

🤖 Addressed by Claude Code

Fixed: qualified the entry to specifically call out the missing capability as an operator-configurable fill policy (like upstream's --fill/--allow-fills), while noting this project's server does honor negotiated fill requests with a fixed fallback pattern. See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server.
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
finite or continuous UDP latency probing, plus the server side those probes
run against.
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](https://github.com/heistp/irtt).
This is not the upstream IRTT project. For the original implementation,
protocol background, and broader documentation, see
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
mistaken for it.
## 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
* Concurrent probing of multiple targets, with staggered or burst pacing
* Reusable client library with a structured event stream (blocking, Tokio,
or managed)
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
monitoring systems, with selectable output columns
* Optional terminal UI
* Reusable Tokio-native server library
* UDP server applet with explicit bind and session policy options
* UDP server applet with sensible zero-argument defaults and 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.
## Comparison with upstream IRTT
`irtt-rs` is an independent, compatible reimplementation, not a successor or
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
remains the original implementation. Interoperability is a project goal; see
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
methodology and protocol baseline this project verifies against. Choose
between them based on the capabilities and deployment model you need.
The table below was checked against upstream release
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
This project's own interop testing and protocol baseline are currently
pinned to the prior release, v0.9.1; the wire protocol version number is
unchanged between the two, but v0.9.2 has not itself been exercised against
this project's interop harness.
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
| --- | --- | --- |
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
| Finite probing | Supported, default 10s | Supported, default 1m |
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
Upstream has capabilities this project does not implement: a `report`
subcommand for replaying saved JSON results, benchmarking and
timer/clock-diagnostic subcommands, client-side payload fill and
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
server-side fill policy (this project's server always honors a session's
negotiated fill request, with a fixed fallback pattern, but has no equivalent
to upstream's flags for restricting or overriding it), syslog logging, and
bundled systemd/OpenRC service files.
This project has capabilities upstream does not implement: multi-target
probing with labels, the terminal UI, CSV/TSV output with selectable
columns, a quantified and actively-warned memory-retention model for
long-running client sessions, and best-effort Linux kernel-timestamp
preference for its own send/receive endpoint.
## Installation
@@ -32,15 +81,10 @@ Install the latest release from crates.io:
cargo install irtt-rs --locked
```
This installs:
* `irtt-rs`, the canonical multi-applet dispatcher
* `irtt-client`, the stream and text client
* `irtt-tui`, the terminal UI
* `irtt-server`, the UDP server
Each applet can also be built in isolation with `--no-default-features` plus
the applet's own feature. For example, a lightweight client-only install:
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
isolation with `--no-default-features` plus the applet's own feature, for
example a lightweight client-only install:
```sh
cargo install irtt-rs --locked --no-default-features --features client
@@ -56,356 +100,80 @@ cargo install --path crates/irtt-app --locked
## Quick start
Probe a server using the default settings:
Probe a server:
```sh
irtt-client netperf-eu.bufferbloat.net:2112
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
```
Set the test duration and probe interval:
```sh
irtt-client netperf-eu.bufferbloat.net:2112 \
--duration 30s \
--interval 100ms
```
Run continuously until interrupted:
Probe continuously until interrupted (`Ctrl-C`):
```sh
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
```
Use `Ctrl-C` to stop gracefully.
### Continuous-mode memory
`--duration 0` uses continuous statistics. Its retained application state does
not grow without bound with elapsed run time or the number of probes: timing
metrics keep running count/min/max/mean/variance values, not a complete
timing-sample history. Consequently, exact medians are unavailable. The
default continuous statistics configuration also enables no rolling
event/history window; it keeps bounded adjacent-sequence IPDV state (4,096
sequences) so nearby replies can still form IPDV pairs.
Reply classification is bounded per target session. By default, each of the
pending, timed-out, and completed/duplicate sequence stores has its own
4,096-entry limit; this is not one shared 4,096-probe total. A full pending
store makes the managed CLI/TUI fail and drain that target as resource
exhausted; it does not wait for capacity to free. Timed-out and completed state
evict their oldest entries, so a sufficiently old late reply can still be seen
and counted but no longer has retained send state for measurements such as RTT
or applicable one-way delay.
The retained state scales with the number of configured targets and these
fixed per-target limits, rather than total probes. CLI output is written as
events arrive rather than accumulated. Its managed presentation stream is
bounded and lossy (the applets configure 16,384 events), so a slow consumer can
miss events and the CLI/TUI reports that resulting output/statistics may be
incomplete. The TUI additionally keeps up to 100,000 graph samples per target
and 80 recent messages. These are retained-application-state bounds, not a
claim of a fixed process RSS ceiling: allocator behavior, output consumers,
and operating-system resources remain outside them.
For finite runs, the tradeoff is different: exact timing samples (and
adjacent-sequence IPDV state) are retained to calculate exact medians, so
statistics memory grows with probe count.
## Multiple targets
Probe several targets concurrently:
Watch the same session live in the terminal UI:
```sh
irtt-client host-a:2112 host-b:2112
irtt-tui netperf-eu.bufferbloat.net: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:
Run a server with sensible defaults — no `--bind` needed:
```sh
irtt-client ams=ams.example.com:2112 sg=sg.example.com:2112
irtt-server
```
Labeled and unlabeled targets can be freely mixed in one argument list:
## Documentation
```sh
irtt-client host-a:2112 ams=ams.example.com:2112
```
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
formats and columns, measurement field definitions, finite/continuous
memory behavior, kernel timestamps.
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
retained history.
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
overrides, wildcard listener behavior, session policy, HMAC.
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
binary names, feature-gated builds.
* [docs.rs](https://docs.rs) for the library crates below.
* [`docs/protocol/`](docs/protocol/) and
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
protocol specification and interoperability validation against upstream
`irtt`.
Multi-target sessions use staggered pacing by default. To send each target's probes together:
Every applet also documents its full option list with `--help`.
```sh
irtt-client host-a:2112 host-b:2112 --pacing burst
```
## Crates
Target labels are included in the default multi-target output. Final per-target summaries are printed in the order targets were supplied on the command line, not the order in which they finish or their labels sort alphabetically.
| Crate | crates.io | What it is |
| --- | --- | --- |
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
## 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:
```sh
irtt-client <server> --format table
irtt-client <server> --format jsonl
irtt-client <server> --format csv \
--columns event,seq,remote,effective_rtt_us
```
For a stream containing only effective RTT values in microseconds:
```sh
irtt-client <server> \
--format tsv \
--columns effective_rtt_us \
--header never
```
List all available columns with:
```sh
irtt-client --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.
On Linux, `sd_us` and `rd_us` (and the underlying one-way delay samples) prefer a kernel-captured timestamp over userspace timing for this client's own send/receive endpoint where the platform and socket support it, reducing the effect of scheduling and queuing delay on these measurements. The peer's reported endpoint gets the same preference when it is the `irtt-server` applet running on Linux; against another IRTT-compatible server, whatever wall-clock value it reports is used as-is. This is a best-effort fallback: unsupported or implausible kernel timestamps fall back to userspace timing. `raw_rtt_us` always uses userspace timing and is unaffected; `adjusted_rtt_us`/`effective_rtt_us` can still shift slightly, since server processing time is derived from the same server receive endpoint.
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:
```sh
irtt-tui <server>
```
It runs continuously by default. A finite duration can be selected explicitly:
```sh
irtt-tui <server> --duration 30s
```
Multiple targets and pacing options work the same way as in `irtt-client`:
```sh
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:
```sh
irtt-server --bind 127.0.0.1:2112
```
The same applet is reachable through the dispatcher:
```sh
irtt-rs server --bind 192.0.2.10:2112
```
Repeat `--bind` to serve several addresses from one process, in the order given:
```sh
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:
```sh
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:
```sh
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:
```sh
irtt-server --bind 192.0.2.10:2112 --hmac secret
```
For the full option list:
```sh
irtt-server --help
```
## Library usage
`irtt-client` exposes the client session and event layer independently of CLI formatting and statistics.
Add it from crates.io:
```toml
[dependencies]
irtt-client = { version = "0.5", features = ["tokio"] }
```
For development against an unpublished change, use a path dependency instead:
```toml
[dependencies]
irtt-client = { path = "crates/irtt-client", features = ["tokio"] }
```
Minimal managed-client example for a synchronous frontend:
```rust
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.
Each library crate has its own crates.io README describing what it's for and
when to use it instead of `irtt-client`/`irtt-server` directly.
## Binaries and features
| Build | Binaries |
| -------------------------------------------- | --------------------------------------------------- |
| `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
`irtt-client` 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:
```sh
irtt-client --help
irtt-server --help
irtt-tui --help
```
| Build | Binaries |
| --- | --- |
| `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
## Development
Common verification commands:
```sh
cargo fmt --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo build -p irtt-rs --all-features --release
cargo fmt --all -- --check
cargo test --workspace --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
## 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`.
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
clean-room provenance boundary this project maintains against upstream
`irtt`.
+1 -1
View File
@@ -7,7 +7,7 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
+33
View File
@@ -0,0 +1,33 @@
# irtt-rs
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
package `irtt-rs`.
Installing this crate builds:
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
server applet by subcommand.
- `irtt-client` — the dedicated client binary (feature `client`).
- `irtt-tui` — the interactive terminal UI (feature `tui`).
- `irtt-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs`
installs everything.
## This is an application, not a library
This package's library target is undocumented (`doc = false`) and exists only
to share code between its binaries. If you're looking for the client/session
or server API to embed in your own Rust program, use
[`irtt-client`](https://crates.io/crates/irtt-client) or
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
## Usage
See the [project repository](https://github.com/Lochnair/irtt-rs) for
installation instructions, command-line usage, and configuration.
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
+59 -12
View File
@@ -1,8 +1,14 @@
use std::{net::SocketAddr, time::Duration};
use std::{
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance};
/// The ordinary IRTT port, used to build the zero-argument default binds.
const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
@@ -10,15 +16,14 @@ use irtt_server::{ServerConfig, TimestampAllowance};
#[command(
name = "irtt-server",
about = "Minimal IRTT-compatible UDP server",
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
)]
pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg(
long,
required = true,
value_name = "ADDR",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
)]
pub bind: Vec<SocketAddr>,
@@ -47,12 +52,12 @@ pub struct ServerArgs {
)]
pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval, and the allowance refill cadence.
/// Floor on the negotiated send interval.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
)]
pub min_interval: Option<Duration>,
@@ -124,6 +129,20 @@ impl From<TimestampAllowanceArg> for TimestampAllowance {
}
impl ServerArgs {
/// The addresses this invocation binds: the requested `--bind` list, or
/// the wildcard IRTT port on both address families if none was given.
#[must_use]
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
if self.bind.is_empty() {
vec![
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
]
} else {
self.bind.clone()
}
}
/// Builds the server configuration this invocation asks for.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
2
@@ -236,12 +255,7 @@ mod tests {
}
#[test]
fn bind_is_required_and_parses_both_families() {
assert!(
parse(&[]).is_err(),
"a server with no address serves nothing"
);
fn bind_is_optional_and_parses_both_families() {
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
@@ -255,6 +269,39 @@ mod tests {
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
}
#[test]
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"the empty list is the parsed representation of \"no explicit bind\""
);
assert_eq!(
args.resolve_binds(),
[
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
],
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
);
}
#[test]
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(
args.resolve_binds(),
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
"one explicit bind means exactly that one listener, not that listener plus defaults"
);
}
#[test]
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.resolve_binds(), args.bind);
}
#[test]
fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[
+119 -2
View File
@@ -1,9 +1,10 @@
use std::{
net::SocketAddr,
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use irtt_server::ServerSet;
use irtt_server::{address_family_available, ServerSet, ServerSetError};
use super::args::ServerArgs;
@@ -36,7 +37,29 @@ async fn serve(
shutdown_requested: &AtomicBool,
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Keep the surviving IPv6 listener IPv6-only

On hosts where IPv6 sockets default to dual-stack, this retry can still fail when the IPv4 default listener is unavailable. The initial two-address bind makes [::]:2112 IPv6-only, but after the IPv4 bind fails—for example because another process occupies only 0.0.0.0:2112—retrying [::]:2112 alone no longer enables IPV6_V6ONLY, so it also tries to claim the occupied IPv4 port and exits. Preserve the family isolation used by the original pair so zero-argument startup can actually continue on the available IPv6 family.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Keep the surviving IPv6 listener IPv6-only** On hosts where IPv6 sockets default to dual-stack, this retry can still fail when the IPv4 default listener is unavailable. The initial two-address bind makes `[::]:2112` IPv6-only, but after the IPv4 bind fails—for example because another process occupies only `0.0.0.0:2112`—retrying `[::]:2112` alone no longer enables `IPV6_V6ONLY`, so it also tries to claim the occupied IPv4 port and exits. Preserve the family isolation used by the original pair so zero-argument startup can actually continue on the available IPv6 family. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:20:32 +00:00 (Migrated from github.com)
Review

Fixed: added ServerSet::bind_with_family_context, which decides IPV6_V6ONLY against a caller-supplied context instead of the addresses actually being bound (bind() is now this method using the addresses as their own context, so its existing behavior is unchanged). The CLI's fallback now passes the original two-address default as context, so a surviving lone IPv6 listener stays IPV6_V6ONLY instead of reverting to dual-stack. Added a test that reproduces the dual-stack collision without the context and confirms it's avoided with it. See 17ef443.

🤖 Addressed by Claude Code

Fixed: added `ServerSet::bind_with_family_context`, which decides IPV6_V6ONLY against a caller-supplied context instead of the addresses actually being bound (`bind()` is now this method using the addresses as their own context, so its existing behavior is unchanged). The CLI's fallback now passes the original two-address default as context, so a surviving lone IPv6 listener stays IPV6_V6ONLY instead of reverting to dual-stack. Added a test that reproduces the dual-stack collision without the context and confirms it's avoided with it. See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
Lochnair commented 2026-08-20 14:00:37 +00:00 (Migrated from github.com)
Review

This fallback is currently too broad: any ListenerSetup failure for one of the implicit defaults causes a retry with the other family. Please only fall back when the underlying bind error specifically means that address family is unavailable/unsupported on this host. AddrInUse, permission errors, wildcard source-selection/setup failures, and unknown failures should abort startup. If fallback is restricted that way, the bind_with_family_context() retry path should no longer be needed, since we should never retry merely because the sibling family's port is occupied.

This fallback is currently too broad: any `ListenerSetup` failure for one of the implicit defaults causes a retry with the other family. Please only fall back when the underlying bind error specifically means that address family is unavailable/unsupported on this host. `AddrInUse`, permission errors, wildcard source-selection/setup failures, and unknown failures should abort startup. If fallback is restricted that way, the `bind_with_family_context()` retry path should no longer be needed, since we should never retry merely because the sibling family's port is occupied.
) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config();
let set = ServerSet::bind(args.bind, config).await?;
let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
Ok(set) => set,
Err(ServerSetError::ListenerSetup { addr, source }) => {
let family_available = address_family_available(addr);
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
Some(remaining) => {
eprintln!(
"irtt-server: default listener {addr} unavailable ({source}); \
continuing with the remaining default listener"
);
// `remaining`'s lone survivor is bound exactly as it
// would be alone: the address family that just failed
// has no local support at all, so nothing on that family
// could be competing for the surviving listener's port.
ServerSet::bind(remaining, config).await?
}
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
}
}
Err(err) => return Err(err.into()),
};
// Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started.
for addr in set.local_addrs() {
@@ -46,6 +69,37 @@ async fn serve(
Ok(())
}
/// Whether a listener-setup failure for `failed_addr` should be retried with
/// a reduced bind set instead of failing the whole invocation, and if so,
/// what to retry with.
///
/// Only the implicit zero-argument default gets this treatment, and only
/// when `failed_addr`'s own address family has no local support on this host
/// at all (`family_available` is `false`) — not merely because its specific
/// port was unavailable for some other reason: already in use, permission
/// denied, no safe wildcard reply-source path, or anything else
/// `ServerSet::bind` can fail with. Those are real configuration or
/// environment problems this invocation should fail on, not silently work
/// around by dropping a listener the operator asked for. An explicit
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
/// regardless.
fn implicit_default_fallback(
is_implicit_default: bool,
family_available: bool,
original_binds: &[SocketAddr],
failed_addr: SocketAddr,
) -> Option<Vec<SocketAddr>> {
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
return None;
}
let remaining: Vec<SocketAddr> = original_binds
.iter()
.copied()
.filter(|&addr| addr != failed_addr)
.collect();
(!remaining.is_empty()).then_some(remaining)
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
@@ -82,4 +136,67 @@ mod tests {
run_server(args, &shutdown_requested).unwrap();
}
fn default_binds() -> Vec<SocketAddr> {
ServerArgs::try_parse_from(["irtt-server"])
.unwrap()
.resolve_binds()
}
#[test]
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
let binds = default_binds();
let failed = binds[0];
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
assert_eq!(remaining, [binds[1]]);
}
#[test]
fn a_supported_familys_failure_never_falls_back() {
// The family itself works fine here; whatever made this specific
// bind fail (port in use, permission, ...) is a real problem this
// invocation should surface, not paper over.
let binds = default_binds();
assert_eq!(
implicit_default_fallback(true, true, &binds, binds[0]),
None,
"family_available: true means this is not a family-support problem"
);
}
#[test]
fn explicit_binds_never_fall_back() {
let binds = default_binds();
assert_eq!(
implicit_default_fallback(false, false, &binds, binds[0]),
None
);
}
#[test]
fn a_failure_outside_the_requested_set_does_not_fall_back() {
let binds = default_binds();
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
assert_eq!(
implicit_default_fallback(true, false, &binds, unrelated),
None,
"the failing address must actually be one this invocation asked for"
);
}
#[test]
fn losing_the_only_remaining_default_address_does_not_fall_back() {
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
assert_eq!(
implicit_default_fallback(true, false, &single, single[0]),
None,
"no address would be left to retry with"
);
}
}
+16 -1
View File
@@ -7,7 +7,7 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["network-programming"]
@@ -27,6 +27,21 @@ tokio = ["dep:tokio"]
[package.metadata.docs.rs]
all-features = true
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
[[example]]
name = "blocking_client"
doc-scrape-examples = true
[[example]]
name = "tokio_client"
required-features = ["tokio"]
doc-scrape-examples = true
chatgpt-codex-connector[bot] commented 2026-08-20 11:31:16 +00:00 (Migrated from github.com)
Review

P2 Badge Opt the client examples into rustdoc scraping

This docs.rs command does not actually scrape any of the three client examples because the package has dev-dependencies and none of its [[example]] targets explicitly opts in. Running the configured cargo doc -Zunstable-options -Zrustdoc-scrape-examples reports: “Rustdoc did not scrape the following examples because they require dev-dependencies” and instructs adding doc-scrape-examples = true; consequently the published API documentation will omit the example usage this metadata is intended to add.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Opt the client examples into rustdoc scraping** This docs.rs command does not actually scrape any of the three client examples because the package has dev-dependencies and none of its `[[example]]` targets explicitly opts in. Running the configured `cargo doc -Zunstable-options -Zrustdoc-scrape-examples` reports: “Rustdoc did not scrape the following examples because they require dev-dependencies” and instructs adding `doc-scrape-examples = true`; consequently the published API documentation will omit the example usage this metadata is intended to add. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:46:21 +00:00 (Migrated from github.com)
Review

Fixed: added doc-scrape-examples = true to all three irtt-client example targets. Confirmed with cargo +nightly doc -p irtt-client --all-features --no-deps -Zunstable-options -Zrustdoc-scrape-examples — the "did not scrape" warning is gone. irtt-server needed no change since it has no dev-dependencies and wasn't affected. See 68e5f36.

🤖 Addressed by Claude Code

Fixed: added `doc-scrape-examples = true` to all three irtt-client example targets. Confirmed with `cargo +nightly doc -p irtt-client --all-features --no-deps -Zunstable-options -Zrustdoc-scrape-examples` — the "did not scrape" warning is gone. irtt-server needed no change since it has no dev-dependencies and wasn't affected. See 68e5f36. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
[[example]]
name = "managed_client"
required-features = ["tokio"]
doc-scrape-examples = true
[dev-dependencies]
irtt-server.workspace = true
+37
View File
@@ -0,0 +1,37 @@
# irtt-client
Reusable client/session library for IRTT-compatible round-trip-time probing:
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
classification, and a typed event stream.
## Looking for the CLI?
If you just want to run probes from the command line, use the
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
which installs the `irtt-client` binary. This crate is the library the
binaries are built on.
## API tiers
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
default build.
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
own a runtime and drive readiness directly.
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
own dedicated current-thread runtime.
Tokio stays optional: the default build has no runtime and no Tokio
dependency at all.
See `examples/` in the repository for runnable examples of each tier.
## Documentation
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,110 @@
//! Runtime-free blocking client example.
chatgpt-codex-connector[bot] commented 2026-08-20 10:20:14 +00:00 (Migrated from github.com)
Review

P2 Badge Wait for the blocking client's send deadline

Client::send_probe is caller-paced, but this loop calls it again immediately after each receive instead of waiting for next_send_deadline(). With a reachable low-latency server, recv_once() returns immediately and the example sends probes at roughly RTT speed rather than the configured 200 ms interval, producing invalid pacing measurements and potentially exhausting the server's burst allowance.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Wait for the blocking client's send deadline** `Client::send_probe` is caller-paced, but this loop calls it again immediately after each receive instead of waiting for `next_send_deadline()`. With a reachable low-latency server, `recv_once()` returns immediately and the example sends probes at roughly RTT speed rather than the configured 200 ms interval, producing invalid pacing measurements and potentially exhausting the server's burst allowance. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 10:56:59 +00:00 (Migrated from github.com)
Review

Fixed: the loop now checks next_send_deadline() before calling send_probe(), so probes are paced at the configured interval instead of as fast as the loop spins. See a981487.

🤖 Addressed by Claude Code

Fixed: the loop now checks `next_send_deadline()` before calling `send_probe()`, so probes are paced at the configured interval instead of as fast as the loop spins. See a981487. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 11:17:50 +00:00 (Migrated from github.com)
Review

P2 Badge Wait long enough to classify lost probes

Even with send pacing corrected, this hard deadline expires after three seconds while the example retains the default four-second probe_timeout. Therefore, if any probe reply is lost, not even the first probe can reach its timeout before the loop exits; close() then transitions the session to closed and discards the pending probes without emitting the EchoLoss events this example is set up to print. Base the safety deadline on the run duration plus client.probe_timeout(), or otherwise keep polling until pending probes are classified.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Wait long enough to classify lost probes** Even with send pacing corrected, this hard deadline expires after three seconds while the example retains the default four-second `probe_timeout`. Therefore, if any probe reply is lost, not even the first probe can reach its timeout before the loop exits; `close()` then transitions the session to closed and discards the pending probes without emitting the `EchoLoss` events this example is set up to print. Base the safety deadline on the run duration plus `client.probe_timeout()`, or otherwise keep polling until pending probes are classified. AGENTS.md reference: [AGENTS.md:L78-L78](https://github.com/Lochnair/irtt-rs/blob/17422e0dc3900cbc300bdd79d9e6bc66af3d9ed0/AGENTS.md#L78-L78) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:22:55 +00:00 (Migrated from github.com)
Review

Fixed: the safety deadline is now RUN_DURATION + client.probe_timeout() instead of a fixed 3s guess, so a lost reply near the end of the run can actually reach its timeout and be classified/printed as EchoLoss before the loop gives up and closes. See 86468f0.

🤖 Addressed by Claude Code

Fixed: the safety deadline is now `RUN_DURATION + client.probe_timeout()` instead of a fixed 3s guess, so a lost reply near the end of the run can actually reach its timeout and be classified/printed as EchoLoss before the loop gives up and closes. See 86468f0. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 11:31:16 +00:00 (Migrated from github.com)
Review

P2 Badge Propagate blocking probe-send failures

When the UDP send starts failing after Open, for example after the interface goes down, unwrap_or_default() converts the ClientError into an empty event list. A failed send retains the scheduled probe without advancing the schedule, so this loop silently retries until its safety deadline and then returns from main successfully, presenting an incomplete run with no diagnostic. Handle the error and terminate/report it as the Tokio example does.

AGENTS.md reference: crates/irtt-client/AGENTS.md:L83-L87

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Propagate blocking probe-send failures** When the UDP send starts failing after Open, for example after the interface goes down, `unwrap_or_default()` converts the `ClientError` into an empty event list. A failed send retains the scheduled probe without advancing the schedule, so this loop silently retries until its safety deadline and then returns from `main` successfully, presenting an incomplete run with no diagnostic. Handle the error and terminate/report it as the Tokio example does. AGENTS.md reference: [crates/irtt-client/AGENTS.md:L83-L87](https://github.com/Lochnair/irtt-rs/blob/86468f0ce904844c2eb4f6d852eb47c35dd0fccf/crates/irtt-client/AGENTS.md#L83-L87) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:46:19 +00:00 (Migrated from github.com)
Review

Fixed: send/receive/timeout-poll errors are now matched explicitly and surfaced via eprintln + break instead of unwrap_or_default(), matching the tokio_client fix from the previous round. See 68e5f36.

🤖 Addressed by Claude Code

Fixed: send/receive/timeout-poll errors are now matched explicitly and surfaced via eprintln + break instead of unwrap_or_default(), matching the tokio_client fix from the previous round. See 68e5f36. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 15:10:04 +00:00 (Migrated from github.com)
Review

P2 Badge Report close failures in the client examples

If the socket fails while sending the final close—for example, the interface goes down after the last successful probe—Client::close() returns an error and deliberately leaves the session open for retry, but this if let Ok silently discards it. The example then exits without a diagnostic while the remote session remains allocated until idle expiry; the Tokio example has the same pattern, so both should surface their final cleanup failure just as they now surface send, receive, and timeout-poll failures.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Report close failures in the client examples** If the socket fails while sending the final close—for example, the interface goes down after the last successful probe—`Client::close()` returns an error and deliberately leaves the session open for retry, but this `if let Ok` silently discards it. The example then exits without a diagnostic while the remote session remains allocated until idle expiry; the Tokio example has the same pattern, so both should surface their final cleanup failure just as they now surface send, receive, and timeout-poll failures. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 15:20:01 +00:00 (Migrated from github.com)
Review

Fixed: both examples now match on client.close()'s result and eprintln the error instead of silently discarding it via if-let-Ok, matching how send/receive/timeout-poll failures are already surfaced. Folded into the relevant commit as part of this PR's squash/rebase cleanup.

🤖 Addressed by Claude Code

Fixed: both examples now match on client.close()'s result and eprintln the error instead of silently discarding it via if-let-Ok, matching how send/receive/timeout-poll failures are already surfaced. Folded into the relevant commit as part of this PR's squash/rebase cleanup. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
//!
//! Opens a session against a local IRTT-compatible server, sends a few
//! probes, and prints the events observed. Uses only [`Client`], which has no
//! Tokio dependency and works in a default (`--no-default-features`) build.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example blocking_client
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! fast demonstration; a real caller would normally keep the default retry
//! schedule.
use std::time::{Duration, Instant};
use irtt_client::{Client, ClientConfig, ClientEvent};
const RUN_DURATION: Duration = Duration::from_secs(2);
fn main() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(RUN_DURATION),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(100)),
..Default::default()
},
..Default::default()
};
let mut client = match Client::connect(config) {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
match client.open() {
Ok(outcome) => println!("session opened: {outcome:?}"),
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
// The run itself lasts `duration`, but a lost probe isn't classified as
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
// that much past the run's own end so a loss near the end can still be
// observed and printed before this example gives up and closes.
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
while Instant::now() < deadline && !client.is_run_complete() {
// send_probe is caller-paced: only call it once its own schedule
// says a probe is due, rather than as fast as the loop spins.
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry silently until the
// safety deadline instead of reporting it.
match client.send_probe() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("send failed: {err}");
break;
}
}
}
match client.recv_once() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
match client.close() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
@@ -0,0 +1,73 @@
//! Managed multi-target client example (`tokio` feature).
//!
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
//! that does not want to touch Tokio directly can still get managed
//! multi-target orchestration, retries, and a lossy event stream.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example managed_client --features tokio
//! ```
//!
//! This example does not wait for the managed run to finish naturally: it
//! requests a stop after a fixed short window so it exits promptly even
//! without a reachable server.
use std::time::Duration;
use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
ManagedTargetConfig,
};
use irtt_client::ClientConfig;
fn main() {
let config = ManagedClientConfig {
client: ClientConfig {
duration: None,
interval: Duration::from_millis(200),
open_timeouts: vec![Duration::from_millis(300)],
..Default::default()
},
completion: ManagedCompletionPolicy::ExplicitStop,
..Default::default()
};
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
{
Ok(started) => started,
Err(err) => {
eprintln!("failed to start managed client: {err}");
return;
}
};
let stop_after = Duration::from_secs(1);
let deadline = std::time::Instant::now() + stop_after;
while std::time::Instant::now() < deadline {
match events.try_recv() {
Ok(event) => print_event(&event),
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
// `stop()` requests the stop synchronously; the returned receipt is only
// useful for awaiting durable acknowledgement, which this blocking
// example does not need.
std::mem::drop(owner.handle().stop());
match owner.join() {
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
}
}
fn print_event(event: &ManagedEvent) {
match event {
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
other => println!("{other:?}"),
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Low-level Tokio client example (`tokio` feature).
chatgpt-codex-connector[bot] commented 2026-08-20 10:20:15 +00:00 (Migrated from github.com)
Review

P2 Badge Bound the asynchronous receive wait

When any echo reply is lost or rate-limited, recv().await remains pending indefinitely, so the loop never reaches poll_timeouts(), rechecks the two-second run completion, or closes the session. This is readily triggered because the preceding caller-paced sends also run without waiting for next_send_deadline() and exhaust the default server burst allowance; the documented runnable example then hangs rather than finishing. Drive the receive together with the next send/timeout deadline instead of awaiting it unconditionally.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Bound the asynchronous receive wait** When any echo reply is lost or rate-limited, `recv().await` remains pending indefinitely, so the loop never reaches `poll_timeouts()`, rechecks the two-second run completion, or closes the session. This is readily triggered because the preceding caller-paced sends also run without waiting for `next_send_deadline()` and exhaust the default server burst allowance; the documented runnable example then hangs rather than finishing. Drive the receive together with the next send/timeout deadline instead of awaiting it unconditionally. AGENTS.md reference: [AGENTS.md:L78-L78](https://github.com/Lochnair/irtt-rs/blob/a4dc9331848f9fd7c80b8ca128378b2954b8ac2d/AGENTS.md#L78-L78) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 10:57:19 +00:00 (Migrated from github.com)
Review

Fixed: the receive wait is now bounded with tokio::select! against a sleep to the next send/timeout deadline instead of awaiting recv() unconditionally, and send_probe() is only called once its deadline is due. See a981487.

🤖 Addressed by Claude Code

Fixed: the receive wait is now bounded with `tokio::select!` against a sleep to the next send/timeout deadline instead of awaiting `recv()` unconditionally, and `send_probe()` is only called once its deadline is due. See a981487. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 11:06:45 +00:00 (Migrated from github.com)
Review

P2 Badge Poll at the oldest probe's timeout

The new Instant::now() + client.probe_timeout() fallback does not represent the next pending probe timeout. Once the send schedule finishes, if an early probe was lost but later probes replied, recv() and this sleep can both remain pending until a full timeout from the current instant, so poll_timeouts() classifies the loss and completes the run substantially late (up to about two extra seconds with this example's configuration). Wake at the earliest pending timeout, or poll on a short bounded cadence if that deadline is not publicly exposed.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Poll at the oldest probe's timeout** The new `Instant::now() + client.probe_timeout()` fallback does not represent the next pending probe timeout. Once the send schedule finishes, if an early probe was lost but later probes replied, `recv()` and this sleep can both remain pending until a full timeout from the current instant, so `poll_timeouts()` classifies the loss and completes the run substantially late (up to about two extra seconds with this example's configuration). Wake at the earliest pending timeout, or poll on a short bounded cadence if that deadline is not publicly exposed. AGENTS.md reference: [AGENTS.md:L78-L78](https://github.com/Lochnair/irtt-rs/blob/a981487ed097a78c9a039e5f5aa4b31deb038f63/AGENTS.md#L78-L78) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:10:03 +00:00 (Migrated from github.com)
Review

Fixed: replaced the Instant::now() + probe_timeout() fallback with a fixed 50ms poll cadence, since AsyncClient doesn't expose the earliest pending probe's own deadline. This bounds how late a lost probe can be classified to the poll interval instead of a full probe_timeout() from whenever the loop happened to run. See 17422e0.

🤖 Addressed by Claude Code

Fixed: replaced the `Instant::now() + probe_timeout()` fallback with a fixed 50ms poll cadence, since `AsyncClient` doesn't expose the earliest pending probe's own deadline. This bounds how late a lost probe can be classified to the poll interval instead of a full `probe_timeout()` from whenever the loop happened to run. See 17422e0. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 11:17:50 +00:00 (Migrated from github.com)
Review

P2 Badge Stop retrying after a probe send error

Separately from the now-bounded receive branch, a persistent send_probe() error (for example, the interface going down after Open) is discarded here. Failed sends intentionally retain the prepared probe without advancing the schedule, so next_send_deadline() remains overdue and is_run_complete() remains false; this loop consequently retries every 50 ms forever instead of reporting the error or exiting. Surface the error and terminate the example rather than converting it to an empty event list.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Stop retrying after a probe send error** Separately from the now-bounded receive branch, a persistent `send_probe()` error (for example, the interface going down after Open) is discarded here. Failed sends intentionally retain the prepared probe without advancing the schedule, so `next_send_deadline()` remains overdue and `is_run_complete()` remains false; this loop consequently retries every 50 ms forever instead of reporting the error or exiting. Surface the error and terminate the example rather than converting it to an empty event list. AGENTS.md reference: [AGENTS.md:L78-L78](https://github.com/Lochnair/irtt-rs/blob/17422e0dc3900cbc300bdd79d9e6bc66af3d9ed0/AGENTS.md#L78-L78) Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:22:53 +00:00 (Migrated from github.com)
Review

Fixed: send/receive/timeout-poll errors are now matched explicitly and surfaced via eprintln + break instead of being discarded by unwrap_or_default(), so a persistent send failure reports an error and exits rather than retrying every 50ms forever. See 86468f0.

🤖 Addressed by Claude Code

Fixed: send/receive/timeout-poll errors are now matched explicitly and surfaced via eprintln + break instead of being discarded by unwrap_or_default(), so a persistent send failure reports an error and exits rather than retrying every 50ms forever. See 86468f0. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
//!
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
//! adapter to reach for when you already have a Tokio runtime and want to
//! await sends/receives yourself, rather than use the higher-level `managed`
//! driver (see the `managed_client` example).
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example tokio_client --features tokio
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! fast demonstration.
use std::time::{Duration, Instant};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
/// Upper bound on how long the receive wait can go without also calling
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
/// probe's own timeout deadline, so this example polls on a short fixed
/// cadence instead of waiting for the full `probe_timeout()` from whenever
/// this loop last ran — that would let poll_timeouts() classify a lost
/// probe (and its dependent statistics/session state) far later than it
/// actually became lost.
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.expect("failed to build Tokio runtime");
runtime.block_on(run());
}
async fn run() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(Duration::from_secs(2)),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
..Default::default()
};
let mut client = match AsyncClient::connect(config).await {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
match client.open().await {
Ok(outcome) => println!("session opened: {outcome:?}"),
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
while !client.is_run_complete() {
// Bound the receive wait by the next send/timeout deadline, rather
// than awaiting recv() unconditionally: a lost or rate-limited reply
// must not stall pacing or timeout classification indefinitely.
let wake_at = client
.next_send_deadline()
.into_iter()
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
.min()
.expect("the fixed poll-interval deadline is always present");
let recv_result = tokio::select! {
events = client.recv() => Some(events),
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
};
if let Some(result) = recv_result {
match result {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
}
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry forever instead of
// reporting it.
match client.send_probe().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("send failed: {err}");
break;
}
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
match client.close().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
+13 -4
View File
@@ -13,10 +13,19 @@
//! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew.
//!
//! [`Client`] is the runtime-free low-level blocking adapter. With the
//! `tokio` feature, `AsyncClient` provides the corresponding Tokio adapter;
//! [`managed`] contains the unified managed task/handle implementation,
//! including the dedicated-runtime blocking frontend for synchronous callers.
//! [`Client`] is the runtime-free low-level blocking adapter and requires no
//! Tokio dependency at all; it is what a caller without a runtime, or that
//! wants full control over its own event loop, should start from. With the
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
//! adapter for a caller that owns a runtime and drives socket readiness
//! itself. [`managed`] sits above both: `ManagedClientTask` /
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
//! feature) wraps that in a synchronous owner backed by its own dedicated
//! current-thread runtime for callers that want managed multi-target
//! behavior without touching Tokio themselves.
//!
//! See `examples/` in the repository for a runnable example of each tier.
//!
#![forbid(unsafe_code)]
+1 -1
View File
@@ -7,7 +7,7 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "protocol"]
categories = ["network-programming"]
+30
View File
@@ -0,0 +1,30 @@
# irtt-proto
Low-level wire encoding and decoding for the IRTT-compatible protocol: request
and reply structs, flags, negotiable parameters, varint/zigzag primitives, and
HMAC-MD5 packet authentication. No sockets, no runtime, no session state.
## Who should use this
Most applications should not depend on this crate directly. Use
[`irtt-client`](https://docs.rs/irtt-client) to run client sessions or
[`irtt-server`](https://docs.rs/irtt-server) to run a server; both build on
`irtt-proto` and provide session lifecycle, negotiation, and validated
configuration on top of it.
Reach for `irtt-proto` directly only if you are implementing another
IRTT-compatible client or server from scratch and need the raw packet layer.
Field values mirror the wire format closely rather than expressing policy:
`Params::decode` rejects malformed input, but direct construction plus
`Params::encode` performs no additional validation — callers are responsible
for validating values before sending them.
## Documentation
Full API documentation is on [docs.rs/irtt-proto](https://docs.rs/irtt-proto).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
+4
View File
@@ -6,6 +6,10 @@
//! crates such as `irtt-client` are responsible for session behavior and
//! user-facing validation.
//!
//! Most applications should depend on `irtt-client` or `irtt-server` instead
//! of this crate directly; use `irtt-proto` only when implementing another
//! IRTT-compatible client or server that needs the raw packet layer itself.
//!
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
//! `server_fill` values. Direct construction followed by [`Params::encode`]
+11 -1
View File
@@ -7,7 +7,7 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "server"]
categories = ["network-programming"]
@@ -23,3 +23,13 @@ tokio = { version = "1.53.1", default-features = false, features = ["rt", "net",
# source-selection path here; elsewhere a wildcard bind is refused.
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))'.dependencies]
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"] }
[package.metadata.docs.rs]
all-features = true
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
[[example]]
name = "basic_server"
[[example]]
name = "multi_listener"
+41
View File
@@ -0,0 +1,41 @@
# irtt-server
Reusable, Tokio-native library for running an IRTT-compatible UDP server:
open negotiation, the bounded session table, echo processing, per-session
rate limiting, idle and max-duration expiry, and reply construction.
## Looking for the CLI?
If you just want to run a server from the command line, use the
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
which installs the `irtt-server` binary. This crate is the library that
binary is built on.
## Structure
- `Server` — the one-listener primitive: one UDP socket, one deterministic
`ServerCore`, one sequential receive/reply loop.
- `ServerSet` — supervises one or more `Server`s as a single service: binds
them all or none, runs each in its own task, and fans one shutdown out to
all of them.
Unlike `irtt-client`, Tokio is not optional here — this crate is intentionally
Tokio-native, with no blocking or alternate-runtime variant.
A wildcard listener (`0.0.0.0` / `[::]`) recovers each request's local
destination and replies from it, so a client on a second local address is
answered from the address it actually contacted. That path is implemented
for Linux, macOS, and FreeBSD; a wildcard bind is refused at construction on
other targets rather than served incorrectly. Explicit-address listeners are
unaffected everywhere.
See `examples/` in the repository for runnable examples.
## Documentation
Full API documentation is on [docs.rs/irtt-server](https://docs.rs/irtt-server).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,42 @@
//! Basic single-listener server example.
//!
//! Binds one [`Server`] on an ephemeral loopback port, serves it for a short
//! fixed window, and shuts down. A real deployment would normally bind a
//! fixed port and run until an external shutdown signal.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-server --example basic_server
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use irtt_server::{Server, ServerConfig};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let config = ServerConfig::default();
let mut server = match Server::bind(bind_addr, config).await {
Ok(server) => server,
Err(err) => {
eprintln!("failed to bind server: {err}");
return;
}
};
println!(
"listening on {}",
server
.local_addr()
.expect("bound socket has a local address")
);
let result = server.run(tokio::time::sleep(Duration::from_secs(2))).await;
if let Err(err) = result {
eprintln!("server stopped with an error: {err}");
}
}
@@ -0,0 +1,43 @@
//! Multi-listener example using [`ServerSet`].
//!
//! `ServerSet` binds several independent [`Server`] listeners as one service:
//! all-or-nothing bind, one shutdown fanned out to every listener, and the
//! whole set fails if any listener does. Each listener still gets its own
//! socket, session table, and tokens — a token issued by one is unknown at
//! the other. This is what the `irtt-rs` server CLI always runs through, even
//! for a single bind.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-server --example multi_listener
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use irtt_server::{ServerConfig, ServerSet};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let addrs = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
];
let config = ServerConfig::default();
let set = match ServerSet::bind(addrs, config).await {
Ok(set) => set,
Err(err) => {
eprintln!("failed to bind listener set: {err}");
return;
}
};
println!("listening on {:?}", set.local_addrs());
let result = set.run(tokio::time::sleep(Duration::from_secs(2))).await;
if let Err(err) = result {
eprintln!("listener set stopped with an error: {err}");
}
}
+4 -1
View File
@@ -32,6 +32,9 @@
//! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not
//! per process.
//!
//! See `examples/` in the repository for a runnable single-listener and
//! multi-listener example.
//!
//! # Rejection is silence
//!
//! The protocol defines no error reply, no reset and no NACK. Malformed,
@@ -129,4 +132,4 @@ pub use config::{
pub use core::{OutboundDatagram, ServerCore};
pub use error::ServerError;
pub use runtime::{Server, ServerRuntimeError};
pub use set::{ServerSet, ServerSetError};
pub use set::{address_family_available, ServerSet, ServerSetError};
+34
View File
@@ -218,6 +218,40 @@ impl ServerSet {
}
chatgpt-codex-connector[bot] commented 2026-08-20 15:10:04 +00:00 (Migrated from github.com)
Review

P2 Badge Detect families that cannot bind, not only missing socket domains

On Linux systems where IPv6 is disabled through interface/sysctl configuration, creating an AF_INET6 datagram socket can still succeed while binding [::]:2112 fails with EADDRNOTAVAIL. This probe therefore reports the family as available, so implicit_default_fallback rejects the fallback and the zero-argument server exits instead of serving the usable IPv4 default. Fresh evidence relative to the earlier IPv4-only-host finding is that the replacement helper tests only Socket::new and never the wildcard bind capability that actually failed.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Detect families that cannot bind, not only missing socket domains** On Linux systems where IPv6 is disabled through interface/sysctl configuration, creating an `AF_INET6` datagram socket can still succeed while binding `[::]:2112` fails with `EADDRNOTAVAIL`. This probe therefore reports the family as available, so `implicit_default_fallback` rejects the fallback and the zero-argument server exits instead of serving the usable IPv4 default. Fresh evidence relative to the earlier IPv4-only-host finding is that the replacement helper tests only `Socket::new` and never the wildcard bind capability that actually failed. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 15:20:00 +00:00 (Migrated from github.com)
Review

Fixed: address_family_available now actually binds the wildcard address of the family on an ephemeral port instead of only constructing a bare socket, so administratively-disabled-per-interface IPv6 (constructible AF_INET6 socket, every bind EADDRNOTAVAIL) is now correctly detected as unavailable. Folded into the relevant commit as part of this PR's squash/rebase cleanup.

🤖 Addressed by Claude Code

Fixed: address_family_available now actually binds the wildcard address of the family on an ephemeral port instead of only constructing a bare socket, so administratively-disabled-per-interface IPv6 (constructible AF_INET6 socket, every bind EADDRNOTAVAIL) is now correctly detected as unavailable. Folded into the relevant commit as part of this PR's squash/rebase cleanup. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
}
/// Whether `addr`'s address family has any local support on this host at
/// all, independent of whether this particular address or port can be
/// bound.
///
/// A [`ServerSetError::ListenerSetup`] failure can mean many different
/// things — the port is already in use, permission is denied, this target
/// has no safe wildcard reply-source path (see [`ServerRuntimeError`]) — and
/// only one of them, the address family itself having no local support at
/// all (for example IPv6 disabled on the host), is safe for a caller to work
/// around by silently trying a different address instead. This checks only
/// that, by actually binding the wildcard address of `addr`'s family on an
/// ephemeral port: a bare socket of an unsupported family can still
/// construct successfully on some systems (Linux administratively disables
/// IPv6 per-interface, so `AF_INET6` sockets keep working while every bind
/// fails with `EADDRNOTAVAIL`), so construction alone is not a reliable
/// signal — only a real bind attempt is.
#[must_use]
pub fn address_family_available(addr: SocketAddr) -> bool {
let wildcard = if addr.is_ipv6() {
SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0))
} else {
SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0))
};
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let Ok(socket) = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) else {
return false;
};
socket.bind(&SockAddr::from(wildcard)).is_ok()
}
/// Binds one listener, pre-configuring the socket where the bind itself depends
/// on it.
///
4
+4 -1
View File
@@ -7,12 +7,15 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"]
[dependencies]
irtt-client.workspace = true
[[example]]
name = "finite_vs_continuous"
[dev-dependencies]
proptest = "1.11.0"
+38
View File
@@ -0,0 +1,38 @@
# irtt-stats
chatgpt-codex-connector[bot] commented 2026-08-20 12:26:39 +00:00 (Migrated from github.com)
Review

P2 Badge Limit the finite-mode median promise to cumulative snapshots

When StatsConfig::finite() is combined with rolling_count or rolling_time, the rolling snapshots still report no medians because rolling::snapshot deliberately builds its CoreStats with SampleMode::RunningOnly. Since the README introduces both cumulative and rolling snapshots, saying every metric gets an exact median gives rolling-window users the wrong API contract; qualify this as applying to cumulative metrics with samples and note that rolling metrics remain running-only.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Limit the finite-mode median promise to cumulative snapshots** When `StatsConfig::finite()` is combined with `rolling_count` or `rolling_time`, the rolling snapshots still report no medians because `rolling::snapshot` deliberately builds its `CoreStats` with `SampleMode::RunningOnly`. Since the README introduces both cumulative and rolling snapshots, saying every metric gets an exact median gives rolling-window users the wrong API contract; qualify this as applying to cumulative metrics with samples and note that rolling metrics remain running-only. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:35:09 +00:00 (Migrated from github.com)
Review

Fixed: scoped the exact-median claim to cumulative snapshots and noted rolling-window snapshots are always running-only (SampleMode::RunningOnly in rolling::snapshot_window), regardless of which StatsConfig produced them. See 428e46d.

🤖 Addressed by Claude Code

Fixed: scoped the exact-median claim to cumulative snapshots and noted rolling-window snapshots are always running-only (SampleMode::RunningOnly in rolling::snapshot_window), regardless of which StatsConfig produced them. See 428e46d. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
Statistics aggregation over [`irtt-client`](https://crates.io/crates/irtt-client)
events: loss/duplicate/late accounting, RTT and one-way-delay timing, and
inter-packet delay variation (IPDV), as cumulative and rolling snapshots.
## Relationship to irtt-client
This crate does not open sessions or drive sockets. Feed it the `ClientEvent`
stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed
session via `StatsCollector::process`, and read back a `Snapshot`.
chatgpt-codex-connector[bot] commented 2026-08-20 10:20:15 +00:00 (Migrated from github.com)
Review

P2 Badge Require one statistics collector per managed target

A managed session may contain multiple targets, but this instruction reads as though their ClientEvent values can all be fed into one StatsCollector. The collector has one packet-count and sequence/IPDV namespace, while each target starts its sequence numbers at zero; combining a managed event stream therefore merges unrelated measurements and pairs probes from different targets, corrupting the resulting snapshot. Document that callers must route each ManagedEvent::Client to a separate collector keyed by its target, as the CLI does.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Require one statistics collector per managed target** A managed session may contain multiple targets, but this instruction reads as though their `ClientEvent` values can all be fed into one `StatsCollector`. The collector has one packet-count and sequence/IPDV namespace, while each target starts its sequence numbers at zero; combining a managed event stream therefore merges unrelated measurements and pairs probes from different targets, corrupting the resulting snapshot. Document that callers must route each `ManagedEvent::Client` to a separate collector keyed by its target, as the CLI does. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 10:57:21 +00:00 (Migrated from github.com)
Review

Fixed: added a paragraph clarifying that StatsCollector is scoped to a single target and a multi-target managed session needs one collector per target, since feeding multiple targets into one collector would pair up unrelated probes. See a981487.

🤖 Addressed by Claude Code

Fixed: added a paragraph clarifying that `StatsCollector` is scoped to a single target and a multi-target managed session needs one collector per target, since feeding multiple targets into one collector would pair up unrelated probes. See a981487. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
scoped to a single target. A managed session covering multiple targets needs
one collector per target — as the `irtt-rs` CLI does — since a target's
sequence numbers start over at zero and feeding two targets into one
collector would pair up unrelated probes.
## Retention modes
`StatsConfig::finite()` retains exact timing samples for its cumulative
snapshot, so every metric with samples reports an exact median there —
retention grows with the probe count. `StatsConfig::continuous()` keeps
bounded running statistics instead (no exact median) plus a bounded
4096-entry adjacent-sequence IPDV store per target, for long-running or
unbounded sessions. Rolling-window snapshots are always running-only and
report no medians, whichever `StatsConfig` produced them.
See `examples/` in the repository for a runnable comparison of both modes.
## Documentation
Full API documentation is on [docs.rs/irtt-stats](https://docs.rs/irtt-stats).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,102 @@
//! Contrasts finite and continuous retention modes.
//!
//! Feeds the same synthetic stream of `irtt-client` events into two
//! collectors — one configured with [`StatsConfig::finite`], one with
//! [`StatsConfig::continuous`] — and prints how their cumulative snapshots
//! differ. This uses synthetic events rather than a live session so the
//! example runs deterministically with no network or server involved.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-stats --example finite_vs_continuous
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant, SystemTime};
use irtt_client::{ClientEvent, ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use irtt_stats::{StatsCollector, StatsConfig};
fn main() {
let mut finite = StatsCollector::new(StatsConfig::finite());
let mut continuous = StatsCollector::new(StatsConfig::continuous());
for event in synthetic_probe_stream(10) {
finite.process(&event);
continuous.process(&event);
}
let finite_snapshot = finite.snapshot();
let continuous_snapshot = continuous.snapshot();
println!(
"finite: replies={}, rtt median_ns={:?}",
finite_snapshot.events.echo_replies, finite_snapshot.rtt.primary.median_ns
);
println!(
"continuous: replies={}, rtt median_ns={:?}",
continuous_snapshot.events.echo_replies, continuous_snapshot.rtt.primary.median_ns
);
println!(
"continuous mode keeps no exact samples, so its median is always None, \
while finite mode's median is available once it has samples."
);
}
/// Builds a short synthetic stream of `EchoSent`/`EchoReply` pairs, plus one
/// loss, standing in for what a real `irtt-client` session would emit.
fn synthetic_probe_stream(count: u32) -> Vec<ClientEvent> {
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
let mut events = Vec::new();
for seq in 0..count {
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: Instant::now(),
};
events.push(ClientEvent::EchoSent {
seq,
remote,
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(50),
timer_error: Duration::from_micros(20),
});
if seq == count - 1 {
// The last probe times out instead of receiving a reply.
events.push(ClientEvent::EchoLoss {
seq,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
});
continue;
}
let rtt = Duration::from_millis(10 + u64::from(seq));
let received_at = ClientTimestamp {
wall: sent_at.wall + rtt,
mono: sent_at.mono + rtt,
};
events.push(ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt: RttSample {
raw: rtt,
adjusted: None,
effective: SignedDuration::from_duration(rtt),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
events
}
+19 -2
View File
@@ -1,7 +1,24 @@
//! Statistics aggregation for `irtt-client` events.
//!
//! The crate consumes `irtt-client` events and produces cumulative or rolling
//! snapshots for reporting and integration code.
//! [`StatsCollector`] consumes [`irtt_client::ClientEvent`] values through
//! [`StatsCollector::process`] and produces a cumulative [`Snapshot`], plus
//! optional count- and time-based rolling snapshots, for reporting and
//! integration code.
//!
//! # Retention modes
//!
//! [`StatsConfig`] selects between two retention modes via [`SampleMode`].
//! [`StatsConfig::finite`] ([`SampleMode::Exact`]) retains an exact sample per
//! timing metric, so every metric reports an exact median once it has
//! samples; retention grows with the number of probes processed. This is the
//! default and suits a run with a known, bounded duration.
//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
//! running statistics — no exact median — and bounds its adjacent-sequence
//! IPDV tracking, so memory stays bounded for a long-running or unbounded
//! session. Rolling snapshots always use running statistics regardless of
//! this setting.
//!
//! See `examples/` in the repository for a runnable comparison of both modes.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
+224
View File
@@ -0,0 +1,224 @@
# irtt-client
chatgpt-codex-connector[bot] commented 2026-08-20 11:17:50 +00:00 (Migrated from github.com)
Review

P2 Badge Qualify final summaries as table-only

When a finite run uses --format csv, tsv, or jsonl, no final summary is printed: OutputFormat::prints_summary() returns true only for table output, and StreamOutput::print_summary() enforces that condition. Saying a summary is “always printed” therefore gives structured-output users the wrong contract and contradicts the format documentation immediately below; qualify this statement as applying only to table output.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Qualify final summaries as table-only** When a finite run uses `--format csv`, `tsv`, or `jsonl`, no final summary is printed: `OutputFormat::prints_summary()` returns true only for table output, and `StreamOutput::print_summary()` enforces that condition. Saying a summary is “always printed” therefore gives structured-output users the wrong contract and contradicts the format documentation immediately below; qualify this statement as applying only to table output. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:22:52 +00:00 (Migrated from github.com)
Review

Fixed: qualified both mentions of the final summary to table output specifically, matching the OUTPUT section's existing note that CSV/TSV/JSONL never print one. See 86468f0.

🤖 Addressed by Claude Code

Fixed: qualified both mentions of the final summary to table output specifically, matching the OUTPUT section's existing note that CSV/TSV/JSONL never print one. See 86468f0. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 11:31:16 +00:00 (Migrated from github.com)
Review

P2 Badge Document the canonical delay column names

The canonical serialized column names are rd_us and sd_us: selecting either long spelling is accepted by the parser, but CSV/TSV headers and JSONL keys are still emitted using Column::name(), which returns the abbreviated spelling. Describing receive_delay_us and send_delay_us as the columns and the emitted names as aliases gives structured-output consumers the wrong schema; reverse this wording so the documented keys match the actual output.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Document the canonical delay column names** The canonical serialized column names are `rd_us` and `sd_us`: selecting either long spelling is accepted by the parser, but CSV/TSV headers and JSONL keys are still emitted using `Column::name()`, which returns the abbreviated spelling. Describing `receive_delay_us` and `send_delay_us` as the columns and the emitted names as aliases gives structured-output consumers the wrong schema; reverse this wording so the documented keys match the actual output. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 11:46:22 +00:00 (Migrated from github.com)
Review

Fixed: reversed the wording so rd_us/sd_us are documented as the emitted column names, with receive_delay_us/send_delay_us as the longer selector aliases, matching Column::name(). See 68e5f36.

🤖 Addressed by Claude Code

Fixed: reversed the wording so `rd_us`/`sd_us` are documented as the emitted column names, with `receive_delay_us`/`send_delay_us` as the longer selector aliases, matching `Column::name()`. See 68e5f36. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Exempt --columns default from the all-events claim

An explicit --columns default selection does not show all event rows: OutputConfig::new deliberately treats it like an omitted column option, sets default_table_rows, and continues hiding echo_sent rows in table output. Qualify this claim to custom or all selections so users do not expect --columns default to expose events that remain suppressed.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Exempt `--columns default` from the all-events claim** An explicit `--columns default` selection does not show all event rows: `OutputConfig::new` deliberately treats it like an omitted column option, sets `default_table_rows`, and continues hiding `echo_sent` rows in table output. Qualify this claim to custom or `all` selections so users do not expect `--columns default` to expose events that remain suppressed. Useful? React with 👍 / 👎.
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Qualify the no-success exit-status condition

When a run is interrupted, the implementation suppresses the “no managed target completed successfully” error via the !interrupted condition, so, for example, interrupting a continuous run whose only target never opened exits with status 0. This sentence currently says such a run exits nonzero and contradicts the preceding interrupted-run exception; qualify the no-success condition as applying only to uninterrupted runs.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Qualify the no-success exit-status condition** When a run is interrupted, the implementation suppresses the “no managed target completed successfully” error via the `!interrupted` condition, so, for example, interrupting a continuous run whose only target never opened exits with status 0. This sentence currently says such a run exits nonzero and contradicts the preceding interrupted-run exception; qualify the no-success condition as applying only to uninterrupted runs. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:20:34 +00:00 (Migrated from github.com)
Review

Fixed: clarified that --columns default is equivalent to omitting the option and keeps echo_sent hidden for table output; only a different explicit selection exposes it. See 17ef443.

🤖 Addressed by Claude Code

Fixed: clarified that `--columns default` is equivalent to omitting the option and keeps echo_sent hidden for table output; only a different explicit selection exposes it. See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
Lochnair commented 2026-08-20 12:20:35 +00:00 (Migrated from github.com)
Review

Fixed: qualified the no-success exit condition to uninterrupted runs, with an explicit example (interrupting a continuous run whose only target never opened still exits 0). See 17ef443.

🤖 Addressed by Claude Code

Fixed: qualified the no-success exit condition to uninterrupted runs, with an explicit example (interrupting a continuous run whose only target never opened still exits 0). See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 12:26:39 +00:00 (Migrated from github.com)
Review

P2 Badge Qualify the structured-output all-columns statement

With a command such as --format csv --columns event,seq --verbose, structured output contains only the explicitly selected columns, because parse_columns returns that custom list. Structured formats merely default to all columns; saying they already include every column misdocuments supported custom-column output. Clarify that --verbose has no effect on structured formats regardless of whether their default or a custom selection is used.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Qualify the structured-output all-columns statement** With a command such as `--format csv --columns event,seq --verbose`, structured output contains only the explicitly selected columns, because `parse_columns` returns that custom list. Structured formats merely default to all columns; saying they already include every column misdocuments supported custom-column output. Clarify that `--verbose` has no effect on structured formats regardless of whether their default or a custom selection is used. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:35:07 +00:00 (Migrated from github.com)
Review

Fixed: clarified that --verbose has no effect on structured formats regardless of whether their default or a custom --columns selection is in use, since a custom selection can itself be a subset. See 428e46d.

🤖 Addressed by Claude Code

Fixed: clarified that --verbose has no effect on structured formats regardless of whether their default or a custom --columns selection is in use, since a custom selection can itself be a subset. See 428e46d. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
## NAME
irtt-client - IRTT-compatible stream client
## SYNOPSIS
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]...
`irtt-client` `--list-columns`
## DESCRIPTION
`irtt-client` probes one or more IRTT-compatible servers over UDP and prints
per-probe measurement events as they arrive, plus a final summary for
eligible runs.
## TARGETS
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix
assigns the logical target name used in output; without it, the target
string itself is used as the label. Labeled and unlabeled targets can be
freely mixed in one argument list.
```sh
irtt-client host-a:2112
irtt-client eu=host-a:2112 us=host-b:2112
irtt-client host-a:2112 eu=host-b:2112
```
Multiple targets are probed concurrently. By default, a multi-target session
uses **staggered** pacing, spacing active targets across the probe interval;
pass `--pacing burst` to send one probe to every active target back-to-back
once per interval instead. Final per-target summaries print in the order
targets were supplied on the command line, not the order in which they
finish or their labels sort alphabetically.
## FINITE VERSUS CONTINUOUS OPERATION
`--duration` controls the run length:
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
after that long, and a final summary is always printed for table output
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
- `--duration 0` is **continuous**: the client runs until interrupted
(`Ctrl-C`). For table output, a final summary is printed only if the run
was interrupted; an uninterrupted continuous run (one that exits for
another reason) does not get one.
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
distinction also determines what statistics can be computed and how much
memory a long run retains.
## OUTPUT
### `--format FORMAT`
One of four event-row formats, default `table`:
- `table`: human-readable terminal output; the only format that prints a
final summary.
- `csv`: comma-separated output.
- `tsv`: tab-separated output.
- `jsonl`: one JSON object per event.
CSV, TSV, and JSON Lines default to **all** columns for structured export;
`table` defaults to a compact column set and hides `echo_sent` rows. An
explicit `--columns` selection other than `default` shows all event rows,
including `echo_sent`; `--columns default` is equivalent to omitting the
option and keeps `echo_sent` hidden for table output.
### `--columns COLUMNS` / `-c COLUMNS`
Comma-separated event row columns, or the special values `default` (the
format's default set) or `all` (every column). List every available column,
its meaning, and its aliases with:
```sh
irtt-client --list-columns
```
(`--list-columns` does not require a target.)
### `--header auto|always|never`
Header policy for `table`, `csv`, and `tsv` output (default `auto`). JSON
Lines never prints a header line.
### `--verbose`
Includes extra fields in table output and final summaries. Has no effect on
CSV, TSV, or JSON Lines output either way — those formats show their default
all-column set or an explicit `--columns` selection exactly as given,
regardless of `--verbose`.
### Examples
```sh
irtt-client <server> --format table
irtt-client <server> --format jsonl
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us
```
A stream containing only effective RTT values in microseconds:
```sh
irtt-client <server> --format tsv --columns effective_rtt_us --header never
```
## MEASUREMENT FIELDS
Useful measurement columns:
- `raw_rtt_us`: client-observed send-to-receive RTT. Always userspace-timed
and unaffected by kernel timestamp preference (below).
- `adjusted_rtt_us`: RTT adjusted for server processing when available. Can
be negative when server processing exceeds the measured raw RTT.
- `effective_rtt_us`: `adjusted_rtt_us` when available, otherwise
`raw_rtt_us`.
- `rd_us` / `sd_us` (selectable with the longer aliases `receive_delay_us` /
`send_delay_us`, but always emitted under the short names): one-way delay
estimates. Can be negative because of clock skew between client and
server.
- `ipdv_us`: inter-packet delay variation.
- `server_processing_us`: time spent processing the packet at the server.
- `kernel_rx_ns`: kernel receive timestamp, in Unix nanoseconds, when
captured (see below).
### Kernel timestamps (Linux)
On Linux, one-way delay estimates prefer a kernel-captured timestamp over
userspace timing for this client's own send/receive endpoint, where the
platform and socket support it, reducing the effect of scheduling and
queuing delay. The peer's reported endpoint gets the same preference when it
is the `irtt-server` applet running on Linux; against another
IRTT-compatible server, whatever wall-clock value it reports is used as-is.
This is best-effort: an unsupported or implausible kernel timestamp falls
back to userspace timing. `raw_rtt_us` is unaffected either way;
`adjusted_rtt_us`/`effective_rtt_us` can still shift slightly, since server
processing time is derived from the same server receive endpoint.
## MEASUREMENTS AND MEMORY
Finite and continuous modes make a different tradeoff between exact
statistics and bounded memory.
**Finite mode** retains exact timing samples (and adjacent-sequence IPDV
state) so the final summary can report exact medians. Retained memory
therefore grows with the number of probes sent, at a small constant cost per
probe (on the order of a few hundred bytes of retained sample state per
probe per target, before allocator overhead) — a very long finite run at a
short interval is the case to plan capacity for. The client warns on stderr
when its own estimate of this retained state crosses roughly 128 MiB, and
again more strongly past roughly 512 MiB and 1 GiB; treat those numbers as
practical guidance, not a promised RSS ceiling, since allocator and OS
overhead sit outside the estimate.
**Continuous mode** (`--duration 0`) does not retain a growing sample
history: running statistics keep count/min/max/mean/variance only, so exact
medians are unavailable. It still keeps a bounded adjacent-sequence IPDV
store, capped at 4,096 sequences per target, so nearby replies can still
form IPDV pairs. Retained statistics state therefore approaches a fixed
per-target bound rather than growing with elapsed run time.
Reply classification (independent of the statistics choice above) is also
bounded per target: each of the pending, timed-out, and completed/duplicate
sequence stores has its own 4,096-entry limit — not one shared total across
the three. A full pending store makes the client fail and drain that target
as resource-exhausted rather than wait for capacity to free. Timed-out and
completed state evict their oldest entries, so a sufficiently old late reply
can still be seen and counted, but no longer has retained send state for
measurements such as RTT.
Overall, retained state scales with the **number of configured targets**
and these fixed per-target limits, not with total probes sent in continuous
mode. Output is written as events arrive rather than accumulated; the
managed event stream feeding it is itself bounded and lossy (16,384 events),
so a sufficiently slow consumer can miss events, in which case the client
reports that resulting output/statistics may be incomplete.
For capacity planning on constrained devices (for example an OpenWrt
router): a finite run's statistics memory is roughly proportional to
`targets × probes_sent`, while a continuous run's statistics memory
approaches a fixed multiple of `targets` regardless of how long it runs.
## PEER CLOSE AND EXIT BEHAVIOR
A peer closing a finite run is accepted as a terminal, successful outcome.
In continuous mode, an uninterrupted peer closure is treated as an error —
the client exits nonzero — so a process supervisor can restart it; an
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
mode it was in.
## EXIT STATUS
`0` on success (including a finite run's ordinary completion, and an
interrupted run of either mode). Nonzero if the managed driver failed, if an
uninterrupted continuous run ended by peer closure, or if the run was not
interrupted and no target completed successfully. An interrupted run never
fails on the last of these conditions — for example, interrupting a
continuous run whose only target never opened still exits `0`.
## SERVER FILL REQUEST
`--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
hexadecimal pattern). See `irtt-server(1)` SERVER FILL for what the server
does with an unset or unparseable request.
## EXAMPLES
```sh
irtt-client netperf-eu.bufferbloat.net:2112
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
irtt-client host-a:2112 host-b:2112 --pacing burst
```
## SEE ALSO
`irtt-tui(1)`, `irtt-server(1)`, `irtt-rs(1)`, and `irtt-client --help` for
the full option list, including HMAC, clock/timestamp negotiation, DSCP, and
TTL flags.
+64
View File
@@ -0,0 +1,64 @@
# irtt-rs
chatgpt-codex-connector[bot] commented 2026-08-20 12:26:39 +00:00 (Migrated from github.com)
Review

P2 Badge Exclude unknown irtt- names from dispatcher fallthrough*

If the dispatcher is copied or symlinked as an unrecognized name such as irtt-custom, dispatch_from_argv returns an unknown-applet error before examining the client/tui/server subcommand. Thus irtt-custom server does not fall through as the phrase “any other name” promises, and this directly contradicts the preceding sentence; qualify the fallthrough as applying only to names that do not start with irtt-.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Exclude unknown irtt-* names from dispatcher fallthrough** If the dispatcher is copied or symlinked as an unrecognized name such as `irtt-custom`, `dispatch_from_argv` returns an unknown-applet error before examining the `client`/`tui`/`server` subcommand. Thus `irtt-custom server` does not fall through as the phrase “any other name” promises, and this directly contradicts the preceding sentence; qualify the fallthrough as applying only to names that do not start with `irtt-`. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:35:10 +00:00 (Migrated from github.com)
Review

Fixed: corrected the fallthrough description — only irtt-rs itself, or a name that doesn't start with irtt- at all, falls through to subcommand parsing; any other irtt-* name is rejected before argv[1] is examined. Added the irtt-custom example. See 428e46d.

🤖 Addressed by Claude Code

Fixed: corrected the fallthrough description — only irtt-rs itself, or a name that doesn't start with irtt- at all, falls through to subcommand parsing; any other irtt-* name is rejected before argv[1] is examined. Added the irtt-custom example. See 428e46d. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
## NAME
irtt-rs - IRTT-compatible multi-applet dispatcher
## SYNOPSIS
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
`irtt-rs` `tui` [*OPTIONS*] *[LABEL=]TARGET*...
`irtt-rs` `server` [*OPTIONS*]
## DESCRIPTION
`irtt-rs` is a multicall dispatcher: one binary that runs the client, TUI,
or server applet depending on the subcommand given, or on the name it was
invoked/copied as. It exists for router-friendly single-binary deployment;
it does not add behavior beyond what `irtt-client(1)`, `irtt-tui(1)`, and
`irtt-server(1)` already provide.
Each subcommand behaves exactly like its dedicated binary — `irtt-rs client
...` is equivalent to running `irtt-client ...` directly. See those manuals
for target syntax, options, output, and exit behavior.
## APPLET BINARY NAMES
The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
`irtt-server`. A dedicated binary always runs its own role, regardless of
what it is invoked or copied as — it does not consult argv0 dispatch logic.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
`irtt-server` — it runs that applet directly, without needing a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even
attempted — `irtt-custom server` is a hard error, not a fallthrough. Only
`irtt-rs` itself, or a name that doesn't start with `irtt-` at all, falls
through to ordinary `client`/`tui`/`server` subcommand parsing.
## FEATURE-DEPENDENT APPLET AVAILABILITY
Each applet is gated by a Cargo feature on the `irtt-rs` package: `client`,
`tui`, `server`. All three are in the default feature set. A build with a
feature disabled still produces the `irtt-rs` binary, but that applet is
unavailable at runtime — running it (by subcommand or by binary name)
reports which feature to rebuild with, and `irtt-rs --help` lists which
applets the current build actually contains.
```sh
cargo install irtt-rs --locked --no-default-features --features client
```
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
`irtt-rs` is packaging and dispatch only. All probing, display, and server
behavior lives in `irtt-client(1)`, `irtt-tui(1)`, and `irtt-server(1)` —
consult those manuals for anything observable about running a probe or
serving one.
## SEE ALSO
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
+261
View File
@@ -0,0 +1,261 @@
# irtt-server
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Document the capped-interval refill cadence

When --min-interval exceeds one quarter of --idle-timeout, negotiation caps the session interval below the configured minimum and the rate limiter replenishes at that shorter negotiated interval, not at --min-interval. Describing the configured floor as the refill cadence gives operators the wrong effective rate policy for this supported configuration; mention that the negotiated interval becomes the cadence when the idle cap wins.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Document the capped-interval refill cadence** When `--min-interval` exceeds one quarter of `--idle-timeout`, negotiation caps the session interval below the configured minimum and the rate limiter replenishes at that shorter negotiated interval, not at `--min-interval`. Describing the configured floor as the refill cadence gives operators the wrong effective rate policy for this supported configuration; mention that the negotiated interval becomes the cadence when the idle cap wins. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:21:24 +00:00 (Migrated from github.com)
Review

Fixed: clarified that the reply-allowance refill cadence tracks the negotiated interval, which can be reduced below --min-interval by the quarter-idle-timeout cap. See 17ef443.

🤖 Addressed by Claude Code

Fixed: clarified that the reply-allowance refill cadence tracks the negotiated interval, which can be reduced below --min-interval by the quarter-idle-timeout cap. See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 14:54:04 +00:00 (Migrated from github.com)
Review

P2 Badge Describe the refill cadence as the shorter interval

With an ordinary configuration where the negotiated interval exceeds the configured floor—for example, a 1 s negotiated interval and the default 10 ms minimum—this says allowance refills every second, but RateState::new uses configured.min(params.interval_ns) and therefore refills every 10 ms. The previous thread's documentation fix overcorrected the capped-interval case; the current implementation is fresh evidence that the rule is the shorter of the configured minimum and negotiated interval. Document that formula so operators do not infer a substantially stricter rate limit.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Describe the refill cadence as the shorter interval** With an ordinary configuration where the negotiated interval exceeds the configured floor—for example, a 1 s negotiated interval and the default 10 ms minimum—this says allowance refills every second, but `RateState::new` uses `configured.min(params.interval_ns)` and therefore refills every 10 ms. The previous thread's documentation fix overcorrected the capped-interval case; the current implementation is fresh evidence that the rule is the shorter of the configured minimum and negotiated interval. Document that formula so operators do not infer a substantially stricter rate limit. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 14:59:15 +00:00 (Migrated from github.com)
Review

Fixed: rewrote to document the actual formula (min(configured floor, negotiated interval)), including the ordinary case where the floor is normally shorter, since the previous wording overcorrected into implying the negotiated value always wins. Folded into the relevant commit as part of this PR's squash/rebase cleanup.

🤖 Addressed by Claude Code

Fixed: rewrote to document the actual formula (min(configured floor, negotiated interval)), including the ordinary case where the floor is normally shorter, since the previous wording overcorrected into implying the negotiated value always wins. Folded into the relevant commit as part of this PR's squash/rebase cleanup. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
## NAME
irtt-server - IRTT-compatible UDP server
## SYNOPSIS
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
## DESCRIPTION
`irtt-server` answers IRTT echo requests over UDP. It is thin orchestration
over the reusable `irtt-server` library: one current-thread Tokio runtime,
one or more listeners, and the library's own session/policy logic.
Run with no arguments for the ordinary case:
```sh
irtt-server
```
With no `--bind`, the server listens on the standard IRTT port (2112) on the
wildcard address for both address families, in this fixed order:
1. `[::]:2112`
2. `0.0.0.0:2112`
This is available wherever the platform supports safe wildcard reply-source
selection (see [Wildcard binds](#wildcard-binds) below). Any explicit
`--bind` **replaces** this default pair entirely rather than adding to it —
`irtt-server --bind 127.0.0.1:2112` binds only that one address.
If one of the two default addresses cannot be bound because its address
family itself has no local support at all — IPv6 disabled on an otherwise
IPv4-capable host, for example — the server falls back to serving just the
other one instead of failing outright, and says so on startup. Any other
reason a default listener fails — the port already in use, permission
denied, no safe wildcard reply-source path — is not a family-support
problem and is not silently worked around; the invocation fails as it would
for an explicit `--bind`. This fallback applies only to the implicit default
pair: an explicit `--bind` list always keeps the ordinary all-or-nothing
behavior described below.
## OPTIONS
### `--bind ADDR`
Local UDP address to bind, as `ADDR:PORT`, for example `127.0.0.1:2112` or
`[::1]:2112`. Host names are not resolved. Repeatable.
Repeat the option to serve several addresses from one process, in the order
given. Every listener applies the same policy options, but each keeps its
own sessions and tokens, so a session belongs to the address it was opened
on. Binding is all-or-nothing: if any address cannot be bound, none are
served.
A port of `0` selects an unused port per listener, so two such binds get two
different ports.
With no `--bind` at all, see [Default bind](#default-bind) above.
### `--hmac KEY`
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
every request must carry a valid MAC and every reply is authenticated;
without one, authenticated requests are dropped. The key is visible in the
process arguments.
### `--max-sessions COUNT`
Maximum number of simultaneously live sessions **per listener**, not per
process. Once a listener's table is full, a session-creating open to it is
dropped silently; nothing is evicted. Zero refuses every session-creating
open.
### `--max-packet-length BYTES`
Maximum echo datagram size a session may negotiate, in bytes. A longer
requested length is reduced during negotiation, an open whose mandatory
field block would not fit is refused, and inbound echo requests are admitted
against the same limit. This is a resource bound, not an MTU.
### `--min-interval DURATION`
Floor on the send interval a session may negotiate. The negotiated interval
is still capped at a quarter of the idle timeout afterwards, so a
`--min-interval` above that cap is not the value actually negotiated.
A session's reply allowance refills at the shorter of this configured floor
and the interval it actually negotiated. Ordinarily that is the configured
floor itself, since the negotiated interval is normally at least as long —
a 10ms `--min-interval` against a 1s negotiated interval refills every
10ms. The two differ only when the idle-timeout cap above pulled the
negotiated interval below the configured floor, in which case the shorter,
negotiated value is what the allowance actually refills at: a server must
not enforce a cadence it did not advertise to the client. Use `0` for no
time-based throttling.
### `--burst COUNT`
Echo replies one session may have answered before its allowance has to
refill. Use `0` for no allowance at all, which rate-limits every echo
request whatever the interval is.
### `--idle-timeout DURATION`
Release a session after this long without a served or rate-limited echo
request. The deadline runs from the open, and release is silent. Use `0` to
expire a session at the next evaluation; it is not a way to disable expiry.
### `--max-duration DURATION`
Maximum test duration a session may negotiate. A longer request is reduced
to it, and a continuous request is answered with it. Omit this flag for no
maximum; a maximum of zero cannot be expressed, because a negotiated
duration of zero means continuous.
### `--timestamp-allowance MODE`
How many timestamps this server will provide: `dual` (default, honors a
request for send, receive, both, or midpoint timestamps), `single`
(provides at most one timestamp instant; a request for both is negotiated to
midpoint), or `none` (no timestamps at all). The requested clock source is
never changed, only which instants are reported, so a single instant is
still reported once per requested clock.
### `--no-dscp`
Refuse to provide requested traffic-class marking. Any requested DSCP is
negotiated to zero, so the client is told its echo replies will be unmarked,
and they are sent unmarked. The session is not refused.
Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`.
## OUTPUT
Each bound endpoint is printed on startup, once every listener is up, which
also resolves a port of `0`:
```
irtt-server: listening on [::]:2112
irtt-server: listening on 0.0.0.0:2112
```
`Ctrl-C` stops the server gracefully.
## MULTIPLE LISTENERS
Every invocation runs through the same multi-listener path, so one address —
whether from the default pair or a single explicit `--bind` — is an ordinary
set of one rather than a separate mode. The policy options above apply to
every listener, but listeners are otherwise independent: each has its own
sessions and tokens, and `--max-sessions` bounds each listener rather than
the process. If any listener fails while running, the others are shut down
with it rather than leaving a service configured for two families answering
on one.
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
does.
## SERVER FILL
Echo replies fill their payload according to the negotiated `ServerFill`
mode, which a client requests with `irtt-client`'s `--sfill` (see
`irtt-client(1)`):
- `none`: zero-filled.
- `rand`: random bytes.
- `pattern:HEX`: the given repeating hexadecimal pattern.
Every valid descriptor is honored. A descriptor the server cannot parse, and
a client that expresses no fill preference at all, both get the default
`pattern:69727474`, i.e. the repeating bytes `69 72 74 74` (`irtt`).
## WILDCARD BINDS
An explicit interface address works on every supported system. A wildcard
bind such as `0.0.0.0:2112` or `[::]: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 actually contacted.
That reply-source selection is implemented on **Linux, macOS, and FreeBSD**.
Elsewhere, a wildcard bind is refused at construction rather than served
from a routing-table-selected source address a client would discard — this
also applies to the zero-argument default, which will fail with a clear
error asking for an explicit `--bind` on such a platform. This correctness
rule is deliberate and is not weakened by the zero-argument default.
## EXAMPLES
Run with the default wildcard listeners on the standard port:
```sh
irtt-server
```
Bind a single explicit address:
```sh
irtt-server --bind 127.0.0.1:2112
```
Through the `irtt-rs` dispatcher:
```sh
irtt-rs server --bind 192.0.2.10:2112
```
Serve two explicit addresses from one process:
```sh
irtt-server \
--bind 0.0.0.0:2112 \
--bind [::]:2112
```
Apply session policy:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--max-sessions 512 \
--idle-timeout 30s
```
Restrict optional capabilities:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--timestamp-allowance single \
--no-dscp
```
Require authentication:
```sh
irtt-server --bind 192.0.2.10:2112 --hmac secret
```
## EXIT STATUS
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
fails while running.
## LIMITATIONS
- No hostname resolution, interface expansion, daemonization, or
configuration file — binds are explicit addresses only.
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
FreeBSD; such platforms must bind explicit addresses.
## SEE ALSO
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
the full option list.
+83
View File
@@ -0,0 +1,83 @@
# irtt-tui
chatgpt-codex-connector[bot] commented 2026-08-20 14:37:50 +00:00 (Migrated from github.com)
Review

P2 Badge Describe p as pausing only the display

When an operator presses p, toggle_pause only suppresses scheduled rendering; the managed client continues sending probes, draining events, and advancing statistics and graph history. Calling this simply “Toggle pause” can make users believe network probing has stopped, so label it as a display/redraw pause.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Describe `p` as pausing only the display** When an operator presses `p`, `toggle_pause` only suppresses scheduled rendering; the managed client continues sending probes, draining events, and advancing statistics and graph history. Calling this simply “Toggle pause” can make users believe network probing has stopped, so label it as a display/redraw pause. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 14:44:38 +00:00 (Migrated from github.com)
Review

Fixed: reworded to "Toggle display pause (probing continues; only redraws stop)", matching toggle_pause's actual effect on scheduled rendering only. Folded into the relevant commit as part of a squash/rebase cleanup of this PR's history.

🤖 Addressed by Claude Code

Fixed: reworded to "Toggle display pause (probing continues; only redraws stop)", matching toggle_pause's actual effect on scheduled rendering only. Folded into the relevant commit as part of a squash/rebase cleanup of this PR's history. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
chatgpt-codex-connector[bot] commented 2026-08-20 14:54:04 +00:00 (Migrated from github.com)
Review

P2 Badge Include --length in the shared-option list

The purported full list omits a supported shared option: running irtt-tui --help reports --length <LENGTH> as “UDP payload length,” because TuiArgs flattens the same CommonClientArgs as the stream client. Users relying on this manual can therefore miss that the TUI can request a specific probe payload size; include --length or avoid describing the list as complete.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Include `--length` in the shared-option list** The purported full list omits a supported shared option: running `irtt-tui --help` reports `--length <LENGTH>` as “UDP payload length,” because `TuiArgs` flattens the same `CommonClientArgs` as the stream client. Users relying on this manual can therefore miss that the TUI can request a specific probe payload size; include `--length` or avoid describing the list as complete. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 14:59:17 +00:00 (Migrated from github.com)
Review

Fixed: added --length to the shared-option list. Folded into the relevant commit as part of this PR's squash/rebase cleanup.

🤖 Addressed by Claude Code

Fixed: added --length to the shared-option list. Folded into the relevant commit as part of this PR's squash/rebase cleanup. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
## NAME
irtt-tui - IRTT-compatible terminal UI client
## SYNOPSIS
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
## DESCRIPTION
`irtt-tui` is a live dashboard over the same probing engine as
`irtt-client(1)`: a graph and status view instead of a printed event stream.
At least one target is required.
Target syntax, multiple targets, and pacing are identical to
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
`--pacing staggered|burst` option. The full set of negotiation flags
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
well; consult that manual for their meaning.
## CONTINUOUS DEFAULT
Unlike `irtt-client`, **the TUI defaults to continuous mode**
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
a finite run:
```sh
irtt-tui <server> --duration 30s
```
## CONTROLS
| Key | Action |
| --- | --- |
| `q`, `Ctrl-C` | Quit |
| `p` | Toggle display pause (probing continues; only redraws stop) |
| `g` | Toggle Graph / Dashboard view |
| `m` | Cycle graph metric |
| `r` | Clear visible graph history |
| `←` / `→` | Pan graph |
| `PageUp` / `PageDown` | Page-pan graph |
| `Home` / `End` | Jump to oldest / jump to live |
| `+` / `=` | Zoom graph in |
| `-` | Zoom graph out |
| `0` | Reset graph window and zoom |
## RETAINED HISTORY
The TUI keeps its own bounded presentation state on top of the statistics
retention described in `irtt-client(1)`:
- Up to 100,000 graph samples per target.
- Up to 80 recent status/log messages.
`r` clears the currently visible graph history and returns the viewport to
live; it does not change the 100,000-sample cap itself. These are
presentation bounds on top of the client's own statistics retention (see
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
total memory is dominated by whichever of the two is larger for your target
count.
## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
exit status follows the same peer-close and driver-failure rules as
`irtt-client(1)`.
## EXAMPLES
```sh
irtt-tui host.example
irtt-tui eu=host.example
irtt-tui eu=host-a.example us=host-b.example --pacing burst
irtt-tui host.example --duration 30s
```
## SEE ALSO
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.