Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee 641f8e18c3 chore(release): prepare irtt-rs 0.10.2
Rust CI / cargo fmt (push) Successful in 11s
Rust CI / cargo clippy (push) Successful in 27s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m12s
Rust CI / cargo doc (push) Successful in 28s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m16s
Netem Interop / netem interop (push) Successful in 1m18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Supply chain / cargo-deny advisories (push) Successful in 16s
Supply chain / actionlint (push) Failing after 2s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Build binaries / Build (arm-musleabi) (push) Successful in 4m58s
Build binaries / Build (aarch64-musl) (push) Successful in 5m8s
Semver / cargo-semver-checks (push) Successful in 53s
Publish irtt-server image / publish (push) Successful in 1m8s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m29s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m25s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m36s
Build binaries / Build (i686-musl) (push) Successful in 4m35s
Build binaries / Build (mips-musl) (push) Successful in 4m24s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m22s
Build binaries / Build (mipsel-musl) (push) Successful in 4m39s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m41s
Build binaries / Build (riscv64gc-musl) (push) Successful in 5m19s
Build binaries / Build (x86_64-musl) (push) Successful in 5m19s
2026-10-28 15:30:59 +07:00
Nils Andreas Svee d2d5f87e91 feat(client): filter output by selected event types
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m17s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Netem Interop / netem interop (push) Successful in 1m19s
Supply chain / cargo-deny advisories (push) Successful in 19s
Supply chain / cargo-deny bans licenses sources (push) Successful in 18s
Supply chain / actionlint (push) Failing after 3s
Build binaries / Build (arm-musleabi) (push) Successful in 4m50s
Build binaries / Build (aarch64-musl) (push) Successful in 5m5s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m45s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m38s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m52s
Build binaries / Build (i686-musl) (push) Successful in 4m54s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m33s
Build binaries / Build (mips-musl) (push) Successful in 4m35s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m44s
Build binaries / Build (mipsel-musl) (push) Successful in 4m45s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m47s
Build binaries / Build (x86_64-musl) (push) Successful in 5m19s
2026-10-28 15:28:26 +07:00
Nils Andreas Svee 5b5e38a059 chore(release): prepare irtt-rs 0.10.1
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo clippy (push) Successful in 27s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 36s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Rust CI / cargo doc (push) Successful in 32s
Build binaries / Build (arm-musleabi) (push) Successful in 4m54s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m17s
Build binaries / Build (aarch64-musl) (push) Successful in 5m7s
Supply chain / cargo-deny advisories (push) Successful in 22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 18s
Supply chain / actionlint (push) Failing after 4s
Netem Interop / netem interop (push) Successful in 1m20s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Semver / cargo-semver-checks (push) Successful in 58s
Publish irtt-server image / publish (push) Successful in 1m6s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m46s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m42s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m58s
Build binaries / Build (i686-musl) (push) Successful in 4m59s
Build binaries / Build (mips-musl) (push) Successful in 4m37s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m34s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m26s
Build binaries / Build (mipsel-musl) (push) Successful in 4m21s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m25s
Build binaries / Build (x86_64-musl) (push) Successful in 4m25s
2026-10-28 12:30:02 +07:00
Nils Andreas Svee 97bd903769 chore: improve documentation and README
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 23s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m7s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m40s
Netem Interop / netem interop (push) Successful in 1m18s
Supply chain / cargo-deny advisories (push) Successful in 18s
Supply chain / actionlint (push) Failing after 4s
Supply chain / cargo-deny bans licenses sources (push) Successful in 17s
Build binaries / Build (arm-musleabi) (push) Successful in 4m58s
Build binaries / Build (aarch64-musl) (push) Successful in 5m6s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m38s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m33s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m57s
Build binaries / Build (i686-musl) (push) Successful in 5m1s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m35s
Build binaries / Build (mips-musl) (push) Successful in 4m42s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m54s
Build binaries / Build (mipsel-musl) (push) Successful in 4m54s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m36s
Build binaries / Build (x86_64-musl) (push) Successful in 4m40s
2026-10-28 12:17:48 +07:00
Nils Andreas Svee f02f9c0714 chore(deps): update unicode-width to 0.2.3
Rust CI / cargo fmt (push) Successful in 13s
Rust CI / cargo clippy (push) Successful in 25s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 30s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m9s
Rust CI / cargo doc (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m40s
Netem Interop / netem interop (push) Successful in 1m24s
Supply chain / cargo-deny advisories (push) Successful in 18s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Supply chain / actionlint (push) Failing after 3s
Build binaries / Build (arm-musleabi) (push) Successful in 4m57s
Build binaries / Build (aarch64-musl) (push) Successful in 5m7s
Build binaries / Build (arm-musleabihf) (push) Successful in 5m9s
Build binaries / Build (armv7-musleabi) (push) Successful in 5m4s
Build binaries / Build (armv7-musleabihf) (push) Successful in 5m33s
Build binaries / Build (i686-musl) (push) Successful in 5m37s
Build binaries / Build (mips64-muslabi64) (push) Successful in 5m15s
Build binaries / Build (mips-musl) (push) Successful in 5m18s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m29s
Build binaries / Build (mipsel-musl) (push) Successful in 4m47s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m54s
Build binaries / Build (x86_64-musl) (push) Successful in 5m7s
2026-10-28 11:26:13 +07:00
Nils Andreas Svee 5957e395a2 Merge branch 'fix/stdout-cancellation'
Rust CI / cargo fmt (push) Successful in 43s
Rust CI / cargo clippy (push) Successful in 58s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Rust CI / cargo doc (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m39s
Netem Interop / netem interop (push) Successful in 1m23s
Supply chain / cargo-deny advisories (push) Successful in 16s
Supply chain / actionlint (push) Failing after 3s
Supply chain / cargo-deny bans licenses sources (push) Successful in 19s
Build binaries / Build (arm-musleabi) (push) Successful in 5m25s
Build binaries / Build (aarch64-musl) (push) Successful in 5m30s
Build binaries / Build (arm-musleabihf) (push) Successful in 5m19s
Build binaries / Build (armv7-musleabi) (push) Successful in 5m21s
Build binaries / Build (armv7-musleabihf) (push) Successful in 5m38s
Build binaries / Build (i686-musl) (push) Successful in 5m36s
Build binaries / Build (mips64-muslabi64) (push) Failing after 2m27s
Build binaries / Build (mips-musl) (push) Successful in 5m16s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m48s
Build binaries / Build (mipsel-musl) (push) Successful in 4m48s
Build binaries / Build (riscv64gc-musl) (push) Successful in 5m6s
Build binaries / Build (x86_64-musl) (push) Successful in 5m10s
2026-10-28 11:21:15 +07:00
Nils Andreas Svee 5b5f5b2609 fix(client): preserve partial summaries on graceful interruption 2026-10-28 11:10:18 +07:00
Nils Andreas Svee c8a911297d fix(client): allow cancellation while stdout is blocked 2026-10-28 00:32:52 +07:00
13 changed files with 816 additions and 521 deletions

No files matched your search

+14
View File
@@ -240,6 +240,20 @@ release. The format is loosely based on
## irtt-rs
### 0.10.2
#### Added
- Client `--events` filters output by event type, using comma-separated names or repeated options. For example, `--events echo_reply` prints only echo replies. Omitting the option preserves normal output behavior.
- Explicitly selecting `echo_sent` with `--events` shows those events even with default table columns.
### 0.10.1
#### Fixed
- The client now responds to SIGINT and SIGTERM while stdout is blocked, so a stalled output reader no longer prevents shutdown.
- Interrupted measurements preserve partial statistics summaries when output can be written. A shared 250 ms grace period bounds output flushing during shutdown.
### 0.10.0
#### Added
Generated
+3 -3
View File
@@ -890,7 +890,7 @@ dependencies = [
[[package]]
name = "irtt-rs"
version = "0.10.0"
version = "0.10.2"
dependencies = [
"chrono",
"clap",
@@ -2142,9 +2142,9 @@ dependencies = [
[[package]]
name = "unicode-width"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "utf8parse"
+74 -85
View File
@@ -1,97 +1,67 @@
# irtt-rs
`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.
`irtt-rs` is an IRTT-compatible client and server written in Rust. You can use
it to measure UDP latency for a fixed duration or leave it running
continuously, and run your own server for the probes to talk to.
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.
The original IRTT implementation is [heistp/irtt](https://github.com/heistp/irtt),
which also has protocol background and more documentation. This project is
independent, so the installed dispatcher is named `irtt-rs` to keep it from
conflicting with or being mistaken for `irtt`.
## Features
* Finite and continuous probe sessions
* Concurrent probing of multiple targets, with staggered or burst pacing
* Reusable client library with a structured event stream (blocking, Tokio,
- Finite and continuous probe sessions
- 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
- 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 sensible zero-argument defaults and explicit bind
and session-policy options
* Router-friendly multi-applet binary layout
- Optional terminal UI
- Reusable Tokio-native server library
- UDP server applet you can run without arguments, with options for bind
addresses and session policy when you need them
- A single binary that can run several applets, useful on routers
## 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.
`irtt-rs` is useful if you want to watch several targets at once, compare
them in a terminal dashboard, or feed selected measurements into a script.
You can leave it running with statistics and automatic reconnection, use
its Rust libraries in your own application, or deploy a single binary on
a router.
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.
Upstream IRTT is much more mature, and if you don't need the features
specific to `irtt-rs`, you should probably use it. It also has more tools
for working with saved results and controlling how probes run, including
replaying saved JSON reports, timer and clock diagnostics, and client
payload fill and timing algorithms.
| 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 (`--continuous` or `--duration 0`), with optional per-target reconnection | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
| Statistics in continuous mode | Optional running count/min/max/mean/variance per session; no exact medians; bounded IPDV tracking. Use `--no-stats` or omit the `stats` feature to disable collection | 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.
See the [detailed comparison](docs/INTEROP_COMPARISON.md) for individual
features, version coverage, and black-box interoperability testing.
## Installation
The build requires Rust 1.88 or newer.
Prebuilt binaries for supported platforms are available on the
[GitHub Releases](https://github.com/Lochnair/irtt-rs/releases) page.
Install the latest release from crates.io:
Alternatively, install from crates.io using Rust 1.88 or newer:
```sh
cargo install irtt-rs --locked
```
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 without statistics:
This installs the `irtt-rs` dispatcher along with dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. If you only need the client, you can
disable the other applets and statistics:
```sh
cargo install irtt-rs --locked --no-default-features --features client
```
Add `stats` (`--features client,stats`) to retain statistics in a client-only
build. Statistics are enabled by default; the TUI always enables them.
This installs `irtt-client` and the `irtt-rs` dispatcher. Add `stats`
(`--features client,stats`) to retain statistics in a client-only build.
Statistics are enabled by default; the TUI always enables them.
For development, or to install directly from a checkout:
@@ -131,30 +101,56 @@ Watch the same session live in the terminal UI:
irtt-tui netperf-eu.bufferbloat.net:2112
```
Run a server with sensible defaults — no `--bind` needed:
To run your own server, you can leave out `--bind` and use the defaults:
```sh
irtt-server
```
### Dynamic targets from stdin
If another application decides which servers to probe, `--targets-stdin`
lets it change targets without restarting the client. It works in
continuous mode:
```sh
irtt-client --continuous --targets-stdin --reconnect --no-stats \
--format csv --columns event,target,seq,rd_us,sd_us
```
Send one comma-separated target set per line to stdin, for example:
```text
ams=ams.example:2112,lhr=lhr.example:2112
ams=ams.example:2112
[]
```
Each line gives the complete desired target set. Unchanged targets keep
running, removed targets stop, and new or changed targets start. Use `[]`
to clear all targets without exiting.
The CSV includes an `event` column so your script can distinguish
`echo_reply` measurements from other events.
Keep stdin open while controlling the client. Closing it sends EOF and stops the run gracefully.
See [Dynamic target sets from standard input](docs/irtt-client.md#dynamic-target-sets-from-standard-input)
for the full syntax and behavior.
## Documentation
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
- [`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,
- [`docs/irtt-tui.md`](docs/irtt-tui.md): controls, continuous default,
retained history.
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
- [`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
- [`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`.
Every applet also documents its full option list with `--help`.
You can also run any applet with `--help` for its full option list.
## Crates
@@ -166,9 +162,6 @@ Every applet also documents its full option list with `--help`.
| [`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)) |
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 |
@@ -188,7 +181,3 @@ cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
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
@@ -1,6 +1,6 @@
[package]
name = "irtt-rs"
version = "0.10.0"
version = "0.10.2"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
+34 -1
View File
@@ -101,12 +101,22 @@ pub struct ClientArgs {
)]
pub format: OutputFormat,
/// Filter output by event type.
#[arg(
long,
value_name = "EVENTS",
value_delimiter = ',',
value_enum,
long_help = "Filter output by event type. Accepts comma-separated names or repeated options. If omitted, the normal output behavior is unchanged."
)]
pub events: Vec<EventKind>,
/// Comma-separated event row columns, or default/all.
#[arg(
short = 'c',
long,
value_name = "COLUMNS",
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows unless explicitly selected with --events. Custom table columns include all event rows. Run --list-columns to see valid names."
)]
pub columns: Option<String>,
@@ -189,6 +199,29 @@ impl std::ops::Deref for ClientArgs {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "snake_case")]
pub enum EventKind {
SessionStarted,
NoTestCompleted,
SessionClosed,
EchoSent,
EchoReply,
Loss,
Duplicate,
Late,
Warning,
}
impl EventKind {
pub(super) fn name(self) -> String {
self.to_possible_value()
.expect("all event kinds have a CLI value")
.get_name()
.to_owned()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// Readable terminal table output.
+19 -13
View File
@@ -12,12 +12,13 @@ use irtt_client::{
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
use super::args::{HeaderMode, OutputFormat};
use super::args::{EventKind, HeaderMode, OutputFormat};
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
#[derive(Debug, Clone)]
pub(super) struct OutputConfig {
format: OutputFormat,
events: Vec<EventKind>,
columns: Vec<Column>,
header: HeaderMode,
default_table_rows: bool,
@@ -30,6 +31,7 @@ impl OutputConfig {
columns: Option<&str>,
header: HeaderMode,
verbose: bool,
events: Vec<EventKind>,
) -> Result<Self, String> {
let uses_default_columns = columns
.map(|columns| columns.trim() == "default")
@@ -42,6 +44,7 @@ impl OutputConfig {
Ok(Self {
format,
events,
columns,
header,
default_table_rows,
@@ -102,7 +105,10 @@ impl OutputConfig {
#[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>,
) -> Option<String> {
let row = OutputRow::from_event(event);
if self.default_table_rows && row.is_default_table_hidden() {
if !self.events.is_empty() && !self.events.contains(&row.event_kind()) {
return None;
}
if self.events.is_empty() && self.default_table_rows && row.is_default_table_hidden() {
return None;
}
@@ -322,17 +328,17 @@ impl OutputRow {
}
}
fn event_name(&self) -> &'static str {
fn event_kind(&self) -> EventKind {
match self {
Self::SessionStarted(_) => "session_started",
Self::NoTestCompleted(_) => "no_test_completed",
Self::SessionClosed { .. } => "session_closed",
Self::EchoSent { .. } => "echo_sent",
Self::EchoReply(_) => "echo_reply",
Self::Loss { .. } => "loss",
Self::Duplicate { .. } => "duplicate",
Self::Late { .. } => "late",
Self::Warning { .. } => "warning",
Self::SessionStarted(_) => EventKind::SessionStarted,
Self::NoTestCompleted(_) => EventKind::NoTestCompleted,
Self::SessionClosed { .. } => EventKind::SessionClosed,
Self::EchoSent { .. } => EventKind::EchoSent,
Self::EchoReply(_) => EventKind::EchoReply,
Self::Loss { .. } => EventKind::Loss,
Self::Duplicate { .. } => EventKind::Duplicate,
Self::Late { .. } => EventKind::Late,
Self::Warning { .. } => EventKind::Warning,
}
}
@@ -784,7 +790,7 @@ fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
match column {
Column::Target => context.target.map(CellValue::text),
Column::Event => Some(CellValue::text(row.event_name())),
Column::Event => Some(CellValue::text(row.event_kind().name())),
Column::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
Column::Remote => row
.remote()
+192 -33
View File
@@ -3,12 +3,13 @@ use std::{
io::{self, BufRead, Write},
sync::Arc,
thread,
time::Duration,
};
use irtt_client::{
managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
@@ -18,8 +19,7 @@ use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{
parse_stdin_target_set,
session::{
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once,
peer_close_run_error, request_managed_stop_for_peer_close, request_managed_stop_once,
},
worker::ManagedWorker,
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
@@ -36,6 +36,63 @@ use irtt_client::managed::ManagedStatus;
use std::collections::{BTreeMap, BTreeSet};
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
const INTERRUPTED_OUTPUT_GRACE: Duration = Duration::from_millis(250);
// The frontend only renders into memory. This thread is the sole stdout owner;
// dropping its JoinHandle deliberately detaches a writer blocked in a pipe.
struct StdoutWriter {
sender: tokio::sync::mpsc::Sender<Vec<u8>>,
shutdown: tokio::sync::watch::Receiver<bool>,
grace_deadline: Option<tokio::time::Instant>,
}
impl StdoutWriter {
// Measurement output stops waiting for capacity as soon as interrupted.
async fn send(&self, buffer: &mut Vec<u8>) -> io::Result<()> {
let mut shutdown = self.shutdown.clone();
tokio::select! {
biased;
_ = shutdown.wait_for(|requested| *requested) => {
buffer.clear();
Ok(())
}
result = self.enqueue(buffer) => result,
}
}
// Summaries and writer completion share one grace period, regardless of
// the number of targets. Normal output never has a delivery deadline.
async fn send_summary(&mut self, buffer: &mut Vec<u8>) -> io::Result<()> {
let mut shutdown = self.shutdown.clone();
tokio::select! {
biased;
_ = shutdown.wait_for(|requested| *requested) => {
let deadline = *self.grace_deadline.get_or_insert_with(|| {
tokio::time::Instant::now() + INTERRUPTED_OUTPUT_GRACE
});
match tokio::time::timeout_at(deadline, self.enqueue(buffer)).await {
Ok(result) => result,
Err(_) => { buffer.clear(); Ok(()) }
}
}
result = self.enqueue(buffer) => result,
}
}
async fn enqueue(&self, buffer: &mut Vec<u8>) -> io::Result<()> {
if !buffer.is_empty() {
// Take ownership only once capacity is available. Normal operation
// preserves ordering without dropping rendered chunks.
let permit = self
.sender
.reserve()
.await
.map_err(|_| io::Error::other("stdout writer stopped accepting output"))?;
permit.send(std::mem::take(buffer));
}
Ok(())
}
}
pub async fn run_stream(
args: ClientArgs,
@@ -45,6 +102,64 @@ pub async fn run_stream(
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (completed, mut completion) = tokio::sync::oneshot::channel();
let writer = thread::Builder::new()
.name("irtt-stdout".to_owned())
.spawn(move || {
let result = (|| {
let mut stdout = io::LineWriter::new(io::stdout().lock());
while let Some(chunk) = receiver.blocking_recv() {
stdout.write_all(&chunk)?;
}
stdout.flush()
})();
let _ = completed.send(result);
})?;
let mut output = StdoutWriter {
sender,
shutdown: shutdown.clone(),
grace_deadline: None,
};
let run_shutdown = shutdown.clone();
let result = tokio::select! {
biased;
result = &mut completion => {
// Dropping the run future stops and joins its ManagedWorker first.
writer.join().map_err(|_| io::Error::other("stdout writer thread panicked"))?;
result.map_err(|_| io::Error::other("stdout writer completed without a result"))??;
return Err(io::Error::other("stdout writer stopped unexpectedly").into());
}
result = run_stream_output(args, run_shutdown, &mut output) => result,
};
let grace_deadline = output.grace_deadline;
drop(output);
// An interrupt during normal flushing also starts the bounded grace period.
let written = tokio::select! {
biased;
_ = shutdown.wait_for(|requested| *requested) => {
let deadline = grace_deadline.unwrap_or_else(|| {
tokio::time::Instant::now() + INTERRUPTED_OUTPUT_GRACE
});
match tokio::time::timeout_at(deadline, &mut completion).await {
Ok(written) => written,
Err(_) => return result,
}
}
result = &mut completion => result,
};
writer
.join()
.map_err(|_| io::Error::other("stdout writer thread panicked"))?;
written.map_err(|_| io::Error::other("stdout writer completed without a result"))??;
result
}
async fn run_stream_output(
args: ClientArgs,
mut shutdown: tokio::sync::watch::Receiver<bool>,
output: &mut StdoutWriter,
) -> Result<(), Box<dyn std::error::Error>> {
let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
@@ -56,6 +171,7 @@ pub async fn run_stream(
args.columns.as_deref(),
args.header,
args.verbose,
args.events.clone(),
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
#[cfg(feature = "stats")]
@@ -63,7 +179,7 @@ pub async fn run_stream(
eprintln!("{warning}");
}
if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown, &args).await;
return run_stdin_stream(setup, output_config, shutdown, &args, output).await;
}
let continuous = args.is_continuous();
@@ -87,7 +203,7 @@ pub async fn run_stream(
let mut events = handle.subscribe()?;
let mut status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stdout = Vec::new();
let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
@@ -134,14 +250,18 @@ pub async fn run_stream(
) {
drop(handle.stop());
}
if snapshot.final_outcome.is_some() {
if interrupted || snapshot.final_outcome.is_some() {
break;
}
tokio::select! {
_ = shutdown.changed(), if !interrupted => {}
_ = shutdown.changed() => {}
changed = status.changed() => { if changed.is_err() { break; } }
event = events.recv() => match event {
Ok(event) => process_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?,
Ok(event) => {
if *shutdown.borrow() { continue; }
process_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?;
output.send(stream_output.out).await?;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
@@ -153,15 +273,24 @@ pub async fn run_stream(
eprintln!("interrupted, closing managed run...");
}
let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| {
process_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)
})?;
while !*shutdown.borrow() {
match events.try_recv() {
Ok(event) => {
process_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)?;
output.send(stream_output.out).await?;
}
Err(ManagedEventTryRecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
}
}
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
@@ -212,9 +341,10 @@ pub async fn run_stream(
writeln!(stream_output.out, "target: {}", target.label)?;
}
stream_output.print_summary(target_stats)?;
output.send_summary(stream_output.out).await?;
}
}
stream_output.out.flush()?;
output.send_summary(stream_output.out).await?;
if let Some(error) = terminal_error {
return Err(error.into());
}
@@ -476,6 +606,7 @@ async fn run_stdin_stream(
output_config: OutputConfig,
mut shutdown: tokio::sync::watch::Receiver<bool>,
args: &ClientArgs,
output: &mut StdoutWriter,
) -> Result<(), Box<dyn std::error::Error>> {
if *shutdown.borrow() {
return Ok(());
@@ -496,7 +627,7 @@ async fn run_stdin_stream(
let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
spawn_stdin_target_reader(updates)?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stdout = Vec::new();
let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
@@ -528,10 +659,19 @@ async fn run_stdin_stream(
let mut summary_targets = None;
loop {
let snapshot = status.borrow_and_update().clone();
if *shutdown.borrow() {
stop = Some(StdinStop::Interrupted);
#[cfg(feature = "stats")]
{
summary_targets = Some(stats.keys().cloned().collect());
}
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
break;
}
if stop.is_none() {
if *shutdown.borrow() {
stop = Some(StdinStop::Interrupted);
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
if stdin_changed || stdin.has_changed().unwrap_or(true) {
stdin_changed = false;
match stdin.borrow_and_update().clone() {
StdinUpdate::Targets(specs) => {
@@ -595,7 +735,7 @@ async fn run_stdin_stream(
break;
}
tokio::select! {
_ = shutdown.changed(), if stop.is_none() => {}
_ = shutdown.changed() => {}
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
changed = status.changed() => { if changed.is_err() { break; } }
result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
@@ -645,9 +785,11 @@ async fn run_stdin_stream(
}
event = events.recv() => match event {
Ok(event) => {
if *shutdown.borrow() { continue; }
process_stdin_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?;
#[cfg(feature = "stats")]
reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
output.send(stream_output.out).await?;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
@@ -658,15 +800,24 @@ async fn run_stdin_stream(
}
let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| {
process_stdin_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)
})?;
while !*shutdown.borrow() {
match events.try_recv() {
Ok(event) => {
process_stdin_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)?;
output.send(stream_output.out).await?;
}
Err(ManagedEventTryRecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
}
}
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
@@ -682,6 +833,13 @@ async fn run_stdin_stream(
);
}
if *shutdown.borrow() && !matches!(stop, Some(StdinStop::Interrupted)) {
stop = Some(StdinStop::Interrupted);
#[cfg(feature = "stats")]
{
summary_targets = Some(stats.keys().cloned().collect());
}
}
#[cfg(feature = "stats")]
{
let print_summary =
@@ -703,10 +861,11 @@ async fn run_stdin_stream(
)?;
}
stream_output.print_summary(stats)?;
output.send_summary(stream_output.out).await?;
}
}
}
stream_output.out.flush()?;
output.send_summary(stream_output.out).await?;
match stop {
Some(StdinStop::Fatal(error)) => Err(error.into()),
@@ -1,5 +1,7 @@
#[cfg(feature = "tui")]
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
#[cfg(feature = "tui")]
pub(crate) fn drain_final_events<E>(
events: &mut ManagedEventSubscription,
dropped_events: &mut u64,
+40 -6
View File
@@ -1,4 +1,38 @@
# Interop Comparison
# Interop comparison
## Feature comparison
This compares `irtt-rs` with the original [heistp/irtt](https://github.com/heistp/irtt)
release [v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
The aim is for the two implementations to interoperate, though the interop
tests and verified protocol baseline still use upstream v0.9.1. Both
upstream releases use wire protocol version 1, but v0.9.2 hasn't been run
through this project's interop harness. See [`docs/protocol/`](protocol/)
for the verified protocol baseline.
| Capability | irtt-rs | Upstream IRTT (v0.9.2) |
| --- | --- | --- |
| Wire protocol | Version 1, verified against upstream v0.9.1 | Version 1, unchanged from v0.9.1 |
| Finite probing | Default duration of 10s | Default duration of 1m |
| Continuous probing | `--continuous` or `--duration 0`, with optional per-target reconnection | Streaming mode (`-s`, added in v0.9.2) |
| Statistics in continuous mode | Optional count/min/max/mean/variance per session, with bounded IPDV tracking and no exact medians. Disable with `--no-stats` or omit the `stats` feature | No statistics; raw/event output only to avoid unbounded memory |
| Multiple targets | Labels and staggered/burst pacing in one invocation | One remote address per invocation |
| Terminal UI | Live graph and dashboard through `irtt-tui` | None |
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and fixed-field raw output; no CSV/TSV or column selection |
| Replay saved results | Not implemented | `report` subcommand for saved JSON results |
| Benchmarking and clock diagnostics | Not implemented | Benchmarking and timer/clock-diagnostic subcommands |
| Client payload and timing controls | No client-side payload fill or don't-fragment/wait-strategy/timer-algorithm controls | Client-side payload fill and controls for don't-fragment, wait strategy, and timer algorithm |
| Kernel timestamps | Best-effort preference on Linux for its own send/receive endpoint | Not implemented |
| Server listeners | Several listeners in one process, supervised by `ServerSet` | Multiple requested addresses through `ListenAndServe` |
| Server wildcard reply-source selection | Default on Linux/macOS/FreeBSD; other platforms refuse wildcard binds | Opt-in through `--set-src-ip`, with a documented per-packet allocation cost |
| Server default bind | Wildcard addresses for both families, subject to the platform restriction above | Wildcard addresses for both families; no documented platform restriction |
| Server payload fill policy | Honors the session's negotiated request, with a fixed fallback pattern; no options to restrict or override it | Options to restrict or override client fill requests |
| Syslog logging | Not implemented | Supported |
| Bundled service files | None | systemd and OpenRC |
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One Go package exposing both roles |
| Prebuilt release binaries | Published for six target triples | None published; install through `go install` or build manually |
## Black-box testing
`scripts/compare_irtt_clients.py` is a black-box comparison harness for running
upstream `irtt` and `irtt-rs` clients concurrently against the same server. It
@@ -8,7 +42,7 @@ summaries, and comparison JSON under `target/interop-comparisons` by default.
The harness does not inspect upstream source or tests. It only invokes the
upstream executable.
## Remote or Existing Local Server
### Remote or Existing Local Server
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
@@ -36,7 +70,7 @@ python3 scripts/compare_irtt_clients.py \
Remote comparisons are useful for smoke coverage, but Internet paths are noisy
and can make RTT/loss differences hard to interpret.
## Linux Netem Interop
### Linux Netem Interop
`scripts/run_netem_interop_ci.py` provides deterministic Linux CI coverage using
network namespaces, a veth pair, and `tc netem`. It is intended for GitHub
@@ -69,7 +103,7 @@ Netem qdiscs are applied on veth egress:
Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
a `finally` path.
### Scenarios
#### Scenarios
The CI harness implements:
@@ -86,7 +120,7 @@ both clients run in the same client namespace against the same server namespace,
relative one-way shape is still useful for diagnostics, but the CI pass/fail
policy is based on RTT and packet behavior.
### Pass/Fail Policy
#### Pass/Fail Policy
Deterministic scenarios fail when:
@@ -113,7 +147,7 @@ python3 scripts/run_netem_interop_ci.py \
--loss-rate-warning-pct 12
```
### Manual Linux Usage
#### Manual Linux Usage
On Linux:
+215 -162
View File
@@ -1,29 +1,31 @@
# irtt-client
## NAME
## Name
irtt-client - IRTT-compatible stream client
## SYNOPSIS
## Synopsis
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
`irtt-client` `--list-columns`
## DESCRIPTION
## 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.
Use `irtt-client` to probe one or more IRTT-compatible servers over UDP.
It prints measurement events as they arrive, and a final summary when the
run qualifies for one.
## TARGETS
## Targets
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. 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. Prefer explicit labels for
long-running dynamic target sets: the label is the stable target identity
across replacements.
Write targets as `[LABEL=]TARGET[@hmac=KEY]`. You can add a `LABEL=` prefix
to choose the name shown in output. If you leave it out, the client uses
the target string as its label. You can mix labeled and unlabeled targets
in the same command.
If you're changing targets during a long run, give them explicit labels.
The label identifies the same logical target when you replace its
configuration.
```sh
irtt-client host-a:2112
@@ -37,29 +39,31 @@ overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting.
Global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text keys or hexadecimal keys prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent.
Both the global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text
keys or hexadecimal keys prefixed with `0x`. For example, `secret` and
`0x736563726574` are equivalent.
Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first
`=` is how an unlabeled target or a label can contain that character. An `=` in an HMAC value does not need escaping. Positional
target arguments may contain a raw comma; stdin target sets have the additional
`\,` escape described below.
`=` lets an unlabeled target or a label contain that character. An `=` in
an HMAC value does not need escaping. Positional target arguments may
contain a raw comma; stdin target sets have the additional `\,` escape
described below.
Shells process unquoted backslashes and characters such as semicolons before
the client sees them. Single-quote a target when it contains parser escapes,
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
backslashes reach `irtt-client` unchanged.
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.
The client probes multiple targets concurrently. By default it staggers
them across the probe interval. If you want to send one probe to every
active target back-to-back once per interval, use `--pacing burst`.
Final summaries follow the command-line target order, regardless of which
target finished first or how their labels sort.
## ADDRESS FAMILY
## Address family
By default, each target uses the first address returned by the system resolver,
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
@@ -94,42 +98,58 @@ labels; choose another alias if one collides with an explicit label.
ordinary multi-target pacing and each sends its own probes, so dual-stack can
double the number of probes per interval compared with one target per hostname.
## LOCAL SOCKET CONFIGURATION
## Local socket configuration
The client supports configuring its local UDP sockets using the following options:
If you need to control where probes leave the machine, use these socket
options:
- `--bind-addr IP[:PORT]` binds sockets to the specified local IP address and optional port. If no port is specified, or the port is `0`, the operating system assigns an ephemeral port. IPv6 addresses with an explicit port must use `[ADDR]:PORT` syntax. The address family must match the target's resolved address family.
- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a network interface using `SO_BINDTODEVICE`.
- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark using `SO_MARK`, allowing traffic to be selected by policy routing or firewall rules. Accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit integer.
- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. The FIB is specified as a decimal unsigned 32-bit integer.
- `--bind-addr IP[:PORT]` binds sockets to a local IP address and optional
port. Leave out the port or use `0` to let the OS assign an ephemeral port.
IPv6 addresses with a port need `[ADDR]:PORT` syntax. The address family
must match the target's resolved address family.
- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a
network interface using `SO_BINDTODEVICE`.
- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark
using `SO_MARK`, so policy routing or firewall rules can select the
traffic. It accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit
integer.
- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. Supply
the FIB as a decimal unsigned 32-bit integer.
For example, bind IPv4 probes to a specific source address and interface, with a firewall mark:
For example, to bind IPv4 probes to a specific source address and interface
with a firewall mark:
```sh
irtt-client -4 --bind-addr 192.0.2.10 --bind-device wan0 --mark 1234 host.example
```
Setting a firewall mark or binding to a network device may require elevated privileges or appropriate capabilities. If the operating system rejects a socket option, socket setup fails.
Setting a firewall mark or binding to a network device may require elevated
privileges or appropriate capabilities. If the OS rejects a socket option,
socket setup fails.
A fixed local port may cause binding conflicts when multiple target sockets are created. Use an ephemeral port unless a specific source port is required. A family-specific bind address is also incompatible with targets using the other address family.
A fixed local port can cause bind conflicts when the client creates sockets
for multiple targets. Unless you need a particular source port, let the OS
assign one. A bind address for one family also cannot be used with targets
in the other family.
## DYNAMIC TARGET SETS FROM STANDARD INPUT
## Dynamic target sets from standard input
`--targets-stdin` is available only with continuous mode (`--continuous` or
`--duration 0`).
Positional targets remain an initial desired set, and may be omitted to
start empty:
`--targets-stdin` only works in continuous mode (`--continuous` or
`--duration 0`). Use it to change targets while the client is running.
Positional targets form the initial set. You can leave them out to start
with none:
~~~sh
irtt-client --duration 0 --targets-stdin
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
~~~
Each non-empty stdin record declares the complete desired target set, rather
than a delta. A later record replaces an earlier set: targets absent from it
are retired, unchanged target configurations retain their generation, and a
changed address or HMAC setting creates a fresh generation. Under transient
live-generation backpressure, only the latest unapplied desired set is kept.
Each non-empty stdin record gives the complete set of targets you want.
A new record replaces the previous set: the client retires targets you leave
out, keeps the generation for unchanged configurations, and starts a fresh
generation when an address or HMAC setting changes. If live-generation
capacity temporarily prevents an update, only the latest unapplied set is
kept.
The same address-family preparation applies to initial and stdin targets.
With `--dual-stack`, unchanged labels and endpoints retain their discovered
@@ -138,11 +158,12 @@ and later adding it again performs fresh discovery. New declarations are
prepared asynchronously while existing measurements continue. A preparation
error stops the stream gracefully without applying a partial set.
Commas frame targets in stdin records; escape a literal comma within one
target as `\,`. Target parsing inside each element is otherwise the same as
for positional arguments, including the other escapes above, `@hmac=KEY`, and
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
client is being controlled. For example, in one terminal:
Separate targets in stdin records with commas, and escape a comma inside a
target as `\,`. Each target otherwise uses the same syntax as positional
arguments, including the escapes above, `@hmac=KEY`, and `@hmac=`.
Keep the producer or pipe/FIFO writer open while controlling the client.
For example, run this in one terminal:
~~~sh
mkfifo /tmp/irtt-targets
@@ -150,8 +171,8 @@ irtt-client --duration 0 --targets-stdin --hmac default-secret \
< /tmp/irtt-targets
~~~
Then open the FIFO once from another terminal and keep that descriptor open
while publishing complete desired sets:
Then, in another terminal, open the FIFO once and keep that descriptor open
while sending complete target sets:
~~~sh
exec 3> /tmp/irtt-targets
@@ -161,19 +182,21 @@ printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
exec 3>&-
~~~
The exact record `[]` selects an empty desired set. Empty records are
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
or closure of the control plane and requests a graceful stop. A desired-state
revision that was read but has not yet been acknowledged as applied may be
discarded when EOF is observed; EOF does not wait for a capacity-rejected
revision to become applicable.
Send exactly `[]` to select an empty target set. The client ignores empty
records, but otherwise treats whitespace as part of the input without
trimming it.
Closing stdin sends EOF and asks the client to stop gracefully. An update
that has been read but not yet acknowledged as applied may be discarded
at that point. EOF does not wait for an update blocked by capacity to
become applicable.
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
desired set to 128 targets and retains at most 256 live target generations
while replacements drain. The 128-target limit applies after dual-stack
expansion: a hostname producing both families consumes two target slots.
## NEGOTIATION ONLY
## Negotiation only
`-n` / `--no-test` connects to each target, negotiates the requested parameters,
and exits without sending measurement probes. For example:
@@ -182,17 +205,18 @@ and exits without sending measurement probes. For example:
irtt-client --no-test host.example:2112 --duration 30s --interval 100ms
```
The `no_test_completed` event reports successful negotiation, including the
On successful negotiation, the `no_test_completed` event includes the
negotiated duration, interval, and packet length. Output format and column
selection work as usual; no measurement summary or statistics-memory warning
is printed. Negotiation failures use the normal target diagnostics and exit
policy: an uninterrupted run exits nonzero if no target succeeds.
selection work as usual. The client prints no measurement summary or
statistics-memory warning. Negotiation failures use the normal target
diagnostics and exit policy: an uninterrupted run exits nonzero if no
target succeeds.
Duration options still specify the duration to negotiate; even with
`--continuous` or `--duration 0`, the client exits after negotiation.
`--no-test` cannot be combined with `--targets-stdin`.
## FINITE VERSUS CONTINUOUS OPERATION
## Finite versus continuous operation
`--duration` and `--interval` accept fractional and composite durations, such as
`2.5s`, `1m30s`, and `1h`, as well as `ms`, `us` (or `µs`), and `ns` units
@@ -208,24 +232,25 @@ Omitting the option preserves the library default schedule.
`--duration` controls the run length:
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
after that long, and a final summary is printed for table output when
statistics are enabled (see [OUTPUT](#output) below — CSV, TSV, and
JSON Lines never print one).
- `--continuous` (or `--duration 0`) is **continuous**: the client runs until
interrupted (`Ctrl-C`). With statistics enabled and table output, a final
summary is printed only if the run was interrupted. Stdin-controlled mode
is the exception: EOF also requests a graceful stop and prints the final
retained summary. An uninterrupted
continuous run that exits for another reason does not get one.
- A nonzero duration (default `10s`) gives you a finite run. Probing stops
after that long. If statistics are enabled and you're using table
[output](#output), the client prints a final summary. CSV, TSV, and
JSON Lines never include a summary.
- With `--continuous` (or `--duration 0`), the client runs until interrupted
(`Ctrl-C`). With statistics and table output enabled, interrupting the run
prints a final summary. In stdin-controlled mode, EOF also stops the run
gracefully and prints the retained summary. A continuous run that exits
for another reason without being interrupted does not print 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.
The choice also affects which statistics are available and how much memory
a long run retains. See [Measurements and memory](#measurements-and-memory).
### Optional recovery and event-only runs
`--no-stats` disables statistics collection and final summaries while preserving individual measurement output. Statistics-dependent columns (such as IPDV) are unavailable. This runtime option does not remove the `irtt-stats` dependency.
If you only need individual measurements, use `--no-stats` to disable
statistics collection and final summaries. Columns that need statistics,
such as IPDV, are unavailable. The runtime flag still leaves the
`irtt-stats` dependency in the build.
To omit statistics at compile time, build with:
@@ -240,15 +265,31 @@ warnings are disabled, and IPDV columns retain their names with absent values
(`-` in tables, empty CSV/TSV fields, and JSON `null`). Measurement events and
protocol-level received statistics remain available.
`--reconnect` enables automatic recovery for individual targets in continuous mode (`--continuous` or `--duration 0`). Failed connections, closed sessions, and unresponsive servers are retried without interrupting other targets. Retries double the delay after each failed attempt, from 250 ms up to 10 seconds by default. A valid reply resets the delay to the initial value; each target backs off independently.
Use `--reconnect` to recover individual targets in continuous mode
(`--continuous` or `--duration 0`). The client retries failed connections,
closed sessions, and unresponsive servers while the other targets keep
running. By default, the delay doubles after each failed attempt, from
250 ms up to 10 seconds. A valid reply resets it to the initial delay.
Each target backs off independently.
`--reconnect-initial-delay DURATION` (default: `250ms`) and `--reconnect-max-delay DURATION` (default: `10s`) configure these delays. Both require `--reconnect`, must be positive and schedulable, and the maximum must be at least twice the initial delay. For example, `--reconnect-initial-delay 1s --reconnect-max-delay 5s` retries after `1s`, `2s`, `4s`, `5s`, then `5s` thereafter.
You can change the delays with `--reconnect-initial-delay DURATION`
(default: `250ms`) and `--reconnect-max-delay DURATION` (default: `10s`).
Both require `--reconnect` and must be positive and schedulable. The maximum
must be at least twice the initial delay. For example,
`--reconnect-initial-delay 1s --reconnect-max-delay 5s` retries after `1s`,
`2s`, `4s`, `5s`, then `5s` from there on.
`--reconnect-timeout DURATION` controls how long a session can go without valid replies before reconnection is attempted (default: `10s`). The timeout applies only while actively sending probes and should account for the probe interval and expected packet loss.
`--reconnect-timeout DURATION` sets how long a session can go without valid
replies before the client tries to reconnect (default: `10s`). It only
applies while actively sending probes, so allow for your probe interval and
expected packet loss.
**Statistics reset on reconnection**, so final summaries only reflect the latest session per target. A warning is emitted when reconnection is enabled with statistics collection. Use `--no-stats` to disable collection and suppress the warning.
Reconnecting resets statistics for that target, so its final summary only
covers the latest session. The client warns about this when reconnection
and statistics collection are both enabled. Use `--no-stats` to disable
collection and suppress the warning.
Example for continuous, multi-target CSV output without statistics:
For continuous CSV output from multiple targets without statistics:
```sh
irtt-client --continuous --reconnect --no-stats \
@@ -259,11 +300,11 @@ irtt-client --continuous --reconnect --no-stats \
reflector1 reflector2
```
## OUTPUT
## Output
### `--format FORMAT`
One of four event-row formats, default `table`:
Choose one of four event-row formats. The default is `table`:
- `table`: human-readable terminal output; the only format that prints a
final summary.
@@ -271,23 +312,36 @@ One of four event-row formats, default `table`:
- `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.
CSV, TSV, and JSON Lines include all columns by default for structured export;
`table` defaults to a compact column set and hides `echo_sent` events.
You can show those events by explicitly selecting them with `--events`
or by using custom columns.
### `--events EVENTS`
Choose which events to print with a comma-separated list, or repeat the
option to select more events. The available names are `session_started`,
`no_test_completed`, `session_closed`, `echo_sent`, `echo_reply`, `loss`,
`duplicate`, `late`, and `warning`. Without this option, the client prints
all events normally shown by the selected format.
For CSV output containing only echo replies:
```sh
irtt-client <server> --events echo_reply --format csv --header never --no-stats
```
### `--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:
Choose event-row columns with a comma-separated list, or use `default`
for the format's default set and `all` for every column. To see the
available columns, their meanings, and aliases, run:
```sh
irtt-client --list-columns
```
(`--list-columns` does not require a target.)
You don't need a target for `--list-columns`.
### `--header auto|always|never`
@@ -296,10 +350,9 @@ 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`.
Include extra fields in table output and final summaries. CSV, TSV, and
JSON Lines keep their default all-column set or your explicit `--columns`
selection regardless of `--verbose`.
### Examples
@@ -309,13 +362,13 @@ 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:
To print only effective RTT values in microseconds:
```sh
irtt-client <server> --format tsv --columns effective_rtt_us --header never
```
## MEASUREMENT FIELDS
## Measurement fields
Useful measurement columns:
@@ -336,95 +389,95 @@ Useful measurement columns:
### 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.
On Linux, the client prefers kernel timestamps for its own send/receive
endpoint when calculating one-way delays, where the platform and socket
support them. This reduces the effect of scheduling and queuing delay.
The `irtt-server` applet makes the same choice for its endpoint on Linux.
With another IRTT-compatible server, the client uses the wall-clock value
that server reports as-is.
## MEASUREMENTS AND MEMORY
If a kernel timestamp is unsupported or implausible, timing falls back to
userspace. This leaves `raw_rtt_us` unchanged. The
`adjusted_rtt_us`/`effective_rtt_us` values can still shift slightly, since
server processing time uses the same server receive endpoint.
The statistics retention below applies only when the `stats` feature is compiled
in and `--no-stats` is absent. Disabling collection keeps the client
classification bounds but allocates no statistics collectors.
## Measurements and memory
Finite and continuous modes make a different tradeoff between exact
statistics and bounded memory.
If the `stats` feature is compiled in and you haven't used `--no-stats`,
the client collects statistics as described here. Disabling collection
allocates no statistics collectors, though the client still keeps its
bounded reply-classification state.
**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.
In finite mode, the client keeps exact timing samples and adjacent-sequence
IPDV state so it can report exact medians in the final summary. Memory
grows with the number of probes sent, at roughly a few hundred bytes per
probe per target before allocator overhead. So if you're planning a long
finite run with a short interval, allow for that growth.
**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.
The client prints a warning on stderr when its estimate of retained state
crosses roughly 128 MiB, with stronger warnings past roughly 512 MiB and
1 GiB. These are useful planning figures, but they don't promise an RSS
ceiling: allocator and OS overhead are outside the estimate.
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.
In continuous mode (`--duration 0`), running statistics keep only
count/min/max/mean/variance, so exact medians are unavailable. The client
still keeps up to 4,096 sequences per target for adjacent-sequence IPDV
pairs. Statistics memory approaches a fixed limit per target as the run
continues.
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.
Reply classification has its own bounds, regardless of whether you collect
statistics. Each target has separate pending, timed-out, and
completed/duplicate sequence stores, each with a 4,096-entry limit. If the
pending store fills, the client fails and drains that target as
resource-exhausted. It does not wait for space to free up.
The timed-out and completed stores evict their oldest entries. A very late
reply can still be seen and counted after eviction, but without its retained
send state the client cannot calculate measurements such as RTT.
In continuous mode, retained state scales with the number of configured
targets and these fixed limits per target. The client writes output as
events arrive. The managed event stream has a 16,384-event capacity and can
drop events if the consumer falls behind. If that happens, the client
reports that output and 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
## Peer close and exit behavior
A peer closing a finite run is accepted as a terminal, successful outcome.
Without `--reconnect`, 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.
If a peer closes a finite run, the client treats that as successful
completion. In continuous mode without `--reconnect`, a peer closure ends
an uninterrupted run with a nonzero exit status so a process supervisor
can restart it. If you interrupted the run (`Ctrl-C`), peer closure is
never reported as an error in either mode.
With `--reconnect`, a desired target reopens automatically after peer closure.
Without recovery, `--targets-stdin` makes peer closure target-local: other
desired targets and the stdin controller continue running. Including that
target in a later complete set starts a fresh generation.
## EXIT STATUS
## 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`.
The client exits `0` on success, including normal finite completion and
interrupted runs in either mode. It exits nonzero if the managed driver
fails, an uninterrupted continuous run ends by peer closure, or no target
completes successfully in an uninterrupted run.
## SERVER FILL REQUEST
Interrupting the run prevents the last condition from counting as a failure.
For example, if you interrupt a continuous run whose only target never
opened, it 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.
hexadecimal pattern). See the Server fill section in `irtt-server(1)` for
what the server does with an unset or unparseable request.
## EXAMPLES
## Examples
```sh
irtt-client netperf-eu.bufferbloat.net:2112
@@ -433,7 +486,7 @@ irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
irtt-client host-a:2112 host-b:2112 --pacing burst
```
## SEE ALSO
## 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
+34 -36
View File
@@ -1,10 +1,10 @@
# irtt-rs
## NAME
## Name
irtt-rs - IRTT-compatible multi-applet dispatcher
## SYNOPSIS
## Synopsis
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
@@ -12,41 +12,40 @@ irtt-rs - IRTT-compatible multi-applet dispatcher
`irtt-rs` `server` [*OPTIONS*]
## DESCRIPTION
## 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.
`irtt-rs` lets you run the client, TUI, or server from one binary. You choose
the applet with a subcommand, or by the name you invoke or copy the binary
as. This is useful on a router where you want a single binary to deploy.
The applets behave the same as `irtt-client(1)`, `irtt-tui(1)`, and
`irtt-server(1)`.
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.
For example, `irtt-rs client ...` does the same thing as running `irtt-client ...` directly. See the
individual manuals for target syntax, options, output, and exit behavior.
## APPLET BINARY NAMES
## 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.
The build also produces dedicated `irtt-client`, `irtt-tui`, and
`irtt-server` binaries. These always run their own applet, even if you copy
or invoke them under a different name. They do not use argv0 to choose an
applet.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, 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.
The `irtt-rs` dispatcher checks argv0 first. If you invoke it through a
symlink or copy named `irtt-client`, `irtt-tui`, or `irtt-server`, it runs
that applet without a subcommand.
## FEATURE-DEPENDENT APPLET AVAILABILITY
Other names starting with `irtt-`, except `irtt-rs` itself, are rejected
before subcommands are parsed. So `irtt-custom server` fails with an unknown
applet error. If the name is `irtt-rs`, or doesn't start with `irtt-`, the
dispatcher parses the usual `client`/`tui`/`server` subcommands.
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.
## Feature-dependent applet availability
Each applet has a Cargo feature on the `irtt-rs` package: `client`,
`tui`, `server`. All three are enabled by default. If you disable one, the
build still produces `irtt-rs`, but you cannot run that applet. Trying it
through either a subcommand or a binary name tells you which feature to
rebuild with. Use `irtt-rs --help` to see which applets your build includes.
```sh
cargo install irtt-rs --locked --no-default-features --features client,stats
@@ -55,13 +54,12 @@ cargo install irtt-rs --locked --no-default-features --features client,stats
Use `--features client` to omit statistics. The default build includes the
`stats` feature, and `tui` always enables it.
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
## 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.
The `irtt-rs` dispatcher handles packaging and applet selection. For how to run
probes, use the dashboard, or serve requests, see `irtt-client(1)`,
`irtt-tui(1)`, and `irtt-server(1)`.
## SEE ALSO
## See also
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
+104 -104
View File
@@ -1,22 +1,22 @@
# irtt-server
## NAME
## Name
irtt-server - IRTT-compatible UDP server
## SYNOPSIS
## Synopsis
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
## DESCRIPTION
## 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.
`irtt-server` answers IRTT echo requests over UDP. It uses the reusable
`irtt-server` library for sessions and policy, with one current-thread
Tokio runtime running one or more listeners.
Run with no arguments for the ordinary case:
To start a server with the defaults, run:
```sh
irtt-server
@@ -28,34 +28,31 @@ 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.
The defaults work on platforms that support wildcard reply-source
selection (see [Wildcard binds](#wildcard-binds)). If you supply any
`--bind`, it replaces both defaults. For example,
`irtt-server --bind 127.0.0.1:2112` listens only on that 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.
If the host has no support for one address family, for example because IPv6
is disabled, the server uses the other default listener and tells you on
startup. Other bind failures still stop startup. This includes a port
already in use, permission denied, or a platform without safe wildcard
reply-source selection.
## OPTIONS
The address-family fallback only applies to the default pair. An explicit
`--bind` list always uses the 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.
Use `ADDR:PORT` to choose a local UDP address, for example `127.0.0.1:2112`
or `[::1]:2112`. Hostnames are not resolved.
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.
Repeat `--bind` to serve several addresses from one process, in the order
given. Every listener uses the same policy options, but keeps its own
sessions and tokens. 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.
@@ -63,71 +60,75 @@ different ports.
With no `--bind` at all, see [Description](#description) above for the
default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
served through that socket are IPv4-mapped and their replies cannot retain the
negotiated marking. Configure the ordinary paired `[::]:PORT` and
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
same-port pair, so each family uses the correct socket option. This is a
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
IPv6 listener.
If you need DSCP/traffic-class marking on replies to IPv4 peers, use care
with a lone dual-stack `[::]:PORT` listener. On Linux, those peers use
IPv4-mapped addresses and their replies cannot retain the negotiated
marking. Use paired `[::]:PORT` and `0.0.0.0:PORT` listeners on the same
port. The server makes the IPv6 listener IPv6-only for that pair, so each
family gets the correct socket option. A lone IPv6 listener keeps its usual
behavior; the server does not force `IPV6_V6ONLY` on it.
### `--hmac KEY`
Accepts a plain-text key or a hexadecimal key prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent. The `IRTT_SERVER_HMAC` environment variable uses the same syntax.
You can use a plain-text key or a hexadecimal key prefixed with `0x`.
For example, `secret` and `0x736563726574` are equivalent. The
`IRTT_SERVER_HMAC` environment variable accepts the same syntax.
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.
With a key configured, every request must carry a valid MAC and the server
authenticates every reply. Without a key, the server drops authenticated
requests. A key passed on the command line 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.
Set the maximum number of live sessions per listener. This limit applies
to each listener separately, so the process can have more sessions in total.
Once a listener's table is full, it silently drops opens that would create
a session and keeps the existing sessions. Zero refuses every open that
would create a session.
### `--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.
Set the maximum echo datagram size a session may negotiate, in bytes. The
server reduces longer requests during negotiation and refuses an open if
its mandatory fields would not fit. Incoming echo requests must fit the
same limit. Use this to bound resource use; it does not set the network 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.
Set the minimum send interval a session may negotiate. The server then caps
the negotiated interval at a quarter of the idle timeout, so a
`--min-interval` above that cap will be reduced.
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.
The session's reply allowance refills at the shorter of the configured
minimum and the negotiated interval. Usually this is the configured
minimum: a 10ms `--min-interval` with a 1s negotiated interval refills every
10ms.
If the idle-timeout cap reduces the negotiated interval below the configured
minimum, the allowance refills at that shorter interval too. This keeps the
server from enforcing a slower cadence than it told the client to use.
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.
Set how many echo replies a session can send before its allowance needs to
refill. Use `0` for no allowance, which rate-limits every
echo request regardless of the interval.
### `--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.
request. The timeout starts at open, and the server releases the session
without sending a close. If you use `0`, the session expires at the next
evaluation; zero does not 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.
Set the maximum test duration a session may negotiate. The server reduces
longer requests to this value and uses it for continuous requests too.
Leave the flag out if you don't want a maximum. You cannot set a maximum of
zero, since a negotiated duration of zero means continuous.
### `--timestamp-allowance MODE`
@@ -147,13 +148,12 @@ 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`.
## ENVIRONMENT
## Environment
Every option above can also be supplied through an environment variable, for
deployments that configure by environment rather than by command line
(containers, orchestrators, unit files). A flag given on the command line
overrides the corresponding variable entirely; an unset variable leaves the
built-in default in place.
You can also set each option through an environment variable, which is
useful for containers, orchestrators, and unit files. A command-line flag
overrides the corresponding variable entirely. If neither is set, the
server uses its built-in default.
| Variable | Option |
| --- | --- |
@@ -176,10 +176,10 @@ more than one address needs repeated `--bind` options.
an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled.
## OUTPUT
## Output
Each bound endpoint is printed on startup, once every listener is up, which
also resolves a port of `0`:
Once every listener is up, the server prints each bound endpoint. If you
requested port `0`, this shows which port was assigned:
```
irtt-server: listening on [::]:2112
@@ -188,21 +188,22 @@ irtt-server: listening on 0.0.0.0:2112
`Ctrl-C` stops the server gracefully.
## MULTIPLE LISTENERS
## 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.
The server manages listeners the same way whether you have one or several,
including when only one address from the default pair is available or you
supply a single `--bind`. Each listener uses the same policy options, but
keeps its own sessions and tokens. The `--max-sessions` limit applies to
each listener separately.
If any listener fails while running, the server shuts down the others too.
So a service configured for both address families won't carry on answering
on just one after a listener failure.
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
does.
## SERVER FILL
## Server fill
Echo replies fill their payload according to the negotiated `ServerFill`
mode, which a client requests with `irtt-client`'s `--sfill` (see
@@ -212,11 +213,11 @@ mode, which a client requests with `irtt-client`'s `--sfill` (see
- `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`).
The server honors every valid descriptor. If it cannot parse the descriptor,
or the client sends no fill preference, it uses `pattern:69727474`: the
repeating bytes `69 72 74 74` (`irtt`).
## WILDCARD BINDS
## 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
@@ -224,14 +225,13 @@ 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.
Reply-source selection is supported on Linux, macOS, and FreeBSD. On other
platforms, the server refuses wildcard binds because the routing table
could select a source address the client would discard. This also means
running without arguments fails on those platforms, with an error asking
you to supply an explicit `--bind`.
## EXAMPLES
## Examples
Run with the default wildcard listeners on the standard port:
@@ -283,19 +283,19 @@ Require authentication:
irtt-server --bind 192.0.2.10:2112 --hmac secret
```
## EXIT STATUS
## Exit status
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
fails while running.
## LIMITATIONS
## Limitations
- No hostname resolution, interface expansion, daemonization, or
configuration file — binds are explicit addresses only.
configuration file. Binds must use explicit addresses.
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
FreeBSD; such platforms must bind explicit addresses.
## SEE ALSO
## See also
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
the full option list.
+84 -77
View File
@@ -1,25 +1,24 @@
# irtt-tui
## NAME
## Name
irtt-tui - IRTT-compatible terminal UI client
## SYNOPSIS
## Synopsis
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
## DESCRIPTION
## 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.
`irtt-tui` gives you a live graph and status dashboard using the same probing
engine as `irtt-client(1)`. You need at least one target to start it.
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 client configuration flags
Targets and pacing work the same as in `irtt-client(1)`. See that manual
for `[LABEL=]TARGET` syntax, multiple targets, and the
`--pacing staggered|burst` option. The configuration flags
(`--interval`, `--timeouts`, `--length`, `--hmac`, `--clock`, `--tstamp`,
`--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are shared with `irtt-client` as
well; consult that manual for their meaning.
`--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are also shared with
`irtt-client`, so the same descriptions apply.
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
@@ -36,11 +35,12 @@ The second command creates `cov/v4` and/or `cov/v6` according to the system
resolver's initial results, letting you compare the two paths directly.
See the client manual's address-family section for discovery errors and labels.
## CONTINUOUS DEFAULT
## 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. Both `--duration` and `--interval` use the
The TUI defaults to continuous mode (`--duration 0`) and runs until you
quit. If you're used to `irtt-client`, which defaults to a finite run,
keep that difference in mind. To set a finite run, pass an explicit
`--duration`. Both `--duration` and `--interval` use the
[client duration syntax](irtt-client.md#finite-versus-continuous-operation),
including fractional values (`2.5s`), composite values (`1m30s`), and units
from nanoseconds (`ns`) through hours (`h`):
@@ -49,26 +49,29 @@ from nanoseconds (`ns`) through hours (`h`):
irtt-tui <server> --duration 30s
```
Continuous runs reconnect after target-local failures, peer closure, or a
session completing because the server restricted its duration. After a
1.5-second delay, one coalesced retry reopens completed targets while healthy
sessions continue undisturbed. Even if every target fails, the TUI stays alive
until you quit. Explicit stops, removed or replaced targets, and no-test
completion do not trigger retries. Quitting cancels pending retry work.
In continuous mode, the TUI reconnects after a target fails, the peer closes
the session, or the session ends because the server limited its duration.
After a 1.5-second delay, it groups completed targets into one retry while
healthy sessions keep running. Even if every target fails, the TUI stays
open until you quit.
Explicit stops, removed or replaced targets, and no-test completion do not
trigger retries. Quitting cancels any pending retry work.
With `--dual-stack`, a failed family reconnects independently while its healthy
sibling continues. Reconnect resolves the original hostname again within the
target's family. It does not discover additional families or pin the address
returned by initial discovery.
Each reconnect keeps the same logical target row. Statistics reset when a new
Reconnecting keeps the target in the same row. Statistics reset when a new
session generation is accepted, including attempts that fail before opening.
Retained graph history and the age of the last actual reply remain; the graph
leaves a gap between generations instead of connecting their samples.
The TUI keeps graph history and the age of the last actual reply, with a
gap in the graph between generations.
## DASHBOARD
## Dashboard
One dashboard is used for every target count and terminal size. It shows:
The dashboard uses the same layout for any target count and terminal size.
It shows:
- Run status, elapsed time, and finite duration or continuous mode.
- The selected target's label and remote address, plus latest signed
@@ -78,30 +81,32 @@ One dashboard is used for every target count and terminal size. It shows:
- A graph overlaying all targets, with matching target colors and a visible
live/history indicator and window size.
An active session with no primary replies shows `waiting`. Reply age makes a
stale RTT distinguishable from a fresh measurement. Loss follows the shared
statistics collector: outstanding sends can temporarily contribute to the
cumulative loss estimate. Jitter is `-` until an IPDV pair is available.
An active session shows `waiting` until it gets a primary reply. Check the
reply age to see how fresh the displayed RTT is. The loss estimate comes
from the shared statistics collector, so outstanding sends can temporarily
count towards cumulative loss. Jitter stays `-` until an IPDV pair is
available.
`Tab` / `Shift-Tab` selects a target, bringing its row into view when necessary.
Up to six target rows are shown; smaller terminals show fewer. Selecting a target
changes the identification, one-way summary and details; the graph continues to
compare every target.
Use `Tab` / `Shift-Tab` to select a target and bring its row into view.
The dashboard shows up to six target rows, with fewer on smaller terminals.
Selecting a target changes the identification, one-way summary, and details.
The graph still compares every target.
`d` opens a details sheet in the graph's space. It contains the selected target's
session and negotiation, packet counters, progress, latest timing sample, full
timing statistics, warnings, and recent events from all targets. Scroll it with
the arrows or page keys. Long lines and timing columns can be read with horizontal
scrolling, including full labels and remote addresses that do not fit in the
header. `d` or `Esc` returns to the graph, preserving its metric and viewport.
The former large/compact Dashboard screens have been replaced by this sheet;
`g` remains an alias for `d`.
Press `d` to open details in the graph's space. You'll find the selected
target's session and negotiation, packet counters, progress, latest timing
sample, full timing statistics, and warnings, along with recent events
from all targets.
The minimum terminal size is 56 columns by 18 rows. Below that size the UI asks
for a resize and continues acquiring measurements. Resize and important status
redraws still work while the display is paused.
Scroll with the arrows or page keys. You can scroll horizontally to read
long lines and timing columns, including labels and remote addresses that
don't fit in the header. Press `d` or `Esc` to return to the graph with its
metric and viewport preserved. You can also use `g` as an alias for `d`.
## CONTROLS
You need a terminal at least 56 columns wide and 18 rows tall. Below that
size, the TUI asks you to resize while continuing to collect measurements.
Resizing and important status redraws still work when the display is paused.
## Controls
| Key | Action |
| --- | --- |
@@ -126,50 +131,52 @@ Signed effective/adjusted RTT and one-way values are preserved. Graph windows
range from 5 seconds to 24 hours and default to one minute. The footer shows
controls appropriate to the currently open graph or details sheet.
## RETAINED HISTORY
## Retained history
The TUI keeps its own bounded presentation state on top of the statistics
retention described in `irtt-client(1)`:
Along with the statistics described in `irtt-client(1)`, the TUI keeps
some history for the display:
- Up to 500,000 graph samples per target.
- Up to 80 recent status/log messages.
At 500,000 replies per target, retention is approximately 13h53m20s at
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph
samples store both clocks, six graph-ready values, and a session-boundary
flag; latest details keep the full typed timing values and sequence number.
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target
at the cap, excluding statistics and temporary rendering buffers. Histories
grow on demand and reserve exactly to the cap on their final growth step;
they do not preallocate the full history for every target at startup.
At 500,000 replies per target, you get approximately 13h53m20s of history
with 100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Each graph
sample stores both clocks, six values ready for graphing, and a flag marking
session boundaries. The latest details keep the full typed timing values
and sequence number.
`r` clears graph history for all targets and returns the viewport to
live; it does not change the 500,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.
On 64-bit macOS, each retained sample takes 88 bytes, giving about 42 MiB
per target at the cap. Statistics and temporary rendering buffers use
additional memory. History grows as needed and reserves exactly to the
cap on its final growth step, so you don't pay for the full history of
every target at startup.
A **finite** `--duration` TUI run retains exact statistics samples exactly as
`irtt-client(1)` does in finite mode, so its retained state grows with the
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
prints **no** memory warning when that estimate gets large: it owns the
alternate screen, and a stderr warning there would be invisible or would
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
Press `r` to clear graph history for all targets and return to the live
view. This leaves the 500,000-sample cap unchanged. Display history is kept
in addition to the client's statistics (see Measurements and memory in
`irtt-client(1)`). During a long continuous TUI session, whichever uses more
memory for your target count will dominate the total.
## EXIT BEHAVIOR
If you set a finite `--duration`, the TUI retains exact statistics samples
just as `irtt-client(1)` does in finite mode. Memory then grows with the
probe count. The TUI does not print the memory warnings you get from
`irtt-client`, since a stderr warning on the alternate screen could be
invisible or corrupt the display. For a long finite run, plan memory using
the figures in `irtt-client(1)` under Measurements and memory, or use the
continuous default.
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. In continuous
mode, peer closure and target failures trigger reconnect rather than ending the
run. A managed driver failure or an unexpected reconnect-update failure still
ends the TUI with an error.
## Exit behavior
Quitting with `q` or `Ctrl-C` counts as an interrupted, successful exit.
In continuous mode, the TUI reconnects after peer closure and target failures.
A managed driver failure or an unexpected reconnect-update failure still
ends the run with an error.
Finite runs do not reconnect. They complete after their target sessions end;
a peer closure is a normal session ending, while a run whose targets all fail
exits with an error.
## EXAMPLES
## Examples
```sh
irtt-tui host.example
@@ -178,7 +185,7 @@ irtt-tui eu=host-a.example us=host-b.example --pacing burst
irtt-tui host.example --duration 30s
```
## SEE ALSO
## See also
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.