Compare commits

...
Author SHA1 Message Date
Nils Andreas Svee 641f8e18c3 chore(release): prepare irtt-rs 0.10.2
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 28s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m16s
Supply chain / cargo-deny advisories (push) Successful in 16s
Supply chain / actionlint (push) Failing after 2s
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 (armv7-musleabi) (push) Successful in 4m25s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m36s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m22s
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
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m12s
Netem Interop / netem interop (push) Successful in 1m18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m29s
Build binaries / Build (i686-musl) (push) Successful in 4m35s
Build binaries / Build (mips-musl) (push) Successful in 4m24s
Build binaries / Build (mipsel-musl) (push) Successful in 4m39s
2026-10-28 15:30:59 +07:00
Nils Andreas Svee d2d5f87e91 feat(client): filter output by selected event types
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m17s
Supply chain / cargo-deny advisories (push) Successful in 19s
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 (armv7-musleabi) (push) Successful in 4m38s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m52s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m33s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m44s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m47s
Build binaries / Build (x86_64-musl) (push) Successful in 5m19s
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Netem Interop / netem interop (push) Successful in 1m19s
Supply chain / cargo-deny bans licenses sources (push) Successful in 18s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m45s
Build binaries / Build (i686-musl) (push) Successful in 4m54s
Build binaries / Build (mips-musl) (push) Successful in 4m35s
Build binaries / Build (mipsel-musl) (push) Successful in 4m45s
2026-10-28 15:28:26 +07:00
Nils Andreas Svee 5b5e38a059 chore(release): prepare irtt-rs 0.10.1
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 36s
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 / actionlint (push) Failing after 4s
Semver / cargo-semver-checks (push) Successful in 58s
Publish irtt-server image / publish (push) Successful in 1m6s
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 (mips64-muslabi64) (push) Successful in 4m34s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m26s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m25s
Build binaries / Build (x86_64-musl) (push) Successful in 4m25s
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 18s
Netem Interop / netem interop (push) Successful in 1m20s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m46s
Build binaries / Build (mips-musl) (push) Successful in 4m37s
Build binaries / Build (mipsel-musl) (push) Successful in 4m21s
2026-10-28 12:30:02 +07:00
Nils Andreas Svee 97bd903769 chore: improve documentation and README
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 33s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Supply chain / cargo-deny advisories (push) Successful in 18s
Supply chain / actionlint (push) Failing after 4s
Build binaries / Build (arm-musleabi) (push) Successful in 4m58s
Build binaries / Build (aarch64-musl) (push) Successful in 5m6s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m33s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m57s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m35s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m54s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m36s
Build binaries / Build (x86_64-musl) (push) Successful in 4m40s
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m7s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m40s
Netem Interop / netem interop (push) Successful in 1m18s
Supply chain / cargo-deny bans licenses sources (push) Successful in 17s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m38s
Build binaries / Build (i686-musl) (push) Successful in 5m1s
Build binaries / Build (mips-musl) (push) Successful in 4m42s
Build binaries / Build (mipsel-musl) (push) Successful in 4m54s
2026-10-28 12:17:48 +07:00
Nils Andreas Svee f02f9c0714 chore(deps): update unicode-width to 0.2.3
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 30s
Rust CI / cargo doc (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Supply chain / cargo-deny advisories (push) Successful in 18s
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 (armv7-musleabi) (push) Successful in 5m4s
Build binaries / Build (armv7-musleabihf) (push) Successful in 5m33s
Build binaries / Build (mips64-muslabi64) (push) Successful in 5m15s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m29s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m54s
Build binaries / Build (x86_64-musl) (push) Successful in 5m7s
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m9s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m40s
Netem Interop / netem interop (push) Successful in 1m24s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Build binaries / Build (arm-musleabihf) (push) Successful in 5m9s
Build binaries / Build (i686-musl) (push) Successful in 5m37s
Build binaries / Build (mips-musl) (push) Successful in 5m18s
Build binaries / Build (mipsel-musl) (push) Successful in 4m47s
2026-10-28 11:26:13 +07:00
Nils Andreas Svee 5957e395a2 Merge branch 'fix/stdout-cancellation'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m21s
Supply chain / cargo-deny advisories (push) Successful in 16s
Supply chain / actionlint (push) Failing after 3s
Build binaries / Build (arm-musleabi) (push) Successful in 5m25s
Build binaries / Build (aarch64-musl) (push) Successful in 5m30s
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 (mips64el-muslabi64) (push) Successful in 4m48s
Build binaries / Build (riscv64gc-musl) (push) Successful in 5m6s
Build binaries / Build (x86_64-musl) (push) Successful in 5m10s
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 test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m39s
Netem Interop / netem interop (push) Successful in 1m23s
Supply chain / cargo-deny bans licenses sources (push) Successful in 19s
Build binaries / Build (arm-musleabihf) (push) Successful in 5m19s
Build binaries / Build (mips-musl) (push) Successful in 5m16s
Build binaries / Build (mipsel-musl) (push) Successful in 4m48s
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
Nils Andreas Svee efa1313e95 chore(release): prepare client 0.7.0, stats 0.6.0 and app 0.10.0
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 33s
Rust CI / cargo doc (push) Successful in 26s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m18s
Supply chain / cargo-deny advisories (push) Successful in 17s
Supply chain / actionlint (push) Failing after 4s
Build binaries / Build (arm-musleabi) (push) Successful in 4m46s
Build binaries / Build (aarch64-musl) (push) Successful in 4m54s
Semver / cargo-semver-checks (push) Successful in 48s
Publish irtt-server image / publish (push) Successful in 1m5s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m1s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m1s
Build binaries / Build (mips64-muslabi64) (push) Successful in 3m44s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m5s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m17s
Build binaries / Build (x86_64-musl) (push) Successful in 4m20s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 23s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m41s
Netem Interop / netem interop (push) Successful in 1m22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Build binaries / Build (arm-musleabihf) (push) Successful in 3m54s
Build binaries / Build (i686-musl) (push) Successful in 3m57s
Build binaries / Build (mips-musl) (push) Successful in 3m47s
Build binaries / Build (mipsel-musl) (push) Successful in 3m52s
2026-10-27 11:30:08 +07:00
renovate[bot] 8b7e793d50 chore(deps): maintain cargo lockfile 2026-10-27 11:25:47 +07:00
renovate[bot] e16336e51b chore(deps): update github actions 2026-10-27 04:23:34 +00:00
Nils Andreas Svee 04e1f11f3a feat(client): make reconnect exponential backoff configurable
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 26s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m18s
Supply chain / cargo-deny advisories (push) Successful in 19s
Supply chain / actionlint (push) Successful in 4s
Build binaries / Build (arm-musleabi) (push) Successful in 4m52s
Build binaries / Build (aarch64-musl) (push) Successful in 4m59s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m20s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m30s
Build binaries / Build (i686-musl) (push) Successful in 4m27s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m15s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m15s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m39s
Build binaries / Build (x86_64-musl) (push) Successful in 4m40s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 24s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m39s
Netem Interop / netem interop (push) Successful in 1m22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 15s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m17s
Build binaries / Build (mips-musl) (push) Successful in 4m14s
Build binaries / Build (mipsel-musl) (push) Successful in 4m14s
2026-10-27 01:26:20 +07:00
Nils Andreas Svee 3344407706 ci(interop-netem): adopt nsenter for improved network namespace operations
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 29s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m20s
Supply chain / cargo-deny advisories (push) Successful in 18s
Supply chain / actionlint (push) Successful in 2s
Build binaries / Build (arm-musleabi) (push) Successful in 4m37s
Build binaries / Build (aarch64-musl) (push) Successful in 4m50s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m7s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m19s
Build binaries / Build (i686-musl) (push) Successful in 4m24s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m4s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m1s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m13s
Build binaries / Build (x86_64-musl) (push) Successful in 4m8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 28s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m12s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Netem Interop / netem interop (push) Successful in 1m20s
Supply chain / cargo-deny bans licenses sources (push) Successful in 17s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m9s
Build binaries / Build (mips-musl) (push) Successful in 3m58s
Build binaries / Build (mipsel-musl) (push) Successful in 4m2s
2026-10-26 16:48:41 +07:00
Nils Andreas Svee 492f33408c chore(deps): Update ctutils and tokio dependencies
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 36s
Rust CI / cargo doc (push) Successful in 26s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m19s
Supply chain / cargo-deny advisories (push) Successful in 15s
Supply chain / actionlint (push) Successful in 3s
Build binaries / Build (arm-musleabi) (push) Successful in 4m36s
Build binaries / Build (aarch64-musl) (push) Successful in 4m50s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m1s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m15s
Build binaries / Build (i686-musl) (push) Successful in 4m18s
Build binaries / Build (mips64-muslabi64) (push) Successful in 3m56s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m6s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m25s
Build binaries / Build (x86_64-musl) (push) Successful in 4m26s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 29s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m7s
Netem Interop / netem interop (push) Failing after 28s
Supply chain / cargo-deny bans licenses sources (push) Successful in 18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m0s
Build binaries / Build (mips-musl) (push) Successful in 3m57s
Build binaries / Build (mipsel-musl) (push) Successful in 3m54s
2026-10-26 16:05:34 +07:00
Nils Andreas Svee f290e3a3f0 refactor(cargo): Remove redundant homepage fields from Cargo.toml 2026-10-26 16:05:34 +07:00
Nils Andreas Svee f96dc71beb ci(workflows): Streamline apt-get commands in CI 2026-10-26 16:05:34 +07:00
Nils Andreas Svee e7caf7ec9e ci(supply-chain): Install Rust toolchain in supply-chain workflow 2026-10-26 16:05:34 +07:00
Nils Andreas Svee da433f9902 ci(interop-netem): Enhance netem workflow with Go and pre-flight checks 2026-10-26 16:05:34 +07:00
Nils Andreas Svee 9bb3fd63d8 feat(app): make statistics collection optional at compile time
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 6s
Rust CI / cargo doc (push) Successful in 27s
Supply chain / cargo-deny advisories (push) Failing after 3s
Supply chain / actionlint (push) Successful in 4s
Build binaries / Build (arm-musleabi) (push) Successful in 4m31s
Build binaries / Build (aarch64-musl) (push) Successful in 4m41s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m2s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m26s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m19s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m8s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m11s
Build binaries / Build (x86_64-musl) (push) Successful in 4m17s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 29s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Netem Interop / netem interop (push) Failing after 17s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m7s
Build binaries / Build (i686-musl) (push) Successful in 4m20s
Build binaries / Build (mips-musl) (push) Successful in 4m18s
Build binaries / Build (mipsel-musl) (push) Successful in 4m7s
Decouple statistics collection into a dedicated feature to enable lighter client-only builds.
2026-10-26 15:35:13 +07:00
Nils Andreas Svee ea8734a7aa ci(build): Integrate nightly size experiments for continuous evaluation 2026-10-26 15:35:08 +07:00
Nils Andreas Svee f08d40bfe9 ci(build): introduce experimental build mode for compiler evaluation
Add a workflow dispatch option to run builds with various compiler flags to investigate binary size and panic behavior.
2026-10-26 14:48:13 +07:00
Nils Andreas Svee 583001f091 fix(app): remove release profile from member crate 2026-10-26 13:46:15 +07:00
Nils Andreas Svee 7aca022e11 ci(build): add Gitea workflow to cross-compile static binaries and report size metrics
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo doc (push) Successful in 24s
Build binaries / Build (aarch64-musl) (push) Successful in 1m55s
Build binaries / Build (arm-musleabi) (push) Successful in 1m54s
Supply chain / cargo-deny advisories (push) Failing after 5s
Supply chain / actionlint (push) Successful in 3s
Build binaries / Build (armv7-musleabi) (push) Successful in 1m38s
Build binaries / Build (armv7-musleabihf) (push) Successful in 1m38s
Build binaries / Build (mips64-muslabi64) (push) Successful in 2m11s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 2m19s
Build binaries / Build (x86_64-musl) (push) Successful in 1m52s
Build binaries / Build (riscv64gc-musl) (push) Successful in 2m0s
Rust CI / cargo fmt (push) Failing after 15s
Rust CI / cargo clippy (push) Successful in 30s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 22s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m10s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Failing after 13s
Supply chain / cargo-deny bans licenses sources (push) Failing after 3s
Build binaries / Build (arm-musleabihf) (push) Successful in 1m40s
Build binaries / Build (i686-musl) (push) Successful in 1m39s
Build binaries / Build (mips-musl) (push) Successful in 2m14s
Build binaries / Build (mipsel-musl) (push) Successful in 2m14s
2026-10-26 13:17:20 +07:00
Nils Andreas Svee 3d2554cf2b feat(client): implement automatic session reconnection and disable statistics
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Failing after 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 36s
Rust CI / cargo clippy (push) Successful in 37s
Netem Interop / netem interop (push) Failing after 14s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m19s
2026-10-26 12:16:54 +07:00
renovate Bot e52c03f3b8 fix(deps): update rust crate tokio to 1.54.1
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 29s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 35s
Netem Interop / netem interop (push) Failing after 16s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
2026-10-26 12:03:50 +07:00
renovate Bot 643e3b8b76 chore(deps): update github actions 2026-10-26 12:03:50 +07:00
Nils Andreas Svee 52f2a07490 fix(client): replace deferred open stop with immediate cancellation
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 30s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo clippy (push) Successful in 35s
Netem Interop / netem interop (push) Failing after 17s
Supply chain / cargo-deny bans licenses sources (push) Failing after 3s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
2026-10-21 23:50:14 +07:00
Nils Andreas Svee d2a7acdec4 feat(client): add --no-test option for negotiation-only runs 2026-10-21 15:35:21 +07:00
Nils Andreas Svee 56fe30d0e2 fix(app/client): remove redundant SocketConfig::default() call 2026-10-21 14:59:18 +07:00
Nils Andreas Svee d4c89ee22c feat(client): add --timeouts option for session-opening retry schedule 2026-10-21 14:56:04 +07:00
Nils Andreas Svee 5481009fe3 feat(cli): enhance --version output with component versions 2026-10-21 14:42:49 +07:00
Nils Andreas Svee 7d640793b6 feat(cli): introduce --version flags and a dedicated --continuous option with new short aliases 2026-10-21 13:42:47 +07:00
Nils Andreas Svee 9e8559d713 feat(cli): enhance duration parsing with humantime
Allow fractional and composite duration inputs for `--duration` and `--interval` options.
2026-10-21 12:43:07 +07:00
Nils Andreas Svee 06fff94505 fix(stats): preserve signed per-packet IPDV in output 2026-10-21 12:19:43 +07:00
Nils Andreas Svee d7d3f58716 fix(stats): restore directional IPDV for midpoint timestamps 2026-10-21 12:07:41 +07:00
renovate Bot 2a82951572 chore(deps): maintain cargo lockfile
Rust CI / cargo clippy (pull_request) Failing after 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Successful in 49s
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Failing after 10s
Rust CI / cargo doc (pull_request) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 33s
Supply chain / actionlint (pull_request) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Supply chain / cargo-deny advisories (push) Failing after 3s
Rust CI / cargo doc (push) Successful in 27s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo fmt (pull_request) Successful in 29s
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Failing after 9s
Supply chain / cargo-deny advisories (pull_request) Failing after 3s
Netem Interop / netem interop (pull_request) Failing after 14s
Supply chain / cargo-deny bans licenses sources (pull_request) Failing after 3s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Successful in 1m16s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Failing after 33s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Netem Interop / netem interop (push) Failing after 13s
Supply chain / cargo-deny bans licenses sources (push) Failing after 3s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m20s
2026-10-20 19:08:32 +00:00
Nils Andreas Svee 7da8d2141c chore(release): irtt-rs 0.9.0
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / actionlint (push) Successful in 5s
Semver / cargo-semver-checks (push) Successful in 55s
Publish irtt-server image / publish (push) Successful in 1m3s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Failing after 35s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 38s
Netem Interop / netem interop (push) Failing after 14s
Supply chain / cargo-deny bans licenses sources (push) Failing after 5s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m22s
2026-10-21 01:31:32 +07:00
renovate Bot bdb8c13d7d chore(deps): update github actions 2026-10-21 01:24:56 +07:00
renovate Bot a04ea5727b chore(deps): maintain cargo lockfile 2026-10-21 01:24:56 +07:00
renovate Bot ab5f1ac807 fix(deps): update rust crate arbitrary to 1.5.0 2026-10-21 01:24:01 +07:00
renovate Bot 45b0c8549d chore(deps): update rust crate proptest to 1.12.0 2026-10-21 01:24:00 +07:00
Nils Andreas Svee 18a8e307fb feat(app): allow hexadecimal HMAC key specification and redact keys from debug output 2026-10-21 01:19:59 +07:00
Nils Andreas Svee ab241c4a2b feat(app/client): expose socket bind options 2026-10-21 00:33:46 +07:00
64 changed files with 2473 additions and 988 deletions

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
name: Build binaries
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Measure before stripping; package stripped copies separately.
CARGO_PROFILE_RELEASE_STRIP: none
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 120
env:
TARGET: ${{ matrix.target }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
matrix:
target:
- x86_64-musl
- i686-musl
- arm-musleabi
- arm-musleabihf
- armv7-musleabi
- armv7-musleabihf
- aarch64-musl
- mips-musl
- mipsel-musl
- mips64-muslabi64
- mips64el-muslabi64
- riscv64gc-musl
steps:
- name: Checkout
uses: builtin:checkout
- name: Configure Rust directories
# Cross remote does not traverse hidden directories under /root.
run: |
echo "CARGO_HOME=$RUNNER_TEMP/cargo" >> "$GITHUB_ENV"
echo "RUSTUP_HOME=$RUNNER_TEMP/rustup" >> "$GITHUB_ENV"
echo "$RUNNER_TEMP/cargo/bin" >> "$GITHUB_PATH"
- name: Install standard Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
toolchain: ${{ startsWith(matrix.target, 'mips') && 'nightly' || 'stable' }}
cache: false
build-warnings: warn
- name: Install nightly toolchain and standard library sources
run: |
rustup toolchain install nightly \
--profile minimal \
--component rust-src
- name: Install cross and binary tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends binutils llvm
cargo install --git https://github.com/cross-rs/cross --locked cross
echo "RUST_TARGET=${TARGET%-*}-unknown-linux-${TARGET#*-}" >> "$GITHUB_ENV"
- name: Detect statistics feature
run: |
if grep -Eq '^[[:space:]]*stats[[:space:]]*=' crates/irtt-app/Cargo.toml; then
echo "HAS_STATS_FEATURE=true" >> "$GITHUB_ENV"
else
echo "HAS_STATS_FEATURE=false" >> "$GITHUB_ENV"
fi
- name: Build, verify, package, and compare
env:
# MIPS build-std needs cross GCC's CRT objects.
RUSTFLAGS: ${{ startsWith(matrix.target, 'mips') && '-C target-feature=+crt-static -C link-self-contained=no' || '-C target-feature=+crt-static' }}
CROSS_REMOTE: "true"
CROSS_REMOTE_COPY_FULL_TARGET_DIR: ${{ startsWith(matrix.target, 'mips') && 'true' || 'false' }}
run: |
set -euo pipefail
declare -A raw_size
declare -A stripped_size
declare -A compressed_size
mkdir -p dist
# MIPS requires nightly build-std via Cross.toml.
# Every other standard build uses stable.
standard_toolchain=stable
if [[ "$TARGET" == mips* ]]; then
standard_toolchain=nightly
fi
build_variant() {
local variant="$1"
local toolchain="$2"
local features="$3"
shift 3
local binary="target/${RUST_TARGET}/release/irtt-client"
local directory="dist/${variant}"
local artifact="dist/irtt-client-${TARGET}-${variant}.tar.gz"
echo "::group::Build ${variant} (${toolchain}, ${features})"
cross "+${toolchain}" build \
--release \
--locked \
--target "$RUST_TARGET" \
-p irtt-rs \
--bin irtt-client \
--no-default-features \
--features "$features" \
"$@"
# Reject dynamically linked executables.
readelf --program-headers "$binary" > /tmp/program-headers.txt
readelf --dynamic "$binary" > /tmp/dynamic-section.txt
if grep -q INTERP /tmp/program-headers.txt ||
grep -q NEEDED /tmp/dynamic-section.txt; then
echo "Binary is not statically linked: ${variant}" >&2
exit 1
fi
mkdir -p "$directory"
cp "$binary" "$directory/irtt-client"
raw_size[$variant]=$(stat -c %s "$binary")
llvm-strip --strip-all "$directory/irtt-client"
stripped_size[$variant]=$(
stat -c %s "$directory/irtt-client"
)
# Each archive contains an executable named irtt-client.
tar -czf "$artifact" -C "$directory" irtt-client
compressed_size[$variant]=$(stat -c %s "$artifact")
echo "::endgroup::"
}
human() {
numfmt --to=iec-i --suffix=B --format='%.1f' "$1"
}
# Negative means smaller than the reference binary.
delta() {
awk -v base="$1" -v current="$2" 'BEGIN {
if (base == 0) {
printf "N/A"
} else {
printf "%+.1f%%", 100 * (current - base) / base
}
}'
}
print_row() {
local variant="$1"
local label="$2"
local baseline="$3"
local comparison
if [[ "$variant" == "$baseline" ]]; then
comparison="Baseline"
else
comparison=$(delta \
"${compressed_size[$baseline]}" \
"${compressed_size[$variant]}")
fi
printf '| %s | %s | %s | %s | %s |\n' \
"$label" \
"$(human "${raw_size[$variant]}")" \
"$(human "${stripped_size[$variant]}")" \
"$(human "${compressed_size[$variant]}")" \
"$comparison"
}
# ---------------------------------------------------------
# Standard builds: stable except MIPS.
# ---------------------------------------------------------
full_features=client
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
full_features="client,stats"
fi
build_variant full "$standard_toolchain" "$full_features"
standard_baseline=full
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
build_variant minimal "$standard_toolchain" client
standard_baseline=minimal
fi
# ---------------------------------------------------------
# Nightly baseline: identical compiler for all experiments.
#
# MIPS already uses nightly for standard builds, so reuse
# those binaries rather than compiling them twice.
# ---------------------------------------------------------
if [[ "$standard_toolchain" == "nightly" ]]; then
nightly_full=full
nightly_baseline="$standard_baseline"
else
nightly_full=nightly-full
build_variant nightly-full nightly "$full_features"
nightly_baseline=nightly-full
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
build_variant nightly-minimal nightly client
nightly_baseline=nightly-minimal
fi
fi
# ---------------------------------------------------------
# Nightly size experiments, always enabled.
# ---------------------------------------------------------
# Rebuild std with the ordinary std feature set.
build_variant rebuilt-std nightly client \
-Z build-std
# Rebuild std without backtrace support.
# Retain normal panic unwinding.
build_variant no-backtrace nightly client \
-Z build-std \
-Z build-std-features=panic-unwind
# Aggressive option: terminate immediately on panic.
build_variant immediate-abort nightly client \
-Z panic-immediate-abort \
-Z build-std \
-Z build-std-features= \
--config 'profile.release.panic="immediate-abort"'
# ---------------------------------------------------------
# Job summary.
# ---------------------------------------------------------
{
echo "### irtt-client: ${TARGET}"
echo
echo "#### Standard builds"
echo
echo "Toolchain: \`${standard_toolchain}\`"
echo
echo '| Variant | Unstripped | Stripped | Compressed | Compressed vs baseline |'
echo '|:--|--:|--:|--:|--:|'
print_row full "Full" "$standard_baseline"
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
print_row minimal "Minimal" "$standard_baseline"
fi
echo
echo "#### Nightly experiments"
echo
echo '| Variant | Unstripped | Stripped | Compressed | Compressed vs baseline |'
echo '|:--|--:|--:|--:|--:|'
print_row "$nightly_full" "Nightly full" "$nightly_baseline"
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
print_row "$nightly_baseline" "Nightly minimal" "$nightly_baseline"
fi
print_row rebuilt-std "Rebuilt std" "$nightly_baseline"
print_row no-backtrace "No backtrace" "$nightly_baseline"
print_row immediate-abort "Immediate abort" "$nightly_baseline"
if [[ "$standard_toolchain" == "stable" ]]; then
echo
echo "Nightly vs stable baseline (compressed): $(delta \
"${compressed_size[$standard_baseline]}" \
"${compressed_size[$nightly_baseline]}")"
else
echo
echo "MIPS: standard and nightly baselines are identical."
echo "Cross.toml already enables build-std for these targets."
fi
if [[ "$HAS_STATS_FEATURE" != "true" ]]; then
echo
echo "Statistics feature not detected; client includes statistics."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload binaries
# Gitea's v3 artifact protocol.
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: irtt-client-${{ matrix.target }}
path: dist/irtt-client-${{ matrix.target }}-*.tar.gz
if-no-files-found: error
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
if: matrix.musl if: matrix.musl
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends musl-tools sudo apt-get install -y --no-install-recommends musl-tools
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
registry: ${{ env.GITEA_REGISTRY }} registry: ${{ env.GITEA_REGISTRY }}
# The automatic per-job token comes back unauthorized for package # The automatic per-job token comes back unauthorized for package
@@ -55,7 +55,7 @@ jobs:
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
registry: ${{ env.GHCR_REGISTRY }} registry: ${{ env.GHCR_REGISTRY }}
username: ${{ secrets.GHCR_USERNAME }} username: ${{ secrets.GHCR_USERNAME }}
+24 -1
View File
@@ -34,12 +34,35 @@ jobs:
with: with:
cache: false cache: false
- name: Install Go toolchain
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.27.3'
cache: false
- name: Install netem dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends iproute2 python3 util-linux
- name: Check host tools and network administration access - name: Check host tools and network administration access
run: | run: |
ip -V ip -V
tc -V tc -V
nsenter --version
go version go version
sudo -n true # Exercise the namespace, veth, and qdisc operations the harness needs.
# sudo/NET_ADMIN alone do not establish mount/namespace permissions.
sudo -n bash -euo pipefail <<'SH'
probe_ns="irtt-preflight-$$"
ip netns add "$probe_ns"
trap 'ip netns delete "$probe_ns"' EXIT
nsenter --net="/run/netns/$probe_ns" -- sh -ec '
ip link add probe0 type veth peer name probe1
tc qdisc add dev probe0 root netem delay 1ms
tc qdisc show dev probe0
'
SH
- name: Install upstream irtt - name: Install upstream irtt
run: | run: |
+5
View File
@@ -47,6 +47,11 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
- name: Install cargo-deny - name: Install cargo-deny
run: | run: |
set -euo pipefail set -euo pipefail
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# It remains here while portable actionlint runs with Gitea CI, but scans # It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set. # both workflow directories through its GitHub Actions rule set.
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
with: with:
persona: regular persona: regular
inputs: | inputs: |
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
musl: true musl: true
runs-on: ubuntu-24.04-arm runs-on: ubuntu-26.04-arm
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
@@ -53,7 +53,7 @@ jobs:
if: matrix.musl if: matrix.musl
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends musl-tools sudo apt-get install -y --no-install-recommends musl-tools
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
# the old --skip-git flag is no longer supported. # the old --skip-git flag is no longer supported.
scan-scheduled: scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
scan-pr: scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+111 -2
View File
@@ -10,6 +10,12 @@ release. The format is loosely based on
## irtt-proto ## irtt-proto
### 0.5.2
#### Added
- Exported `VERSION`, the compiled crate's Cargo package version, for applications reporting their component versions. Wire behavior is unchanged.
### 0.5.1 ### 0.5.1
#### Added #### Added
@@ -34,6 +40,25 @@ release. The format is loosely based on
## irtt-client ## irtt-client
### 0.7.0
#### Added
- Opt-in automatic session reconnection in the Tokio managed driver and `BlockingManagedClient`. Continuous measurement runs can retry initial connection failures, failed or peer-closed sessions, and server-limited session completion. Silence detection reconnects a session that stops replying while probes are being sent.
- Each desired target retries independently, leaving healthy targets running. Exponential backoff doubles from `reconnect_initial_delay` (default 250 ms) to `reconnect_max_delay` (default 10 s); a valid reply restores the initial delay. Reopening resolves the original endpoint again. Target removal and explicit shutdown cancel recovery.
- Exported `VERSION` for compiled component-version reporting.
#### Fixed
- Stopping or replacing a target during session opening cancels the pending open immediately, instead of waiting for its retry schedule to finish.
#### Compatibility
- `ManagedClientConfig` gains `reconnect_timeout`, `reconnect_initial_delay`, and `reconnect_max_delay`; update complete struct literals or use `..Default::default()`. Reconnection is disabled by default (`reconnect_timeout: None`) and is valid only for continuous normal measurement mode, with a positive schedulable silence timeout. Retry delays must be positive and schedulable, and the maximum must be at least twice the initial delay.
- `ManagedConfigError` gains `InvalidReconnectPolicy` and `InvalidReconnectDelays`; downstream exhaustive matches must handle them. Delay validation applies even when recovery is disabled.
- A reconnecting target returns to pending/opening status rather than producing a terminal outcome for each retry. Consumers must handle repeated session-start events for the same target instance and reset session-bound statistics and sequence tracking on each new session.
- Use `irtt-stats` 0.6 with this client version when exchanging `ClientEvent` values. Tokio remains optional; the default low-level client remains runtime-free. No wire-protocol migration is required.
### 0.6.0 ### 0.6.0
#### Added #### Added
@@ -105,6 +130,12 @@ release. The format is loosely based on
## irtt-server ## irtt-server
### 0.5.4
#### Added
- Exported `VERSION`, the compiled crate's Cargo package version, for applications reporting their component versions. Server policy and wire behavior are unchanged.
### 0.5.3 ### 0.5.3
#### Added #### Added
@@ -150,6 +181,23 @@ release. The format is loosely based on
## irtt-stats ## irtt-stats
### 0.6.0
#### Changed
- Updated the public `irtt-client` dependency to 0.7. Applications passing `ClientEvent` values into this crate must upgrade both dependencies together; client 0.6 and 0.7 event types come from different crate versions and cannot be mixed.
- Added signed nanosecond changes to `IpdvPairUpdate`: `rtt_ipdv_ns`, `send_ipdv_ns`, and `receive_ipdv_ns`. Existing `Duration` fields and aggregate IPDV statistics remain absolute magnitudes. Update complete struct literals to include the new fields.
- Exported `VERSION` for compiled component-version reporting.
#### Fixed
- Directional IPDV is available with midpoint timestamps, using the midpoint for both server endpoints.
- Per-packet IPDV preserves the sign of the delay change instead of reporting only its magnitude.
#### Compatibility
- Collectors remain scoped to one target session. Reset or replace a collector when the managed client reconnects; this release does not aggregate across session sequence resets.
### 0.5.2 ### 0.5.2
#### Changed #### Changed
@@ -192,6 +240,62 @@ release. The format is loosely based on
## irtt-rs ## 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
- Continuous CLI recovery with `--continuous --reconnect` (or `--duration 0 --reconnect`). Failed initial opens, failed or closed sessions, server-limited completion, and reply silence trigger independent per-target retries. Healthy targets keep running, including in multi-target and `--targets-stdin` runs; removing a desired target cancels its retries.
- `--reconnect-timeout` configures reply-silence detection while sending (default 10 s). `--reconnect-initial-delay` and `--reconnect-max-delay` configure per-target exponential backoff (defaults 250 ms and 10 s); the maximum must be at least twice the initial delay. Valid replies reset the backoff. Explicit stops do not retry.
- `--no-stats` disables client statistics collection, retention warnings, and final summaries while retaining measurement events and structured output columns. Statistics-dependent fields have absent values.
- Optional `stats` Cargo feature, enabled by default and required by `tui`. Build a lightweight client without statistics using `cargo build --release -p irtt-rs --bin irtt-client --no-default-features --features client`; use `--features client,stats` for a client-only build with statistics.
- `--continuous` as an explicit alias for zero duration, `-d` for duration, `-i` for interval, `-l` for packet length, client `-f` for output format, and `-v` for verbose output. Client/TUI durations accept fractional and composite values such as `2.5s` and `1m30s`.
- Client/TUI `--timeouts` configures the session-opening attempt schedule independently of session reconnection.
- Client `-n` / `--no-test` negotiates parameters and exits without measurement probes, using the selected output format.
- `--version` on the dispatcher and applets; applets also report their compiled library versions and whether client statistics are disabled.
#### Fixed
- Signed per-packet IPDV is preserved in output; directional IPDV is available with midpoint timestamps.
#### Compatibility
- Reconnection is opt-in for the CLI and requires continuous measurement mode; it cannot be combined with `--no-test`. Statistics reset on every reopened session, so summaries describe only the latest session per target, not the whole continuous run. A warning explains this when recovery and statistics are both enabled. Continuous TUI recovery remains enabled as before.
- Statistics are no longer implied by the `client` feature. Existing custom builds using `--no-default-features --features client` must add `stats` to retain collection and summaries. `--no-stats` is accepted in builds without statistics; output schemas are preserved, with absent IPDV values.
- Nonzero duration values require units; zero (including `0s`) still selects continuous mode. `--continuous` and an explicit `--duration` are mutually exclusive.
- Application-library users must account for the added client argument/configuration fields and optional statistics feature. Dependencies are now client 0.7 and stats 0.6, with proto >=0.5.2 and server >=0.5.4 for component-version reporting. MSRV remains Rust 1.88.
### 0.9.0
#### Added
- Client and TUI `--bind-addr IP[:PORT]` selects a local source address and optional UDP port. Omitted ports and port `0` use an ephemeral port; IPv6 addresses with an explicit port use `[ADDR]:PORT`. The bind address must match the target's address family, and fixed ports can conflict across multiple target sockets.
- Client and TUI `--bind-device DEVICE` binds sockets to a network interface on Linux, Android, and Fuchsia.
- Client and TUI `--mark MARK` sets a socket firewall mark for policy routing or firewall rules on Linux, Android, and Fuchsia. It accepts decimal and `0x`-prefixed hexadecimal unsigned 32-bit values.
- Client and TUI `--fib FIB` selects a routing table on FreeBSD, accepting a decimal unsigned 32-bit value.
#### Fixed
- Restored CLI compatibility with upstream IRTT for hexadecimal HMAC keys: client, TUI, and server `--hmac`, server `IRTT_SERVER_HMAC`, and client/TUI per-target `@hmac=KEY` accept a lowercase `0x` prefix followed by hexadecimal bytes, with either case allowed for digits. Literal UTF-8 keys remain supported; the HMAC-MD5 algorithm is unchanged.
- Server argument debug output now redacts HMAC keys, matching the client and TUI. Server help hides the `IRTT_SERVER_HMAC` value, and invalid global HMAC keys are rejected during configuration preparation without echoing key material.
#### Compatibility
- Keys beginning with literal `0x` are now interpreted as hexadecimal. Empty keys, empty hexadecimal keys, odd-length hexadecimal strings, and invalid hexadecimal digits are rejected. Per-target `@hmac=` continues to disable authentication explicitly.
### 0.8.0 ### 0.8.0
#### Added #### Added
@@ -312,10 +416,15 @@ Starting with this release, crates are versioned and tagged independently
rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z` rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
(e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released (e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
libraries published to [crates.io](https://crates.io); their tags exist to libraries published to [crates.io](https://crates.io); publication is a separate
manual Cargo step. Their tags exist to
identify the exact source for a given crates.io release and do not produce a identify the exact source for a given crates.io release and do not produce a
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs` GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/) an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
to build and publish a GitHub Release with platform binaries. The historical to build and publish a GitHub Release with platform binaries. On Gitea, the
same application tag triggers the server Docker image workflow, publishing
`X.Y.Z` and `latest` to Gitea and GHCR. Library tags
trigger the Gitea Semver workflow, but neither binary nor image publication.
The application tag also triggers that Semver check. The historical
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced. `irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
Generated
+251 -146
View File
@@ -123,11 +123,11 @@ dependencies = [
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
dependencies = [ dependencies = [
"bit-vec 0.8.0", "bit-vec 0.10.1",
] ]
[[package]] [[package]]
@@ -138,9 +138,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]] [[package]]
name = "bit-vec" name = "bit-vec"
version = "0.8.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
dependencies = [
"borsh",
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
@@ -181,6 +185,30 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -199,6 +227,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "castaway" name = "castaway"
version = "0.2.4" version = "0.2.4"
@@ -210,9 +244,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -230,6 +264,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -251,6 +296,15 @@ dependencies = [
"clap_derive", "clap_derive",
] ]
[[package]]
name = "clap-num"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822c4000301ac390e65995c62207501e3ef800a1fc441df913a5e8e4dc374816"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.7" version = "4.6.7"
@@ -272,7 +326,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -328,6 +382,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -337,6 +397,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "critical-section" name = "critical-section"
version = "1.2.0" version = "1.2.0"
@@ -413,9 +482,9 @@ dependencies = [
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.3" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
dependencies = [ dependencies = [
"cmov", "cmov",
] ]
@@ -440,7 +509,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -451,7 +520,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -462,9 +531,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
@@ -539,9 +608,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
@@ -591,9 +660,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]] [[package]]
name = "finl_unicode" name = "finl_unicode"
@@ -674,17 +743,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
] "rand_core 0.10.1",
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
] ]
[[package]] [[package]]
@@ -719,6 +778,12 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "humantime"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.15" version = "0.4.15"
@@ -759,10 +824,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "indoc" name = "indexmap"
version = "2.0.7" version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "indoc"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -777,12 +852,12 @@ dependencies = [
"indoc", "indoc",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
name = "irtt-client" name = "irtt-client"
version = "0.6.0" version = "0.7.0"
dependencies = [ dependencies = [
"irtt-proto", "irtt-proto",
"irtt-server", "irtt-server",
@@ -804,7 +879,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.1" version = "0.5.2"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
@@ -815,13 +890,16 @@ dependencies = [
[[package]] [[package]]
name = "irtt-rs" name = "irtt-rs"
version = "0.8.0" version = "0.10.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
"clap-num",
"crossterm", "crossterm",
"ctrlc", "ctrlc",
"futures-core", "futures-core",
"hex",
"humantime",
"irtt-client", "irtt-client",
"irtt-proto", "irtt-proto",
"irtt-server", "irtt-server",
@@ -833,7 +911,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-server" name = "irtt-server"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"irtt-proto", "irtt-proto",
@@ -845,7 +923,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.2" version = "0.6.0"
dependencies = [ dependencies = [
"irtt-client", "irtt-client",
"measurement-stats", "measurement-stats",
@@ -886,11 +964,11 @@ dependencies = [
[[package]] [[package]]
name = "kasuari" name = "kasuari"
version = "0.4.12" version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown",
"portable-atomic", "portable-atomic",
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -909,9 +987,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "libm" name = "libm"
@@ -936,9 +1014,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litrs" name = "litrs"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -961,16 +1039,16 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [ dependencies = [
"hashbrown 0.17.1", "hashbrown",
] ]
[[package]] [[package]]
name = "mac_address" name = "mac_address"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
dependencies = [ dependencies = [
"nix 0.30.1", "nix 0.31.3",
"windows-sys", "windows-sys",
] ]
@@ -1041,19 +1119,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.31.3" version = "0.31.3"
@@ -1105,9 +1170,9 @@ dependencies = [
[[package]] [[package]]
name = "num_threads" name = "num_threads"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@@ -1206,19 +1271,20 @@ dependencies = [
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.9.2" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660"
dependencies = [ dependencies = [
"memchr", "memchr",
"pest_stack_guard",
"ucd-trie", "ucd-trie",
] ]
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.9.2" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1226,26 +1292,36 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.9.2" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.9",
] ]
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.9.2" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d"
dependencies = [ dependencies = [
"pest", "pest",
] ]
[[package]]
name = "pest_stack_guard"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2"
dependencies = [
"psm",
"stacker",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.11.3" version = "0.11.3"
@@ -1317,12 +1393,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "ppv-lite86" name = "proc-macro-crate"
version = "0.2.21" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"zerocopy", "toml_edit",
] ]
[[package]] [[package]]
@@ -1336,16 +1412,17 @@ dependencies = [
[[package]] [[package]]
name = "proptest" name = "proptest"
version = "1.11.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [ dependencies = [
"bit-set 0.8.0", "bit-set 0.11.1",
"bit-vec 0.8.0", "bit-vec 0.10.1",
"bitflags 2.13.2", "bitflags 2.13.2",
"chacha20",
"core_detect",
"num-traits", "num-traits",
"rand 0.9.5", "rand 0.10.3",
"rand_chacha",
"rand_xorshift", "rand_xorshift",
"regex-syntax", "regex-syntax",
"rusty-fork", "rusty-fork",
@@ -1353,6 +1430,15 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@@ -1391,22 +1477,12 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.5" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"rand_chacha", "getrandom 0.4.3",
"rand_core 0.9.5", "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1417,20 +1493,17 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_xorshift" name = "rand_xorshift"
version = "0.4.0" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [ dependencies = [
"rand_core 0.9.5", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1458,7 +1531,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"compact_str", "compact_str",
"critical-section", "critical-section",
"hashbrown 0.17.1", "hashbrown",
"itertools", "itertools",
"kasuari", "kasuari",
"lru", "lru",
@@ -1521,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"hashbrown 0.17.1", "hashbrown",
"indoc", "indoc",
"instability", "instability",
"itertools", "itertools",
@@ -1583,9 +1656,9 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.5" version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"errno", "errno",
@@ -1657,7 +1730,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -1667,7 +1740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest 0.10.7", "digest 0.10.7",
] ]
@@ -1722,9 +1795,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.2" version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -1736,6 +1809,19 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -1799,9 +1885,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.6" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1934,7 +2020,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -1960,9 +2046,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.2" version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [ dependencies = [
"libc", "libc",
"mio", "mio",
@@ -1980,7 +2066,37 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
] ]
[[package]] [[package]]
@@ -2003,9 +2119,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
@@ -2026,9 +2142,9 @@ dependencies = [
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
@@ -2038,9 +2154,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.27.0" version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [ dependencies = [
"atomic", "atomic",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2119,7 +2235,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2294,28 +2410,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
+4 -5
View File
@@ -14,13 +14,12 @@ edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
rust-version = "1.88" rust-version = "1.88"
repository = "https://github.com/Lochnair/irtt-rs" repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs"
[workspace.dependencies] [workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" } irtt-proto = { version = "0.5.2", path = "crates/irtt-proto" }
irtt-client = { version = "0.6.0", path = "crates/irtt-client" } irtt-client = { version = "0.7.0", path = "crates/irtt-client" }
irtt-server = { version = "0.5.2", path = "crates/irtt-server" } irtt-server = { version = "0.5.4", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" } irtt-stats = { version = "0.6.0", path = "crates/irtt-stats" }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
+32
View File
@@ -0,0 +1,32 @@
[build]
# MIPS musl std expects libunwind; cross GCC provides libgcc_eh.
pre-build = [
'''
for cc in /usr/local/bin/mips*-gcc; do
[ -x "$cc" ] || continue
archive=$("$cc" -print-file-name=libgcc_eh.a)
test -f "$archive" || exit 1
ln -sf libgcc_eh.a "$(dirname "$archive")/libunwind.a"
done
''',
]
[target.riscv64gc-unknown-linux-musl]
image = "ghcr.io/cross-rs/riscv64gc-unknown-linux-musl:main"
[target.mips-unknown-linux-musl]
image = "ghcr.io/cross-rs/mips-unknown-linux-musl:main"
build-std = ["std", "panic_abort"]
[target.mipsel-unknown-linux-musl]
image = "ghcr.io/cross-rs/mipsel-unknown-linux-musl:main"
build-std = ["std", "panic_abort"]
[target.mips64-unknown-linux-muslabi64]
image = "ghcr.io/cross-rs/mips64-unknown-linux-muslabi64:main"
build-std = ["std", "panic_abort"]
[target.mips64el-unknown-linux-muslabi64]
image = "ghcr.io/cross-rs/mips64el-unknown-linux-muslabi64:main"
build-std = ["std", "panic_abort"]
+87 -84
View File
@@ -1,95 +1,68 @@
# irtt-rs # irtt-rs
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server: `irtt-rs` is an IRTT-compatible client and server written in Rust. You can use
finite or continuous UDP latency probing, plus the server side those probes it to measure UDP latency for a fixed duration or leave it running
run against. continuously, and run your own server for the probes to talk to.
This is not the upstream IRTT project. For the original implementation, The original IRTT implementation is [heistp/irtt](https://github.com/heistp/irtt),
protocol background, and broader documentation, see which also has protocol background and more documentation. This project is
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is independent, so the installed dispatcher is named `irtt-rs` to keep it from
named `irtt-rs` rather than `irtt` to avoid conflicting with or being conflicting with or being mistaken for `irtt`.
mistaken for it.
## Features ## Features
* Finite and continuous probe sessions - Finite and continuous probe sessions
* Concurrent probing of multiple targets, with staggered or burst pacing - Concurrent probing of multiple targets, with staggered or burst pacing
* Reusable client library with a structured event stream (blocking, Tokio, - Reusable client library with a structured event stream (blocking, Tokio,
or managed) 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 monitoring systems, with selectable output columns
* Optional terminal UI - Optional terminal UI
* Reusable Tokio-native server library - Reusable Tokio-native server library
* UDP server applet with sensible zero-argument defaults and explicit bind - UDP server applet you can run without arguments, with options for bind
and session-policy options addresses and session policy when you need them
* Router-friendly multi-applet binary layout - A single binary that can run several applets, useful on routers
## Comparison with upstream IRTT ## Comparison with upstream IRTT
`irtt-rs` is an independent, compatible reimplementation, not a successor or `irtt-rs` is useful if you want to watch several targets at once, compare
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt) them in a terminal dashboard, or feed selected measurements into a script.
remains the original implementation. Interoperability is a project goal; see You can leave it running with statistics and automatic reconnection, use
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and its Rust libraries in your own application, or deploy a single binary on
[`docs/protocol/`](docs/protocol/) for the black-box interoperability a router.
methodology and protocol baseline this project verifies against. Choose
between them based on the capabilities and deployment model you need.
The table below was checked against upstream release Upstream IRTT is much more mature, and if you don't need the features
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17). specific to `irtt-rs`, you should probably use it. It also has more tools
This project's own interop testing and protocol baseline are currently for working with saved results and controlling how probes run, including
pinned to the prior release, v0.9.1; the wire protocol version number is replaying saved JSON reports, timer and clock diagnostics, and client
unchanged between the two, but v0.9.2 has not itself been exercised against payload fill and timing algorithms.
this project's interop harness.
| Capability | irtt-rs | upstream IRTT (v0.9.2) | See the [detailed comparison](docs/INTEROP_COMPARISON.md) for individual
| --- | --- | --- | features, version coverage, and black-box interoperability testing.
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
| Finite probing | Supported, default 10s | Supported, default 1m |
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
Upstream has capabilities this project does not implement: a `report`
subcommand for replaying saved JSON results, benchmarking and
timer/clock-diagnostic subcommands, client-side payload fill and
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
server-side fill policy (this project's server always honors a session's
negotiated fill request, with a fixed fallback pattern, but has no equivalent
to upstream's flags for restricting or overriding it), syslog logging, and
bundled systemd/OpenRC service files.
This project has capabilities upstream does not implement: multi-target
probing with labels, the terminal UI, CSV/TSV output with selectable
columns, a quantified and actively-warned memory-retention model for
long-running client sessions, and best-effort Linux kernel-timestamp
preference for its own send/receive endpoint.
## Installation ## 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 ```sh
cargo install irtt-rs --locked cargo install irtt-rs --locked
``` ```
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`, This installs the `irtt-rs` dispatcher along with dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in `irtt-tui`, and `irtt-server` binaries. If you only need the client, you can
isolation with `--no-default-features` plus the applet's own feature, for disable the other applets and statistics:
example a lightweight client-only install:
```sh ```sh
cargo install irtt-rs --locked --no-default-features --features client cargo install irtt-rs --locked --no-default-features --features client
``` ```
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: For development, or to install directly from a checkout:
```sh ```sh
@@ -104,44 +77,80 @@ Probe a server:
```sh ```sh
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
# Negotiate parameters without sending measurement probes
irtt-client --no-test netperf-eu.bufferbloat.net:2112
``` ```
Probe continuously until interrupted (`Ctrl-C`): Probe continuously until interrupted (`Ctrl-C`):
```sh ```sh
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0 irtt-client netperf-eu.bufferbloat.net:2112 --continuous
# Recover failed, closed, or silent sessions independently per target
irtt-client netperf-eu.bufferbloat.net:2112 --continuous --reconnect \
--reconnect-timeout 10s --reconnect-initial-delay 250ms --reconnect-max-delay 10s
``` ```
Reconnection resets statistics for that target; summaries cover its latest
session. Add `--no-stats` for measurement events without statistics collection.
Watch the same session live in the terminal UI: Watch the same session live in the terminal UI:
```sh ```sh
irtt-tui netperf-eu.bufferbloat.net:2112 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 ```sh
irtt-server 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 ## 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 formats and columns, measurement field definitions, finite/continuous
memory behavior, kernel timestamps. 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. 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. 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. 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 ## Crates
@@ -153,15 +162,13 @@ 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-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)) | | [`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 ## Binaries and features
| Build | Binaries | | Build | Binaries |
| --- | --- | | --- | --- |
| `--no-default-features` | `irtt-rs` | | `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` | | `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
| `--no-default-features --features client,stats` | `irtt-rs`, `irtt-client` (with statistics) |
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` | | `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` | | `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` | | Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
@@ -174,7 +181,3 @@ cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings 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`.
+11 -14
View File
@@ -1,12 +1,11 @@
[package] [package]
name = "irtt-rs" name = "irtt-rs"
version = "0.8.0" version = "0.10.2"
description = "IRTT-compatible client, TUI, and server applications" description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"] keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"] categories = ["command-line-utilities", "network-programming"]
@@ -41,33 +40,31 @@ required-features = ["server"]
doc = false doc = false
[features] [features]
default = ["client", "server", "tui"] default = ["client", "server", "tui", "stats"]
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto"]
server = ["dep:irtt-server", "dep:tokio"] stats = ["dep:irtt-stats"]
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] server = ["dep:irtt-server", "dep:irtt-proto", "dep:tokio"]
tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
[dependencies] [dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
clap = { version = "4.6.7", features = ["derive", "env"] } clap = { version = "4.6.7", features = ["derive", "env"] }
clap-num = "=1.2.0"
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
futures-core = { version = "0.3.34", default-features = false, optional = true } futures-core = { version = "0.3.34", default-features = false, optional = true }
hex = "0.4.3"
humantime = { version = "2.4.0", optional = true }
ctrlc = { version = "3.5.2", features = ["termination"] } ctrlc = { version = "3.5.2", features = ["termination"] }
irtt-client = { workspace = true, optional = true } irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true } irtt-proto = { workspace = true, optional = true }
irtt-server = { workspace = true, optional = true } irtt-server = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true } irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.30.2", optional = true } ratatui = { version = "0.30.2", optional = true }
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true } tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
[dev-dependencies] [dev-dependencies]
irtt-server.workspace = true irtt-server.workspace = true
tokio = { version = "1.53.2", default-features = false, features = ["rt", "sync"] } tokio = { version = "1.54.1", default-features = false, features = ["rt", "sync"] }
[target.'cfg(unix)'.dev-dependencies] [target.'cfg(unix)'.dev-dependencies]
nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] } nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = "symbols"
+7 -2
View File
@@ -11,8 +11,13 @@ Installing this crate builds:
- `irtt-tui` — the interactive terminal UI (feature `tui`). - `irtt-tui` — the interactive terminal UI (feature `tui`).
- `irtt-server` — the dedicated server binary (feature `server`). - `irtt-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs` The `client`, `server`, `tui`, and `stats` features are enabled by default, so a
installs everything. plain `cargo install irtt-rs` installs everything. The TUI requires statistics
and enables `stats` automatically.
For a client without statistics, use
`cargo install irtt-rs --locked --no-default-features --features client --bin irtt-client`.
Use `--features client,stats` for a client-only build with statistics.
## This is an application, not a library ## This is an application, not a library
+2
View File
@@ -55,6 +55,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
}; };
match command { match command {
"-V" | "--version" => Ok(AppletDispatch::Help(crate::version::app())),
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())), "-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run { "client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client, applet: RequestedApplet::Client,
@@ -78,6 +79,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
pub fn dispatcher_help() -> String { pub fn dispatcher_help() -> String {
let mut command = Command::new("irtt-rs") let mut command = Command::new("irtt-rs")
.version(env!("CARGO_PKG_VERSION"))
.about("IRTT-compatible multi-applet dispatcher") .about("IRTT-compatible multi-applet dispatcher")
.subcommand(Command::new("client").about(client_applet_about())) .subcommand(Command::new("client").about(client_applet_about()))
.subcommand(Command::new("tui").about(tui_applet_about())) .subcommand(Command::new("tui").about(tui_applet_about()))
+109 -12
View File
@@ -9,14 +9,18 @@ use clap::{Parser, ValueEnum};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10); pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")] #[command(
name = "irtt-client",
version,
about = "Minimal IRTT-compatible stream client"
)]
pub struct ClientArgs { pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. /// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg( #[arg(
value_name = "TARGET", value_name = "TARGET",
num_args = 0.., num_args = 0..,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target." long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
)] )]
pub targets: Vec<TargetArg>, pub targets: Vec<TargetArg>,
@@ -27,6 +31,15 @@ pub struct ClientArgs {
)] )]
pub targets_stdin: bool, pub targets_stdin: bool,
/// Negotiate parameters and exit without sending measurement probes.
#[arg(
short = 'n',
long,
conflicts_with = "targets_stdin",
long_help = "Negotiate parameters with each target and exit without sending measurement probes. Duration options still set the requested duration, but do not keep this mode running. Reports negotiated parameters using the selected output format, without a measurement summary."
)]
pub no_test: bool,
/// Managed group pacing for multi-target mode. /// Managed group pacing for multi-target mode.
#[arg( #[arg(
long, long,
@@ -38,13 +51,43 @@ pub struct ClientArgs {
#[arg( #[arg(
long, long,
short = 'd',
default_value = "10s", default_value = "10s",
value_parser = parse_test_duration, value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode", help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF." long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted).\n\nWhen statistics support is compiled in and --no-stats is not selected, finite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
)] )]
pub duration: Duration, pub duration: Duration,
/// Run continuously until interrupted (equivalent to --duration 0).
#[arg(long, conflicts_with = "duration")]
pub continuous: bool,
/// Disable statistics collection and final summaries; retain measurement event output.
/// Accepted even when statistics support is not compiled in.
#[arg(long)]
pub no_stats: bool,
/// Retry failed, closed, or silent sessions (requires continuous mode).
#[arg(
long,
conflicts_with = "no_test",
long_help = "Retry failed initial opens and closed, failed, or silent sessions independently per target. Requires --continuous or --duration 0. Unsuccessful connections use exponential backoff (defaults: 250ms initial, 10s maximum), configurable with --reconnect-initial-delay and --reconnect-max-delay. Use --reconnect-timeout to configure silence detection and --no-stats when only measurement events are needed."
)]
pub reconnect: bool,
/// Reconnect after this long without valid replies while sending probes.
#[arg(long, default_value = "10s", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
pub reconnect_timeout: Duration,
/// Initial retry delay, restored after a valid reply.
#[arg(long, default_value = "250ms", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
pub reconnect_initial_delay: Duration,
/// Maximum retry delay (must be at least twice the initial delay).
#[arg(long, default_value = "10s", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
pub reconnect_max_delay: Duration,
#[command(flatten)] #[command(flatten)]
pub common: CommonClientArgs, pub common: CommonClientArgs,
@@ -52,17 +95,28 @@ pub struct ClientArgs {
#[arg( #[arg(
long, long,
value_enum, value_enum,
short = 'f',
default_value_t = OutputFormat::Table, default_value_t = OutputFormat::Table,
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export." long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
)] )]
pub format: OutputFormat, 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. /// Comma-separated event row columns, or default/all.
#[arg( #[arg(
short = 'c', short = 'c',
long, long,
value_name = "COLUMNS", 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>, pub columns: Option<String>,
@@ -83,7 +137,7 @@ pub struct ClientArgs {
pub header: HeaderMode, pub header: HeaderMode,
/// Include extra fields in table output and final summaries. /// Include extra fields in table output and final summaries.
#[arg(long)] #[arg(short = 'v', long)]
pub verbose: bool, pub verbose: bool,
} }
@@ -93,23 +147,43 @@ impl ClientArgs {
/// Targets are only required here. `--list-columns` returns before this is /// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target. /// called, so listing columns still needs no target.
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> { pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() { if self.reconnect && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned()); return Err("--reconnect requires --continuous or --duration 0".to_owned());
} }
prepare_managed_run( if self
.reconnect_initial_delay
.checked_mul(2)
.is_none_or(|minimum| self.reconnect_max_delay < minimum)
{
return Err(
"--reconnect-max-delay must be at least twice --reconnect-initial-delay".to_owned(),
);
}
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --continuous or --duration 0".to_owned());
}
let mut setup = prepare_managed_run(
&self.common, &self.common,
self.duration, if self.continuous {
Duration::ZERO
} else {
self.duration
},
TargetSelection { TargetSelection {
targets: &self.targets, targets: &self.targets,
pacing: self.pacing, pacing: self.pacing,
stdin_controlled: self.targets_stdin, stdin_controlled: self.targets_stdin,
}, },
) )
.await .await?;
if self.no_test {
setup.client.request.run_mode = irtt_client::RunMode::NoTest;
}
Ok(setup)
} }
pub fn is_continuous(&self) -> bool { pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO self.continuous || self.duration == Duration::ZERO
} }
pub fn timestamp_mode(&self) -> TimestampArg { pub fn timestamp_mode(&self) -> TimestampArg {
@@ -125,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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat { pub enum OutputFormat {
/// Readable terminal table output. /// Readable terminal table output.
+1
View File
@@ -1,6 +1,7 @@
pub mod args; pub mod args;
pub mod output; pub mod output;
pub mod run; pub mod run;
#[cfg(feature = "stats")]
pub mod summary; pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat}; pub use args::{ClientArgs, HeaderMode, OutputFormat};
+40 -25
View File
@@ -9,14 +9,16 @@ use irtt_client::{
ServerTiming, SignedDuration, WarningKind, ServerTiming, SignedDuration, WarningKind,
}; };
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate}; 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}; use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct OutputConfig { pub(super) struct OutputConfig {
format: OutputFormat, format: OutputFormat,
events: Vec<EventKind>,
columns: Vec<Column>, columns: Vec<Column>,
header: HeaderMode, header: HeaderMode,
default_table_rows: bool, default_table_rows: bool,
@@ -29,6 +31,7 @@ impl OutputConfig {
columns: Option<&str>, columns: Option<&str>,
header: HeaderMode, header: HeaderMode,
verbose: bool, verbose: bool,
events: Vec<EventKind>,
) -> Result<Self, String> { ) -> Result<Self, String> {
let uses_default_columns = columns let uses_default_columns = columns
.map(|columns| columns.trim() == "default") .map(|columns| columns.trim() == "default")
@@ -41,6 +44,7 @@ impl OutputConfig {
Ok(Self { Ok(Self {
format, format,
events,
columns, columns,
header, header,
default_table_rows, default_table_rows,
@@ -48,10 +52,12 @@ impl OutputConfig {
}) })
} }
#[cfg(feature = "stats")]
pub(super) fn prints_summary(&self) -> bool { pub(super) fn prints_summary(&self) -> bool {
self.format.prints_summary() self.format.prints_summary()
} }
#[cfg(feature = "stats")]
pub(super) fn summary_verbose(&self) -> bool { pub(super) fn summary_verbose(&self) -> bool {
self.verbose self.verbose
} }
@@ -96,15 +102,25 @@ impl OutputConfig {
&self, &self,
event: &ClientEvent, event: &ClientEvent,
target: Option<&str>, target: Option<&str>,
stats: Option<&EventStatsUpdate>, #[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>,
) -> Option<String> { ) -> Option<String> {
let row = OutputRow::from_event(event); 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; return None;
} }
#[cfg(feature = "stats")]
let ipdv_ns = row
.seq()
.and_then(|seq| ipdv_pair(stats, seq))
.map(|pair| pair.rtt_ipdv_ns);
#[cfg(not(feature = "stats"))]
let ipdv_ns = None;
let context = RenderContext { let context = RenderContext {
stats, ipdv_ns,
target, target,
verbose: self.verbose, verbose: self.verbose,
}; };
@@ -155,7 +171,7 @@ impl OutputConfig {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct RenderContext<'a> { struct RenderContext<'a> {
stats: Option<&'a EventStatsUpdate>, ipdv_ns: Option<i128>,
target: Option<&'a str>, target: Option<&'a str>,
verbose: bool, verbose: bool,
} }
@@ -312,17 +328,17 @@ impl OutputRow {
} }
} }
fn event_name(&self) -> &'static str { fn event_kind(&self) -> EventKind {
match self { match self {
Self::SessionStarted(_) => "session_started", Self::SessionStarted(_) => EventKind::SessionStarted,
Self::NoTestCompleted(_) => "no_test_completed", Self::NoTestCompleted(_) => EventKind::NoTestCompleted,
Self::SessionClosed { .. } => "session_closed", Self::SessionClosed { .. } => EventKind::SessionClosed,
Self::EchoSent { .. } => "echo_sent", Self::EchoSent { .. } => EventKind::EchoSent,
Self::EchoReply(_) => "echo_reply", Self::EchoReply(_) => EventKind::EchoReply,
Self::Loss { .. } => "loss", Self::Loss { .. } => EventKind::Loss,
Self::Duplicate { .. } => "duplicate", Self::Duplicate { .. } => EventKind::Duplicate,
Self::Late { .. } => "late", Self::Late { .. } => EventKind::Late,
Self::Warning { .. } => "warning", Self::Warning { .. } => EventKind::Warning,
} }
} }
@@ -541,7 +557,7 @@ columns! {
SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay"); SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay");
SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds"); SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds");
Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples"); Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples");
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in microseconds"); IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in signed microseconds");
ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time"); ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time");
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds"); ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
Bytes("bytes", [], 10, right, "packet bytes for packet events"); Bytes("bytes", [], 10, right, "packet bytes for packet events");
@@ -774,7 +790,7 @@ fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> { fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
match column { match column {
Column::Target => context.target.map(CellValue::text), 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::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
Column::Remote => row Column::Remote => row
.remote() .remote()
@@ -817,14 +833,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
.reply() .reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server)) .and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
.map(|value| CellValue::Integer(signed_duration_us(value))), .map(|value| CellValue::Integer(signed_duration_us(value))),
Column::Ipdv => row Column::Ipdv => context.ipdv_ns.map(|value| {
.seq() CellValue::text(format_signed_duration(SignedDuration::from_nanos(value)))
.and_then(|seq| ipdv_pair(context.stats, seq)) }),
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))), Column::IpdvUs => context
Column::IpdvUs => row .ipdv_ns
.seq() .map(|value| CellValue::Integer(value / 1_000)),
.and_then(|seq| ipdv_pair(context.stats, seq))
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
Column::ServerProcessing => row Column::ServerProcessing => row
.reply() .reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing)) .and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
@@ -931,6 +945,7 @@ fn server_timing_i64(
.map(|value| CellValue::Integer(i128::from(value))) .map(|value| CellValue::Integer(i128::from(value)))
} }
#[cfg(feature = "stats")]
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> { fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
let stats = stats?; let stats = stats?;
stats stats
+353 -57
View File
@@ -1,14 +1,15 @@
use std::{ use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, collections::{HashSet, VecDeque},
io::{self, BufRead, Write}, io::{self, BufRead, Write},
sync::Arc, sync::Arc,
thread, thread,
time::Duration,
}; };
use irtt_client::{ use irtt_client::{
managed::{ managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus, ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance, ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
}, },
ClientEvent, ClientEvent,
}; };
@@ -16,27 +17,82 @@ use irtt_client::{
use super::{args::ClientArgs, output::OutputConfig}; use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{ use crate::shared::client::{
expected_probe_count, parse_stdin_target_set, parse_stdin_target_set,
session::{ session::{
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close, peer_close_run_error, request_managed_stop_for_peer_close, request_managed_stop_once,
request_managed_stop_once, should_print_final_summary,
}, },
worker::ManagedWorker, worker::ManagedWorker,
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT, ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
}; };
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig}; use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
const MIB: u64 = 1024 * 1024; #[cfg(feature = "stats")]
use crate::shared::client::{expected_probe_count, session::should_print_final_summary};
#[cfg(feature = "stats")]
use irtt_client::managed::ManagedStatus;
#[cfg(feature = "stats")]
use std::collections::{BTreeMap, BTreeSet};
const GIB: u64 = 1024 * MIB;
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024; 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( pub async fn run_stream(
args: ClientArgs, args: ClientArgs,
@@ -46,46 +102,127 @@ pub async fn run_stream(
print!("{}", OutputConfig::list_columns()); print!("{}", OutputConfig::list_columns());
return Ok(()); 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! { let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?, result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()), _ = shutdown.wait_for(|requested| *requested) => return Ok(()),
}; };
#[cfg(feature = "stats")]
let multi_target = setup.is_multi_target(); let multi_target = setup.is_multi_target();
let output_config = OutputConfig::new( let output_config = OutputConfig::new(
args.format, args.format,
args.columns.as_deref(), args.columns.as_deref(),
args.header, args.header,
args.verbose, args.verbose,
args.events.clone(),
) )
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
#[cfg(feature = "stats")]
if let Some(warning) = reconnect_stats_warning(&args) {
eprintln!("{warning}");
}
if setup.stdin_controlled { if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown).await; return run_stdin_stream(setup, output_config, shutdown, &args, output).await;
} }
let continuous = args.is_continuous(); let continuous = args.is_continuous();
let target_count = setup.target_count();
if let Some(warning) = finite_stats_memory_warning(&args, target_count) { #[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args, setup.target_count()) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
if *shutdown.borrow() { if *shutdown.borrow() {
return Ok(()); return Ok(());
} }
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?; let (task, handle) = ManagedClient::task(
{
let mut config = setup.managed_config();
config.reconnect_timeout = args.reconnect.then_some(args.reconnect_timeout);
config.reconnect_initial_delay = args.reconnect_initial_delay;
config.reconnect_max_delay = args.reconnect_max_delay;
config
},
setup.managed_targets(),
)?;
let mut events = handle.subscribe()?; let mut events = handle.subscribe()?;
let mut status = handle.subscribe_status(); let mut status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?; 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 { let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats,
config: output_config, config: output_config,
header_printed: false, header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false, print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
}; };
#[cfg(feature = "stats")]
let mut stats = setup let mut stats = setup
.targets .targets
.iter() .iter()
.filter(|_| !args.no_stats)
.map(|target| { .map(|target| {
( (
target.label.clone(), target.label.clone(),
@@ -113,14 +250,18 @@ pub async fn run_stream(
) { ) {
drop(handle.stop()); drop(handle.stop());
} }
if snapshot.final_outcome.is_some() { if interrupted || snapshot.final_outcome.is_some() {
break; break;
} }
tokio::select! { tokio::select! {
_ = shutdown.changed(), if !interrupted => {} _ = shutdown.changed() => {}
changed = status.changed() => { if changed.is_err() { break; } } changed = status.changed() => { if changed.is_err() { break; } }
event = events.recv() => match event { event = events.recv() => match event {
Ok(event) => process_event(event, &mut stream_output, &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)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count); dropped_events = dropped_events.saturating_add(count);
} }
@@ -132,9 +273,24 @@ pub async fn run_stream(
eprintln!("interrupted, closing managed run..."); eprintln!("interrupted, closing managed run...");
} }
let outcome = owner.join().await?; let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| { while !*shutdown.borrow() {
process_event(event, &mut stream_output, &mut stats, &mut terminal_targets) 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) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
@@ -167,13 +323,16 @@ pub async fn run_stream(
}) })
}), }),
}; };
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted); #[cfg(feature = "stats")]
{
stream_output.print_final_summary =
!args.no_stats && !args.no_test && should_print_final_summary(continuous, interrupted);
stream_output.show_running_only_summary_note = stream_output.show_running_only_summary_note =
continuous && interrupted && stream_output.print_final_summary; continuous && interrupted && stream_output.print_final_summary;
for target in &setup.targets { for target in &setup.targets {
let target_stats = stats.get(&target.label).expect( let Some(target_stats) = stats.get(&target.label) else {
"every prepared target must have a stats collector inserted before the run begins", continue;
); };
if multi_target if multi_target
&& stream_output.print_final_summary && stream_output.print_final_summary
&& stream_output.config.prints_summary() && stream_output.config.prints_summary()
@@ -182,8 +341,10 @@ pub async fn run_stream(
writeln!(stream_output.out, "target: {}", target.label)?; writeln!(stream_output.out, "target: {}", target.label)?;
} }
stream_output.print_summary(target_stats)?; 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 { if let Some(error) = terminal_error {
return Err(error.into()); return Err(error.into());
} }
@@ -356,21 +517,33 @@ impl BoundedTargetSet {
fn process_stdin_event<W: Write>( fn process_stdin_event<W: Write>(
event: ManagedEvent, event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>, stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>, #[cfg(feature = "stats")] stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet, terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> { ) -> io::Result<()> {
match event { match event {
ManagedEvent::TargetStateChanged { target, .. } => { #[cfg(feature = "stats")]
ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => {
stats stats
.entry(target) .entry(target)
.or_insert_with(|| StatsCollector::new(stats_config(true))); .or_insert_with(|| StatsCollector::new(stats_config(true)));
} }
ManagedEvent::Client { target, event } => { ManagedEvent::Client { target, event } => {
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| {
let collector = stats let collector = stats
.entry(target.clone()) .entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true))); .or_insert_with(|| StatsCollector::new(stats_config(true)));
let update = collector.process(&event); if matches!(event, ClientEvent::SessionStarted(_)) {
stream_output.print_event(&event, Some(target.id.as_str()), &update)?; *collector = StatsCollector::new(stats_config(true));
}
collector.process(&event)
});
stream_output.print_event(
&event,
Some(target.id.as_str()),
#[cfg(feature = "stats")]
update.as_ref(),
)?;
} }
ManagedEvent::TargetFinished { outcome } ManagedEvent::TargetFinished { outcome }
if terminal_targets.insert(outcome.target.clone()) => if terminal_targets.insert(outcome.target.clone()) =>
@@ -382,6 +555,7 @@ fn process_stdin_event<W: Write>(
Ok(()) Ok(())
} }
#[cfg(feature = "stats")]
fn reconcile_stdin_stats( fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>, stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus, status: &ManagedStatus,
@@ -398,10 +572,15 @@ fn reconcile_stdin_stats(
}); });
} }
#[cfg(feature = "stats")]
fn snapshot_stdin_summary_targets( fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>, stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus, status: &ManagedStatus,
enabled: bool,
) -> BTreeSet<TargetInstance> { ) -> BTreeSet<TargetInstance> {
if !enabled {
return BTreeSet::new();
}
let targets = status let targets = status
.targets .targets
.iter() .iter()
@@ -426,25 +605,43 @@ async fn run_stdin_stream(
setup: ManagedRunSetup, setup: ManagedRunSetup,
output_config: OutputConfig, output_config: OutputConfig,
mut shutdown: tokio::sync::watch::Receiver<bool>, mut shutdown: tokio::sync::watch::Receiver<bool>,
args: &ClientArgs,
output: &mut StdoutWriter,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if *shutdown.borrow() { if *shutdown.borrow() {
return Ok(()); return Ok(());
} }
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?; let (task, handle) = ManagedClient::task(
{
let mut config = setup.managed_config();
config.reconnect_timeout = args.reconnect.then_some(args.reconnect_timeout);
config.reconnect_initial_delay = args.reconnect_initial_delay;
config.reconnect_max_delay = args.reconnect_max_delay;
config
},
setup.managed_targets(),
)?;
let mut events = handle.subscribe()?; let mut events = handle.subscribe()?;
let mut status = handle.subscribe_status(); let mut status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?; let owner = ManagedWorker::start(task, handle.clone())?;
let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new())); let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
spawn_stdin_target_reader(updates)?; spawn_stdin_target_reader(updates)?;
let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stdout = Vec::new();
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats,
config: output_config, config: output_config,
header_printed: false, header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false, print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
}; };
#[cfg(feature = "stats")]
let mut stats = BTreeMap::new(); let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT); let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut stdin_changed = false; let mut stdin_changed = false;
@@ -458,13 +655,23 @@ async fn run_stdin_stream(
let mut dropped_events = 0_u64; let mut dropped_events = 0_u64;
let mut stop_requested = false; let mut stop_requested = false;
let mut stop = None; let mut stop = None;
#[cfg(feature = "stats")]
let mut summary_targets = None; let mut summary_targets = None;
loop { loop {
let snapshot = status.borrow_and_update().clone(); let snapshot = status.borrow_and_update().clone();
if stop.is_none() {
if *shutdown.borrow() { if *shutdown.borrow() {
stop = Some(StdinStop::Interrupted); stop = Some(StdinStop::Interrupted);
} else if stdin_changed || stdin.has_changed().unwrap_or(true) { #[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 stdin_changed || stdin.has_changed().unwrap_or(true) {
stdin_changed = false; stdin_changed = false;
match stdin.borrow_and_update().clone() { match stdin.borrow_and_update().clone() {
StdinUpdate::Targets(specs) => { StdinUpdate::Targets(specs) => {
@@ -509,18 +716,26 @@ async fn run_stdin_stream(
} }
if stop.is_some() { if stop.is_some() {
preparing = None; preparing = None;
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot)); #[cfg(feature = "stats")]
{
summary_targets = Some(snapshot_stdin_summary_targets(
&mut stats,
&snapshot,
!args.no_stats,
));
}
if request_managed_stop_once(&mut stop_requested) { if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop()); drop(handle.stop());
} }
} }
} }
#[cfg(feature = "stats")]
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref()); reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
if snapshot.final_outcome.is_some() { if snapshot.final_outcome.is_some() {
break; break;
} }
tokio::select! { tokio::select! {
_ = shutdown.changed(), if stop.is_none() => {} _ = shutdown.changed() => {}
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; } _ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
changed = status.changed() => { if changed.is_err() { break; } } 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() => { result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
@@ -536,7 +751,10 @@ async fn run_stdin_stream(
} }
Err(error) => { Err(error) => {
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}"))); stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow())); #[cfg(feature = "stats")]
{
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats));
}
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
} }
} }
@@ -557,15 +775,21 @@ async fn run_stdin_stream(
} }
Err(_) => { Err(_) => {
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned())); stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow())); #[cfg(feature = "stats")]
{
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats));
}
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
} }
} }
} }
event = events.recv() => match event { event = events.recv() => match event {
Ok(event) => { Ok(event) => {
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?; 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()); 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)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count); dropped_events = dropped_events.saturating_add(count);
@@ -576,9 +800,24 @@ async fn run_stdin_stream(
} }
let outcome = owner.join().await?; let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| { while !*shutdown.borrow() {
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets) 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) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
@@ -594,7 +833,17 @@ async fn run_stdin_stream(
); );
} }
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof)); 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 =
!args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
stream_output.print_final_summary = print_summary; stream_output.print_final_summary = print_summary;
stream_output.show_running_only_summary_note = print_summary; stream_output.show_running_only_summary_note = print_summary;
if let Some(summary_targets) = summary_targets { if let Some(summary_targets) = summary_targets {
@@ -612,9 +861,11 @@ async fn run_stdin_stream(
)?; )?;
} }
stream_output.print_summary(stats)?; 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 { match stop {
Some(StdinStop::Fatal(error)) => Err(error.into()), Some(StdinStop::Fatal(error)) => Err(error.into()),
@@ -630,16 +881,28 @@ async fn run_stdin_stream(
fn process_event<W: Write>( fn process_event<W: Write>(
event: ManagedEvent, event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>, stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>, #[cfg(feature = "stats")] stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>, terminal_targets: &mut HashSet<TargetInstance>,
) -> io::Result<()> { ) -> io::Result<()> {
match event { match event {
ManagedEvent::Client { target, event } => { ManagedEvent::Client { target, event } => {
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| {
let collector = stats let collector = stats
.entry(target.id.as_str().to_owned()) .get_mut(target.id.as_str())
.or_insert_with(|| StatsCollector::new(stats_config(false))); .expect("prepared target has a statistics collector");
let update = collector.process(&event); if matches!(event, ClientEvent::SessionStarted(_)) {
stream_output.print_event(&event, Some(target.id.as_str()), &update)?; let config = stats_config(stream_output.continuous);
*collector = StatsCollector::new(config);
}
collector.process(&event)
});
stream_output.print_event(
&event,
Some(target.id.as_str()),
#[cfg(feature = "stats")]
update.as_ref(),
)?;
} }
ManagedEvent::TargetFinished { outcome } => { ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone()); terminal_targets.insert(outcome.target.clone());
@@ -673,13 +936,24 @@ fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
messages messages
} }
fn dropped_event_warning(dropped_events: u64) -> Option<String> { fn dropped_event_warning(dropped_events: u64) -> Option<String> {
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" })) let affected = if cfg!(feature = "stats") {
"output and statistics"
} else {
"output"
};
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; {affected} may be incomplete", if dropped_events == 1 { "" } else { "s" }))
} }
struct StreamOutput<'a, W: Write> { struct StreamOutput<'a, W: Write> {
#[cfg(feature = "stats")]
continuous: bool,
#[cfg(feature = "stats")]
collect_stats: bool,
config: OutputConfig, config: OutputConfig,
header_printed: bool, header_printed: bool,
#[cfg(feature = "stats")]
print_final_summary: bool, print_final_summary: bool,
#[cfg(feature = "stats")]
show_running_only_summary_note: bool, show_running_only_summary_note: bool,
out: &'a mut W, out: &'a mut W,
} }
@@ -688,10 +962,15 @@ impl<W: Write> StreamOutput<'_, W> {
&mut self, &mut self,
event: &ClientEvent, event: &ClientEvent,
target: Option<&str>, target: Option<&str>,
update: &EventStatsUpdate, #[cfg(feature = "stats")] update: Option<&EventStatsUpdate>,
) -> io::Result<()> { ) -> io::Result<()> {
self.print_header()?; self.print_header()?;
if let Some(line) = self.config.render_event(event, target, Some(update)) { if let Some(line) = self.config.render_event(
event,
target,
#[cfg(feature = "stats")]
update,
) {
writeln!(self.out, "{line}")?; writeln!(self.out, "{line}")?;
} }
Ok(()) Ok(())
@@ -707,6 +986,7 @@ impl<W: Write> StreamOutput<'_, W> {
Ok(()) Ok(())
} }
#[cfg(feature = "stats")]
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> { fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if self.print_final_summary && self.config.prints_summary() { if self.print_final_summary && self.config.prints_summary() {
write!( write!(
@@ -725,6 +1005,7 @@ impl<W: Write> StreamOutput<'_, W> {
} }
} }
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig { fn stats_config(continuous: bool) -> StatsConfig {
if continuous { if continuous {
StatsConfig::continuous() StatsConfig::continuous()
@@ -732,8 +1013,18 @@ fn stats_config(continuous: bool) -> StatsConfig {
StatsConfig::finite() StatsConfig::finite()
} }
} }
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> { fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() { const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
if args.no_stats || args.no_test || args.is_continuous() || args.duration.is_zero() {
return None; return None;
} }
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX); let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
@@ -760,3 +1051,8 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
}; };
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}")) Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
} }
#[cfg(feature = "stats")]
fn reconnect_stats_warning(args: &ClientArgs) -> Option<&'static str> {
(args.reconnect && !args.no_stats).then_some("irtt-rs: warning: statistics spanning multiple sessions may not accurately represent one uninterrupted measurement run; use --no-stats for measurement events only. Session statistics reset on reconnect.")
}
+4 -1
View File
@@ -57,12 +57,13 @@ pub(crate) fn format_ns_i128(value: i128) -> String {
} }
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. /// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String { pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(absent) value.map(format_ns_i128).unwrap_or_else(absent)
} }
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. /// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
#[cfg(feature = "client")] #[cfg(all(feature = "client", feature = "stats"))]
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String { pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent) value.map(format_ns_f64).unwrap_or_else(absent)
} }
@@ -80,6 +81,7 @@ pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
/// Format a percentage with two decimals; non-finite values render as /// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`]. /// [`ABSENT`].
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
pub(crate) fn format_percent(value: f64) -> String { pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() { if value.is_finite() {
format!("{value:.2}%") format!("{value:.2}%")
@@ -112,6 +114,7 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent) value.map(format_count).unwrap_or_else(absent)
} }
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
fn absent() -> String { fn absent() -> String {
ABSENT.to_owned() ABSENT.to_owned()
} }
+25 -16
View File
@@ -12,9 +12,10 @@ const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding // Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and // `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart. // CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)] #[derive(Clone, Parser)]
#[command( #[command(
name = "irtt-server", name = "irtt-server",
version,
about = "Minimal IRTT-compatible UDP server", about = "Minimal IRTT-compatible UDP server",
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces." after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
)] )]
@@ -28,13 +29,14 @@ pub struct ServerArgs {
)] )]
pub bind: Vec<SocketAddr>, pub bind: Vec<SocketAddr>,
// Decode during configuration preparation so Clap cannot echo rejected keys.
/// HMAC key; every request must then carry a valid MAC. /// HMAC key; every request must then carry a valid MAC.
#[arg( #[arg(
long, long,
value_name = "KEY", value_name = "KEY",
value_parser = parse_hmac_key,
env = "IRTT_SERVER_HMAC", env = "IRTT_SERVER_HMAC",
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: 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, or in the environment as IRTT_SERVER_HMAC." hide_env_values = true,
long_help = "HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes (digits may use either case). Empty keys and invalid hex are rejected.\n\nAuthentication is global: 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, or in the environment as IRTT_SERVER_HMAC."
)] )]
pub hmac: Option<String>, pub hmac: Option<String>,
@@ -126,6 +128,23 @@ pub struct ServerArgs {
pub no_dscp: bool, pub no_dscp: bool,
} }
impl std::fmt::Debug for ServerArgs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerArgs")
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
.field("bind", &self.bind)
.field("max_sessions", &self.max_sessions)
.field("max_packet_length", &self.max_packet_length)
.field("min_interval", &self.min_interval)
.field("burst", &self.burst)
.field("idle_timeout", &self.idle_timeout)
.field("max_duration", &self.max_duration)
.field("timestamp_allowance", &self.timestamp_allowance)
.field("no_dscp", &self.no_dscp)
.finish()
}
}
/// The command-line spelling of [`TimestampAllowance`]. /// The command-line spelling of [`TimestampAllowance`].
/// ///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server` /// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
@@ -166,11 +185,10 @@ impl ServerArgs {
} }
/// Builds the server configuration this invocation asks for. /// Builds the server configuration this invocation asks for.
#[must_use] pub fn server_config(&self) -> Result<ServerConfig, String> {
pub fn server_config(&self) -> ServerConfig {
let mut config = ServerConfig::default(); let mut config = ServerConfig::default();
if let Some(key) = self.hmac.as_ref() { if let Some(key) = self.hmac.as_ref() {
config = config.with_hmac_key(key.as_bytes()); config = config.with_hmac_key(crate::shared::decode_hmac_key(key)?);
} }
if let Some(value) = self.max_sessions { if let Some(value) = self.max_sessions {
config = config.with_max_sessions(value); config = config.with_max_sessions(value);
@@ -198,19 +216,10 @@ impl ServerArgs {
if self.no_dscp { if self.no_dscp {
config = config.with_dscp_allowed(false); config = config.with_dscp_allowed(false);
} }
config Ok(config)
} }
} }
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
/// enable authentication with predictable, empty key material.
fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
/// Parses a duration whose zero is meaningful server policy. /// Parses a duration whose zero is meaningful server policy.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> { fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" { if input == "0" {
+1 -1
View File
@@ -36,7 +36,7 @@ async fn serve(
args: ServerArgs, args: ServerArgs,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config(); let config = args.server_config()?;
let binds = args.resolve_binds(); let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty(); let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await { let set = match ServerSet::bind(binds.clone(), config.clone()).await {
+19 -6
View File
@@ -10,7 +10,11 @@ use crate::shared::client::{
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO; pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] #[command(
name = "irtt-tui",
version,
about = "Minimal IRTT-compatible TUI client"
)]
pub struct TuiArgs { pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. /// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg( #[arg(
@@ -18,7 +22,7 @@ pub struct TuiArgs {
num_args = 1.., num_args = 1..,
required = true, required = true,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example" long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
)] )]
pub targets: Vec<TargetArg>, pub targets: Vec<TargetArg>,
@@ -33,13 +37,18 @@ pub struct TuiArgs {
#[arg( #[arg(
long, long,
short = 'd',
default_value = "0", default_value = "0",
value_parser = parse_test_duration, value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode", help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode." long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted). The TUI defaults to continuous mode."
)] )]
pub duration: Duration, pub duration: Duration,
/// Run continuously until interrupted (equivalent to --duration 0).
#[arg(long, conflicts_with = "duration")]
pub continuous: bool,
#[command(flatten)] #[command(flatten)]
pub common: CommonClientArgs, pub common: CommonClientArgs,
} }
@@ -52,7 +61,11 @@ impl TuiArgs {
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> { pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run( prepare_managed_run(
&self.common, &self.common,
self.duration, if self.continuous {
Duration::ZERO
} else {
self.duration
},
TargetSelection { TargetSelection {
targets: &self.targets, targets: &self.targets,
pacing: self.pacing, pacing: self.pacing,
@@ -63,7 +76,7 @@ impl TuiArgs {
} }
pub fn is_continuous(&self) -> bool { pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO self.continuous || self.duration == Duration::ZERO
} }
} }
+5 -9
View File
@@ -89,11 +89,10 @@ fn with_shutdown_flag(
#[cfg(feature = "client")] #[cfg(feature = "client")]
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> { fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let shutdown = crate::signal::install_async_signal_handler() let shutdown = crate::signal::install_async_signal_handler()
.map_err(|err| format!("failed to install signal handler: {err}"))?; .map_err(|err| format!("failed to install signal handler: {err}"))?;
let args = crate::cmd::client::ClientArgs::parse_from(argv); let args =
crate::version::parse::<crate::cmd::client::ClientArgs>(argv, crate::version::client);
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build()? .build()?
@@ -107,10 +106,9 @@ fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> { fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| { with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::server::ServerArgs::parse_from(argv); let args =
crate::version::parse::<crate::cmd::server::ServerArgs>(argv, crate::version::server);
crate::cmd::server::run_server(args, shutdown_requested) crate::cmd::server::run_server(args, shutdown_requested)
}) })
} }
@@ -122,11 +120,9 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> { fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let shutdown = crate::signal::install_async_signal_handler() let shutdown = crate::signal::install_async_signal_handler()
.map_err(|err| format!("failed to install signal handler: {err}"))?; .map_err(|err| format!("failed to install signal handler: {err}"))?;
let args = crate::cmd::tui::TuiArgs::parse_from(argv); let args = crate::version::parse::<crate::cmd::tui::TuiArgs>(argv, crate::version::client);
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build()? .build()?
+2
View File
@@ -6,3 +6,5 @@ pub mod cmd;
pub mod entry; pub mod entry;
pub mod shared; pub mod shared;
pub mod signal; pub mod signal;
mod version;
+65 -64
View File
@@ -1,4 +1,4 @@
use std::{fmt, time::Duration}; use std::{fmt, net::IpAddr, net::SocketAddr, time::Duration};
use clap::ValueEnum; use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH}; use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
@@ -18,16 +18,21 @@ pub struct CommonClientArgs {
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")] #[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
pub dual_stack: bool, pub dual_stack: bool,
/// Probe interval. /// Positive probe interval (e.g. 2.5s, 1m30s, 500us, 100ns, 1h).
#[arg(long, default_value = "1s", value_parser = parse_duration)] #[arg(short = 'i', long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration, pub interval: Duration,
/// Comma-separated session-opening timeouts, one per attempt (each >= 200ms).
#[arg(long, value_name = "DURATIONS", value_delimiter = ',', value_parser = parse_duration, long_help = "Comma-separated session-opening receive timeouts, one per attempt (each at least 200ms), e.g. 1s,2s,4s,8s. Omit to use the library default retry schedule.")]
pub timeouts: Vec<Duration>,
/// UDP payload length. /// UDP payload length.
#[arg(long, default_value_t = 0, value_parser = parse_length)] #[arg(short = 'l', long, default_value_t = 0, value_parser = parse_length)]
pub length: u32, pub length: u32,
/// HMAC key. // Decode during configuration preparation so Clap cannot echo rejected keys.
#[arg(long, value_parser = parse_hmac_key)] /// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
#[arg(long)]
pub hmac: Option<String>, pub hmac: Option<String>,
/// Clock mode to request. /// Clock mode to request.
@@ -70,6 +75,38 @@ pub struct CommonClientArgs {
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)] #[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>, pub ttl: Option<u32>,
#[arg(
long = "bind-addr",
value_name = "IP[:PORT]",
value_parser = parse_bind_addr,
long_help = "Local IP address and optional UDP port to bind. \
If no port is specified, the operating system selects \
an ephemeral port. IPv6 addresses with an explicit \
port must use [ADDR]:PORT syntax."
)]
pub bind_addr: Option<SocketAddr>,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long = "bind-device",
value_name = "DEVICE",
help = "Bind the socket to a network device"
)]
pub bind_to_device: Option<String>,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long,
value_name = "MARK",
value_parser = clap_num::maybe_hex::<u32>,
help = "Set the outgoing socket firewall mark"
)]
pub mark: Option<u32>,
#[cfg(target_os = "freebsd")]
#[arg(long, value_name = "FIB", help = "Set the socket routing table (FIB)")]
pub fib: Option<u32>,
/// Accept safe server restrictions during negotiation. /// Accept safe server restrictions during negotiation.
#[arg(long)] #[arg(long)]
pub loose: bool, pub loose: bool,
@@ -77,11 +114,13 @@ pub struct CommonClientArgs {
impl fmt::Debug for CommonClientArgs { impl fmt::Debug for CommonClientArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommonClientArgs") let mut debug = f.debug_struct("CommonClientArgs");
debug
.field("ipv4", &self.ipv4) .field("ipv4", &self.ipv4)
.field("ipv6", &self.ipv6) .field("ipv6", &self.ipv6)
.field("dual_stack", &self.dual_stack) .field("dual_stack", &self.dual_stack)
.field("interval", &self.interval) .field("interval", &self.interval)
.field("timeouts", &self.timeouts)
.field("length", &self.length) .field("length", &self.length)
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]")) .field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
.field("clock", &self.clock) .field("clock", &self.clock)
@@ -90,8 +129,14 @@ impl fmt::Debug for CommonClientArgs {
.field("server_fill", &self.server_fill) .field("server_fill", &self.server_fill)
.field("dscp", &self.dscp) .field("dscp", &self.dscp)
.field("ttl", &self.ttl) .field("ttl", &self.ttl)
.field("loose", &self.loose) .field("bind_addr", &self.bind_addr);
.finish() #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
debug
.field("bind_to_device", &self.bind_to_device)
.field("mark", &self.mark);
#[cfg(target_os = "freebsd")]
debug.field("fib", &self.fib);
debug.field("loose", &self.loose).finish()
} }
} }
@@ -152,63 +197,26 @@ impl From<ReceivedStatsArg> for ReceivedStats {
} }
} }
fn parse_bind_addr(input: &str) -> Result<SocketAddr, String> {
input
.parse::<SocketAddr>()
.or_else(|_| input.parse::<IpAddr>().map(|ip| SocketAddr::new(ip, 0)))
.map_err(|_| "expected an IP address, optionally with a port".to_owned())
}
pub fn parse_duration(input: &str) -> Result<Duration, String> { pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?; let duration = parse_test_duration(input)?;
let value: u64 = number if duration.is_zero() {
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned()); return Err("duration must be greater than zero".to_owned());
} }
match unit { Ok(duration)
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
} }
pub fn parse_test_duration(input: &str) -> Result<Duration, String> { pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" { if input == "0" {
return Ok(Duration::ZERO); return Ok(Duration::ZERO);
} }
let (number, unit) = split_duration(input)?; humantime::parse_duration(input).map_err(|error| error.to_string())
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Ok(Duration::ZERO);
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
} }
pub fn parse_length(input: &str) -> Result<u32, String> { pub fn parse_length(input: &str) -> Result<u32, String> {
@@ -221,13 +229,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
Ok(length) Ok(length)
} }
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
pub fn parse_server_fill(input: &str) -> Result<String, String> { pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() { if input.is_empty() {
return Err("server fill must not be empty".to_owned()); return Err("server fill must not be empty".to_owned());
+22 -9
View File
@@ -21,17 +21,22 @@ impl CommonClientArgs {
/// Build the shared client configuration template for a managed run. /// Build the shared client configuration template for a managed run.
/// ///
/// The config is endpoint-independent; managed targets own their addresses. /// The config is endpoint-independent; managed targets own their addresses.
pub fn to_client_config(&self, duration: Duration) -> ClientConfig { pub fn to_client_config(&self, duration: Duration) -> Result<ClientConfig, String> {
ClientConfig { let mut open = OpenPolicy {
address_family: self.address_family(),
open: OpenPolicy {
negotiation: if self.loose { negotiation: if self.loose {
NegotiationPolicy::Loose NegotiationPolicy::Loose
} else { } else {
NegotiationPolicy::Strict NegotiationPolicy::Strict
}, },
..Default::default() ..Default::default()
}, };
if !self.timeouts.is_empty() {
open.timeouts.clone_from(&self.timeouts);
}
Ok(ClientConfig {
address_family: self.address_family(),
open,
request: SessionRequest { request: SessionRequest {
duration: (!duration.is_zero()).then_some(duration), duration: (!duration.is_zero()).then_some(duration),
interval: self.interval, interval: self.interval,
@@ -45,16 +50,24 @@ impl CommonClientArgs {
}, },
auth: self auth: self
.hmac .hmac
.as_ref() .as_deref()
.map(crate::shared::decode_hmac_key)
.transpose()?
.map_or(Authentication::Unauthenticated, |key| { .map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key.as_bytes())) Authentication::Hmac(HmacKey::new(key))
}), }),
socket: SocketConfig { socket: SocketConfig {
bind_addr: self.bind_addr,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
bind_to_device: self.bind_to_device.clone(),
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
mark: self.mark,
#[cfg(target_os = "freebsd")]
fib: self.fib,
ttl: self.ttl, ttl: self.ttl,
..SocketConfig::default()
}, },
..ClientConfig::default() ..ClientConfig::default()
} })
} }
} }
+1 -1
View File
@@ -125,7 +125,7 @@ pub async fn prepare_managed_run(
.await?; .await?;
Ok(ManagedRunSetup { Ok(ManagedRunSetup {
targets, targets,
client: common.to_client_config(duration), client: common.to_client_config(duration)?,
pacing: selection.pacing.into(), pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled, stdin_controlled: selection.stdin_controlled,
preparation, preparation,
@@ -1,5 +1,7 @@
#[cfg(feature = "tui")]
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError}; use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
#[cfg(feature = "tui")]
pub(crate) fn drain_final_events<E>( pub(crate) fn drain_final_events<E>(
events: &mut ManagedEventSubscription, events: &mut ManagedEventSubscription,
dropped_events: &mut u64, dropped_events: &mut u64,
+7 -2
View File
@@ -88,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum TargetParseError { enum TargetParseError {
EmptyLabel, EmptyLabel,
EmptyAddress, EmptyAddress,
InvalidEscape, InvalidEscape,
EmptyTarget, EmptyTarget,
InvalidHmac(String),
} }
impl fmt::Display for TargetParseError { impl fmt::Display for TargetParseError {
@@ -103,6 +104,7 @@ impl fmt::Display for TargetParseError {
Self::EmptyAddress => "target address must not be empty", Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence", Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty", Self::EmptyTarget => "target must not be empty",
Self::InvalidHmac(message) => message,
}; };
f.write_str(message) f.write_str(message)
} }
@@ -197,7 +199,10 @@ fn parse_target_syntax(
let auth = if value.is_empty() { let auth = if value.is_empty() {
TargetAuth::Override(Authentication::Unauthenticated) TargetAuth::Override(Authentication::Unauthenticated)
} else { } else {
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes()))) TargetAuth::Override(Authentication::Hmac(HmacKey::new(
crate::shared::decode_hmac_key(&value)
.map_err(TargetParseError::InvalidHmac)?,
)))
}; };
(&input[..index], auth) (&input[..index], auth)
} }
+14
View File
@@ -0,0 +1,14 @@
/// Decode CLI key syntax without ever including key material in diagnostics.
pub(crate) fn decode_hmac_key(input: &str) -> Result<Vec<u8>, String> {
let key = match input.strip_prefix("0x") {
Some(hex) => hex::decode(hex).map_err(|_| {
"HMAC hexadecimal key must contain an even number of valid hexadecimal digits"
.to_owned()
})?,
None => input.as_bytes().to_vec(),
};
if key.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(key)
}
+5
View File
@@ -1,2 +1,7 @@
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(any(feature = "client", feature = "tui"))]
pub mod client; pub mod client;
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
mod hmac;
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
pub(crate) use hmac::decode_hmac_key;
+44
View File
@@ -0,0 +1,44 @@
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
use std::ffi::OsString;
pub(crate) fn app() -> String {
clap::Command::new("irtt-rs")
.version(env!("CARGO_PKG_VERSION"))
.render_version()
}
// Preserve Clap's version banner and append the compiled component versions.
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
pub(crate) fn parse<T: clap::Parser>(argv: Vec<OsString>, report: fn() -> String) -> T {
match T::try_parse_from(argv) {
Ok(args) => args,
Err(err) if err.kind() == clap::error::ErrorKind::DisplayVersion => {
print!("{}{}", err.render().ansi(), report());
std::process::exit(0)
}
Err(err) => err.exit(),
}
}
#[cfg(any(feature = "client", feature = "tui"))]
pub(crate) fn client() -> String {
#[cfg(feature = "stats")]
let stats = format!(" stats: {}\n", irtt_stats::VERSION);
#[cfg(not(feature = "stats"))]
let stats = " stats: disabled\n";
format!(
" client: {}\n{} proto: {}\n",
irtt_client::VERSION,
stats,
irtt_proto::VERSION,
)
}
#[cfg(feature = "server")]
pub(crate) fn server() -> String {
format!(
" server: {}\n proto: {}\n",
irtt_server::VERSION,
irtt_proto::VERSION,
)
}
+2 -3
View File
@@ -1,12 +1,11 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.6.0" version = "0.7.0"
description = "IRTT-compatible Rust client library" description = "IRTT-compatible Rust client library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"] keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["network-programming"] categories = ["network-programming"]
@@ -15,7 +14,7 @@ categories = ["network-programming"]
irtt-proto.workspace = true irtt-proto.workspace = true
socket2 = { version = "0.6.5", features = ["all"] } socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.21" thiserror = "2.0.21"
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync"], optional = true} tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true } nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
+15
View File
@@ -35,6 +35,21 @@ Use `ClientEvent::from(receipt)` for an `EchoSent` event with `scheduled_at` and
`timer_error` absent; the managed driver supplies those schedule measurements `timer_error` absent; the managed driver supplies those schedule measurements
and publishes committed sends even when post-send processing fails. and publishes committed sends even when post-send processing fails.
Managed continuous measurement runs can opt into automatic recovery with
`ManagedClientConfig.reconnect_timeout: Some(timeout)`. Initial connection
failures, closed/failed sessions, server-limited completion, and reply silence
while sending trigger independent per-target retries. `reconnect_initial_delay`
(default 250 ms) doubles to `reconnect_max_delay` (default 10 s); a valid reply
resets the delay. Both delays must be positive and schedulable, with the maximum
at least twice the initial delay. Explicit stop and target removal cancel retries.
Recovery is disabled by default and is invalid for finite or negotiation-only runs.
In 0.7, complete `ManagedClientConfig` literals must include the three recovery
fields (or use `..Default::default()`), and exhaustive `ManagedConfigError`
matches must handle the new recovery-policy variants. A target can emit repeated
session-start events within one instance; reset session-bound state, including
statistics, for each reopened session. Use `irtt-stats` 0.6 with client 0.7 events.
Tokio stays optional: the default build has no runtime and no Tokio Tokio stays optional: the default build has no runtime and no Tokio
dependency at all. dependency at all.
+10 -23
View File
@@ -93,8 +93,6 @@ pub(crate) struct AsyncOpenState {
deadline: Option<Instant>, deadline: Option<Instant>,
deadline_timer: Option<Pin<Box<time::Sleep>>>, deadline_timer: Option<Pin<Box<time::Sleep>>>,
request_submitted: bool, request_submitted: bool,
stop_after_current_attempt: bool,
stopped_after_current_attempt: bool,
buffer: [u8; MAX_OPEN_PACKET_SIZE], buffer: [u8; MAX_OPEN_PACKET_SIZE],
cleanup: Option<AsyncOpenCleanup>, cleanup: Option<AsyncOpenCleanup>,
} }
@@ -106,8 +104,6 @@ impl AsyncOpenState {
deadline: None, deadline: None,
deadline_timer: None, deadline_timer: None,
request_submitted: false, request_submitted: false,
stop_after_current_attempt: false,
stopped_after_current_attempt: false,
buffer: [0_u8; MAX_OPEN_PACKET_SIZE], buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
cleanup: None, cleanup: None,
} }
@@ -128,21 +124,12 @@ impl AsyncOpenState {
self.deadline = None; self.deadline = None;
self.deadline_timer = None; self.deadline_timer = None;
self.request_submitted = false; self.request_submitted = false;
self.stopped_after_current_attempt = self.stop_after_current_attempt;
} }
pub(crate) fn has_in_flight_work(&self) -> bool { pub(crate) fn has_in_flight_work(&self) -> bool {
self.request_submitted || self.cleanup.is_some() self.request_submitted || self.cleanup.is_some()
} }
pub(crate) fn request_stop_after_current_attempt(&mut self) {
self.stop_after_current_attempt = true;
}
pub(crate) fn stopped_after_current_attempt(&self) -> bool {
self.stopped_after_current_attempt
}
fn deadline(&self) -> Instant { fn deadline(&self) -> Instant {
self.deadline self.deadline
.expect("an open attempt has an active deadline") .expect("an open attempt has an active deadline")
@@ -324,10 +311,6 @@ impl AsyncClient {
if state.attempt >= attempt_count { if state.attempt >= attempt_count {
return Poll::Ready(Err(ClientError::OpenTimeout)); return Poll::Ready(Err(ClientError::OpenTimeout));
} }
if state.stop_after_current_attempt && state.deadline.is_none() {
state.stopped_after_current_attempt = true;
return Poll::Ready(Err(ClientError::OpenTimeout));
}
if state.deadline.is_none() { if state.deadline.is_none() {
let timeout = self.machine.config().open.timeouts[state.attempt]; let timeout = self.machine.config().open.timeouts[state.attempt];
if let Err(error) = state.start_attempt(timeout) { if let Err(error) = state.start_attempt(timeout) {
@@ -368,9 +351,6 @@ impl AsyncClient {
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Ready(Ok(false)) => { Poll::Ready(Ok(false)) => {
state.finish_attempt(); state.finish_attempt();
if state.stopped_after_current_attempt() {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
continue; continue;
} }
Poll::Ready(Ok(true)) => {} Poll::Ready(Ok(true)) => {}
@@ -396,9 +376,6 @@ impl AsyncClient {
}; };
if datagram.received_at.mono > state.deadline() { if datagram.received_at.mono > state.deadline() {
state.finish_attempt(); state.finish_attempt();
if state.stopped_after_current_attempt() {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
continue; continue;
} }
@@ -778,6 +755,16 @@ impl AsyncClient {
.map_err(ClientError::Socket) .map_err(ClientError::Socket)
} }
/// Cancel opening without waiting for a reply or socket writability.
/// Retained post-token cleanup gets one best-effort nonblocking send.
pub(crate) fn cancel_open(&self, state: &mut AsyncOpenState) -> Option<ClientError> {
let cleanup = state.cleanup.take()?;
if let Some(packet) = cleanup.packet() {
let _ = self.socket.try_send(packet);
}
Some(cleanup.into_primary())
}
fn poll_open_cleanup( fn poll_open_cleanup(
&self, &self,
state: &mut AsyncOpenState, state: &mut AsyncOpenState,
+3
View File
@@ -47,6 +47,9 @@
//! //!
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(feature = "tokio")] #[cfg(feature = "tokio")]
mod async_client; mod async_client;
mod auth; mod auth;
+19
View File
@@ -101,6 +101,15 @@ pub struct ManagedClientConfig {
pub outcome_history_limit: usize, pub outcome_history_limit: usize,
pub max_live_target_generations: usize, pub max_live_target_generations: usize,
pub final_drain: Duration, pub final_drain: Duration,
/// Retry failed/closed sessions and detect silence while sending. Only valid
/// for continuous measurement runs. None (the default) disables reconnection.
pub reconnect_timeout: Option<Duration>,
/// First retry delay, also restored after a valid reply (default: 250 ms).
/// Must be positive and at most half of `reconnect_max_delay`.
pub reconnect_initial_delay: Duration,
/// Cap for per-target exponential backoff with a fixed multiplier of two
/// (default: 10 seconds). Must be schedulable from the current instant.
pub reconnect_max_delay: Duration,
} }
impl Default for ManagedClientConfig { impl Default for ManagedClientConfig {
@@ -114,6 +123,9 @@ impl Default for ManagedClientConfig {
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT, outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS, max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
final_drain: DEFAULT_MANAGED_FINAL_DRAIN, final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
reconnect_timeout: None,
reconnect_initial_delay: Duration::from_millis(250),
reconnect_max_delay: Duration::from_secs(10),
} }
} }
} }
@@ -364,6 +376,13 @@ pub struct ManagedTargetFailure {
/// Configuration failure detected before any runtime work begins. /// Configuration failure detected before any runtime work begins.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ManagedConfigError { pub enum ManagedConfigError {
#[error("reconnection requires continuous measurement mode and a positive, schedulable silence timeout")]
InvalidReconnectPolicy,
#[error("reconnection delays must be positive and schedulable, with maximum delay at least twice the initial delay (initial: {initial:?}, maximum: {maximum:?})")]
InvalidReconnectDelays {
initial: Duration,
maximum: Duration,
},
#[error("FinishWhenQuiescent requires at least one initial target")] #[error("FinishWhenQuiescent requires at least one initial target")]
EmptyInitialTargets, EmptyInitialTargets,
#[error("duplicate managed target id {id}")] #[error("duplicate managed target id {id}")]
+7
View File
@@ -327,7 +327,14 @@ impl ManagedClientTask {
let mut non_send_deadline = None; let mut non_send_deadline = None;
let mut send_deadline = None; let mut send_deadline = None;
for target in &self.targets { for target in &self.targets {
non_send_deadline = non_send_deadline
.into_iter()
.chain(target.silence_deadline(self.config.reconnect_timeout))
.min();
match target.phase() { match target.phase() {
TargetPhase::Retrying { deadline } => {
non_send_deadline = non_send_deadline.into_iter().chain(Some(*deadline)).min();
}
TargetPhase::Active { TargetPhase::Active {
client, client,
schedule, schedule,
@@ -367,6 +367,35 @@ fn build_task(
maximum: tokio::sync::Semaphore::MAX_PERMITS, maximum: tokio::sync::Semaphore::MAX_PERMITS,
}); });
} }
if config.reconnect_initial_delay.is_zero()
|| config.reconnect_max_delay.is_zero()
|| config
.reconnect_initial_delay
.checked_mul(2)
.is_none_or(|minimum| config.reconnect_max_delay < minimum)
// Tokio represents timer deadlines in u64 milliseconds, reserving the
// top two values for state. Leave room for its millisecond rounding.
|| config.reconnect_max_delay.as_millis() >= u128::from(u64::MAX - 2)
|| config
.reconnect_max_delay
.checked_add(std::time::Duration::from_millis(1))
.and_then(|delay| Instant::now().checked_add(delay))
.is_none()
{
return Err(ManagedConfigError::InvalidReconnectDelays {
initial: config.reconnect_initial_delay,
maximum: config.reconnect_max_delay,
});
}
if let Some(timeout) = config.reconnect_timeout {
if timeout.is_zero()
|| Instant::now().checked_add(timeout).is_none()
|| config.client.request.duration.is_some()
|| config.client.request.run_mode != crate::RunMode::Normal
{
return Err(ManagedConfigError::InvalidReconnectPolicy);
}
}
if config.max_live_target_generations == 0 { if config.max_live_target_generations == 0 {
return Err(ManagedConfigError::ZeroLiveGenerationLimit); return Err(ManagedConfigError::ZeroLiveGenerationLimit);
} }
@@ -417,6 +446,10 @@ fn build_task(
server_addr: Arc::from(target.server_addr), server_addr: Arc::from(target.server_addr),
remote: None, remote: None,
counters: TargetCounters::default(), counters: TargetCounters::default(),
sent_before_session: 0,
retry_delay: config.reconnect_initial_delay,
silence_since: None,
last_reply_at: None,
phase: Some(TargetPhase::Pending { client_config }), phase: Some(TargetPhase::Pending { client_config }),
}); });
} }
@@ -206,6 +206,12 @@ impl ManagedClientTask {
}; };
self.targets[index].sync_packets_sent(&client); self.targets[index].sync_packets_sent(&client);
send_waiting = result.is_pending(); send_waiting = result.is_pending();
if send_waiting {
// A blocked sender is not evidence of an unresponsive peer. Arm
// a fresh silence period only after transmission resumes.
self.targets[index].silence_since = None;
self.targets[index].last_reply_at = None;
}
self.targets[index].restore_phase(TargetPhase::Active { self.targets[index].restore_phase(TargetPhase::Active {
client, client,
schedule, schedule,
+142 -52
View File
@@ -5,7 +5,7 @@ use std::{
pin::Pin, pin::Pin,
sync::Arc, sync::Arc,
task::{Context, Poll}, task::{Context, Poll},
time::Instant, time::{Duration, Instant},
}; };
use crate::{ use crate::{
@@ -27,6 +27,9 @@ type ConnectFuture =
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>; Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
pub(super) enum TargetPhase { pub(super) enum TargetPhase {
Retrying {
deadline: Instant,
},
Pending { Pending {
client_config: ClientConfig, client_config: ClientConfig,
}, },
@@ -65,6 +68,7 @@ pub(super) enum TargetPhase {
impl TargetPhase { impl TargetPhase {
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle { pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
match self { match self {
Self::Retrying { .. } => ManagedTargetLifecycle::Pending,
Self::Pending { .. } => ManagedTargetLifecycle::Pending, Self::Pending { .. } => ManagedTargetLifecycle::Pending,
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting, Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
Self::Opening { .. } => ManagedTargetLifecycle::Opening, Self::Opening { .. } => ManagedTargetLifecycle::Opening,
@@ -182,11 +186,30 @@ pub(super) struct TargetRuntime {
pub(super) server_addr: Arc<str>, pub(super) server_addr: Arc<str>,
pub(super) remote: Option<std::net::SocketAddr>, pub(super) remote: Option<std::net::SocketAddr>,
pub(super) counters: TargetCounters, pub(super) counters: TargetCounters,
pub(super) sent_before_session: u64,
pub(super) retry_delay: Duration,
pub(super) silence_since: Option<Instant>,
pub(super) last_reply_at: Option<Instant>,
// None only while a synchronous operation owns the phase locally. // None only while a synchronous operation owns the phase locally.
pub(super) phase: Option<TargetPhase>, pub(super) phase: Option<TargetPhase>,
} }
impl TargetRuntime { impl TargetRuntime {
pub(super) fn silence_deadline(&self, timeout: Option<Duration>) -> Option<Instant> {
let TargetPhase::Active {
schedule,
send_waiting,
..
} = self.phase()
else {
return None;
};
if *send_waiting || schedule.is_finished() {
return None;
}
self.silence_since?.checked_add(timeout?)
}
pub(super) fn phase(&self) -> &TargetPhase { pub(super) fn phase(&self) -> &TargetPhase {
self.phase self.phase
.as_ref() .as_ref()
@@ -218,16 +241,18 @@ impl TargetRuntime {
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. }) self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
} }
/// Adopt the underlying client's authoritative sent count. /// Combine the current client's authoritative sent count with retired
/// /// sessions, so one target generation keeps durable accounting on retries.
/// The client owns `packets_sent`; this runtime only mirrors it so a
/// terminal outcome can report it after the client is gone.
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) { pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
self.counters.packets_sent = client.packets_sent(); self.counters.packets_sent = self
.sent_before_session
.saturating_add(client.packets_sent());
} }
pub(super) fn sync_live_packets_sent(&mut self) { pub(super) fn sync_live_packets_sent(&mut self) {
self.counters.packets_sent = self.phase().client().packets_sent(); self.counters.packets_sent = self
.sent_before_session
.saturating_add(self.phase().client().packets_sent());
} }
/// Build this target's durable outcome. /// Build this target's durable outcome.
@@ -284,6 +309,30 @@ impl ManagedClientTask {
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) { pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
for event in events { for event in events {
match &event {
ClientEvent::EchoSent { sent_at, .. } => {
let target = &mut self.targets[index];
let anchor = target
.last_reply_at
.filter(|reply| {
self.config.reconnect_timeout.is_some_and(|timeout| {
sent_at.mono.duration_since(*reply) < timeout
})
})
.unwrap_or(sent_at.mono);
target.silence_since.get_or_insert(anchor);
}
ClientEvent::EchoReply { received_at, .. }
| ClientEvent::LateReply { received_at, .. }
| ClientEvent::DuplicateReply { received_at, .. } => {
// A reply disarms silence until another probe is committed.
// A healthy sender with a long interval can remain idle.
self.targets[index].silence_since = None;
self.targets[index].last_reply_at = Some(received_at.mono);
self.targets[index].retry_delay = self.config.reconnect_initial_delay;
}
_ => {}
}
self.targets[index].counters.observe(&event); self.targets[index].counters.observe(&event);
self.publish_event(ManagedEvent::Client { self.publish_event(ManagedEvent::Client {
target: self.targets[index].instance.clone(), target: self.targets[index].instance.clone(),
@@ -295,9 +344,33 @@ impl ManagedClientTask {
fn finish_target( fn finish_target(
&mut self, &mut self,
index: usize, index: usize,
end_reason: ManagedTargetEndReason, mut end_reason: ManagedTargetEndReason,
cleanup_failure: Option<ManagedTargetFailure>, cleanup_failure: Option<ManagedTargetFailure>,
) { ) {
if self.can_reconnect(index)
&& matches!(
end_reason,
ManagedTargetEndReason::Failed(_)
| ManagedTargetEndReason::PeerClosed
| ManagedTargetEndReason::TestComplete
)
{
let target = &mut self.targets[index];
target.sent_before_session = target.counters.packets_sent;
target.silence_since = None;
target.last_reply_at = None;
if let Some(deadline) = Instant::now().checked_add(target.retry_delay) {
target.retry_delay = target
.retry_delay
.checked_mul(2)
.unwrap_or(self.config.reconnect_max_delay)
.min(self.config.reconnect_max_delay);
self.install_target_phase(index, TargetPhase::Retrying { deadline });
return;
}
// A delay validated at construction may no longer fit after a long run.
end_reason = ManagedTargetEndReason::Failed(duration_overflow_failure());
}
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure)); let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
// Finalize history before the phase publisher installs Terminal, updates // Finalize history before the phase publisher installs Terminal, updates
// pacing and publishes one complete status followed by the lifecycle event. // pacing and publishes one complete status followed by the lifecycle event.
@@ -311,6 +384,12 @@ impl ManagedClientTask {
self.publish_event(ManagedEvent::TargetFinished { outcome }); self.publish_event(ManagedEvent::TargetFinished { outcome });
} }
fn can_reconnect(&self, index: usize) -> bool {
self.config.reconnect_timeout.is_some()
&& !self.phase.is_stopping()
&& self.targets[index].membership.is_desired()
}
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) { fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
let failure = classify_client_error(phase, &error); let failure = classify_client_error(phase, &error);
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None); self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
@@ -332,6 +411,9 @@ impl ManagedClientTask {
}; };
client.discard_prepared_probe(); client.discard_prepared_probe();
self.targets[index].sync_live_packets_sent(); self.targets[index].sync_live_packets_sent();
if self.can_reconnect(index) {
return self.begin_close(index, primary_end, None, now);
}
match cleanup { match cleanup {
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now), OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now), OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
@@ -359,6 +441,27 @@ impl ManagedClientTask {
let retirement = self.effective_retirement(index); let retirement = self.effective_retirement(index);
let stopping = self.phase.is_stopping() || retirement.is_some(); let stopping = self.phase.is_stopping() || retirement.is_some();
match self.targets[index].phase_mut() { match self.targets[index].phase_mut() {
TargetPhase::Retrying { deadline } => {
if stopping {
self.finish_target(
index,
retirement.unwrap_or(ManagedTargetEndReason::Stopped),
None,
);
false
} else if now >= *deadline {
let target = &self.targets[index].config;
let mut client_config = self.config.client.clone();
client_config.address_family = target
.address_family
.unwrap_or(client_config.address_family);
client_config.auth = target.auth.resolve(&client_config.auth);
self.install_target_phase(index, TargetPhase::Pending { client_config });
true
} else {
false
}
}
TargetPhase::Pending { .. } => { TargetPhase::Pending { .. } => {
if stopping { if stopping {
self.finish_target( self.finish_target(
@@ -409,13 +512,13 @@ impl ManagedClientTask {
let retirement = retirement let retirement = retirement
.clone() .clone()
.unwrap_or(ManagedTargetEndReason::Stopped); .unwrap_or(ManagedTargetEndReason::Stopped);
if !open.has_in_flight_work() { let cleanup_failure = client.cancel_open(open).map(|error| {
classify_client_error(ManagedTargetFailurePhase::Opening, &error)
});
self.targets[index].sync_live_packets_sent(); self.targets[index].sync_live_packets_sent();
self.finish_target(index, retirement, None); self.finish_target(index, retirement, cleanup_failure);
return false; return false;
} }
open.request_stop_after_current_attempt();
}
match client.poll_open(open, cx) { match client.poll_open(open, cx) {
Poll::Pending => false, Poll::Pending => false,
Poll::Ready(Ok(OpenOutcome::Started(started))) => { Poll::Ready(Ok(OpenOutcome::Started(started))) => {
@@ -438,16 +541,6 @@ impl ManagedClientTask {
index, index,
vec![ClientEvent::SessionStarted(started)], vec![ClientEvent::SessionStarted(started)],
); );
if stopping {
self.targets[index].sync_live_packets_sent();
self.begin_drain(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
now,
)
} else {
let previous = self.targets[index].take_phase(); let previous = self.targets[index].take_phase();
self.targets[index].restore_phase(TargetPhase::Active { self.targets[index].restore_phase(TargetPhase::Active {
client: previous.into_client(), client: previous.into_client(),
@@ -457,45 +550,16 @@ impl ManagedClientTask {
self.publish_target_phase(index, ManagedTargetLifecycle::Opening); self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
true true
} }
}
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => { Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
self.publish_client_events( self.publish_client_events(
index, index,
vec![ClientEvent::NoTestCompleted(completed)], vec![ClientEvent::NoTestCompleted(completed)],
); );
let end_reason = if stopping { self.finish_target(index, ManagedTargetEndReason::NoTestComplete, None);
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped)
} else {
ManagedTargetEndReason::NoTestComplete
};
self.finish_target(index, end_reason, None);
false false
} }
Poll::Ready(Err(error)) => { Poll::Ready(Err(error)) => {
if stopping {
let TargetPhase::Opening { open, .. } = self.targets[index].phase()
else {
unreachable!()
};
let cleanup_failure =
(!open.stopped_after_current_attempt()).then(|| {
classify_client_error(
ManagedTargetFailurePhase::Opening,
&error,
)
});
self.finish_target(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
cleanup_failure,
);
} else {
self.fail_target(index, ManagedTargetFailurePhase::Opening, error); self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
}
false false
} }
} }
@@ -504,6 +568,16 @@ impl ManagedClientTask {
if stopping { if stopping {
client.discard_prepared_probe(); client.discard_prepared_probe();
self.targets[index].sync_live_packets_sent(); self.targets[index].sync_live_packets_sent();
if self.config.reconnect_timeout.is_some() {
return self.begin_close(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
None,
now,
);
}
return self.begin_drain( return self.begin_drain(
index, index,
retirement retirement
@@ -539,6 +613,22 @@ impl ManagedClientTask {
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None); self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
return false; return false;
} }
if self.targets[index]
.silence_deadline(self.config.reconnect_timeout)
.is_some_and(|deadline| deadline <= now)
{
self.targets[index].sync_live_packets_sent();
return self.begin_close(
index,
ManagedTargetEndReason::Failed(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Receiving,
kind: ManagedTargetFailureKind::Timeout,
message: Arc::from("session silent while sending probes"),
}),
None,
now,
);
}
let TargetPhase::Active { let TargetPhase::Active {
client, schedule, .. client, schedule, ..
} = self.targets[index].phase_mut() } = self.targets[index].phase_mut()
@@ -45,7 +45,8 @@ struct UpdatePlan {
fn synchronously_retireable(phase: &TargetPhase) -> bool { fn synchronously_retireable(phase: &TargetPhase) -> bool {
match phase { match phase {
TargetPhase::Pending { .. } TargetPhase::Retrying { .. }
| TargetPhase::Pending { .. }
| TargetPhase::Connecting { .. } | TargetPhase::Connecting { .. }
| TargetPhase::Terminal { .. } => true, | TargetPhase::Terminal { .. } => true,
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(), TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
@@ -140,6 +141,10 @@ impl ManagedClientTask {
membership: TargetMembership::Desired, membership: TargetMembership::Desired,
remote: None, remote: None,
counters: TargetCounters::default(), counters: TargetCounters::default(),
sent_before_session: 0,
retry_delay: self.config.reconnect_initial_delay,
silence_since: None,
last_reply_at: None,
phase: Some(TargetPhase::Pending { phase: Some(TargetPhase::Pending {
client_config: planned.client_config, client_config: planned.client_config,
}), }),
+1 -1
View File
@@ -10,4 +10,4 @@ publish = false
irtt-client = { workspace = true, features = ["ancillary", "tokio"] } irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
irtt-server.workspace = true irtt-server.workspace = true
irtt-proto.workspace = true irtt-proto.workspace = true
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] } tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
+2 -3
View File
@@ -1,12 +1,11 @@
[package] [package]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.1" version = "0.5.2"
description = "IRTT protocol encoding and decoding primitives" description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["irtt", "networking", "latency", "protocol"] keywords = ["irtt", "networking", "latency", "protocol"]
categories = ["network-programming"] categories = ["network-programming"]
@@ -18,4 +17,4 @@ subtle = "2.6.1"
thiserror = "2.0.21" thiserror = "2.0.21"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.12.0"
+3
View File
@@ -77,6 +77,9 @@
//! //!
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod echo; pub mod echo;
mod envelope; mod envelope;
pub mod error; pub mod error;
+2 -3
View File
@@ -1,12 +1,11 @@
[package] [package]
name = "irtt-server" name = "irtt-server"
version = "0.5.3" version = "0.5.4"
description = "IRTT-compatible Rust server library" description = "IRTT-compatible Rust server library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["irtt", "networking", "latency", "server"] keywords = ["irtt", "networking", "latency", "server"]
categories = ["network-programming"] categories = ["network-programming"]
@@ -16,7 +15,7 @@ irtt-proto.workspace = true
getrandom = "0.4.3" getrandom = "0.4.3"
socket2 = { version = "0.6.5", features = ["all"] } socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.21" thiserror = "2.0.21"
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] } tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
# Wildcard listeners recover each request's local destination address and send # Wildcard listeners recover each request's local destination address and send
# the reply from it. Only these targets have a safe destination-metadata and # the reply from it. Only these targets have a safe destination-metadata and
+3
View File
@@ -108,6 +108,9 @@
//! payload bytes never reach a reply under any mode. //! payload bytes never reach a reply under any mode.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
mod clock; mod clock;
mod config; mod config;
mod core; mod core;
+2 -3
View File
@@ -1,12 +1,11 @@
[package] [package]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.2" version = "0.6.0"
description = "Statistics aggregation for irtt-client events" description = "Statistics aggregation for irtt-client events"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["irtt", "networking", "latency", "statistics"] keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"] categories = ["network-programming"]
@@ -19,4 +18,4 @@ irtt-client.workspace = true
name = "finite_vs_continuous" name = "finite_vs_continuous"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.12.0"
+9 -3
View File
@@ -16,10 +16,16 @@ re-export. Event interpretation, IPDV pairing, loss, and snapshot reconstruction
remain here. remain here.
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
scoped to a single target. A managed session covering multiple targets needs scoped to a single target session. A managed session covering multiple targets
one collector per target — as the `irtt-rs` CLI does — since a target's needs one collector per target — as the `irtt-rs` CLI does — since a target's
sequence numbers start over at zero and feeding two targets into one sequence numbers start over at zero and feeding two targets into one
collector would pair up unrelated probes. collector would pair up unrelated probes. Reset or replace the collector when a
target reconnects, since the new session restarts its sequence numbers.
Version 0.6 consumes `irtt-client` 0.7 events. Applications using both crates
must upgrade them together to avoid incompatible `ClientEvent` types.
`IpdvPairUpdate` exposes signed nanosecond changes alongside the existing
absolute `Duration` values; aggregate IPDV statistics remain absolute magnitudes.
## Retention modes ## Retention modes
+12 -10
View File
@@ -175,29 +175,31 @@ impl CoreStats {
let mut updates = Vec::new(); let mut updates = Vec::new();
for pair in self.ipdv_tracker.insert(sample) { for pair in self.ipdv_tracker.insert(sample) {
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(pair.rtt_ipdv_ns) else { let rtt_magnitude_ns = pair.rtt_ipdv_ns.saturating_abs();
let send_magnitude_ns = pair.send_ipdv_ns.map(i128::saturating_abs);
let receive_magnitude_ns = pair.receive_ipdv_ns.map(i128::saturating_abs);
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(rtt_magnitude_ns) else {
continue; continue;
}; };
let send_ipdv = pair let send_ipdv = send_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
.send_ipdv_ns let receive_ipdv = receive_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
.and_then(duration_from_non_negative_i128_ns);
let receive_ipdv = pair
.receive_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
self.ipdv_round_trip.push_ns(pair.rtt_ipdv_ns); self.ipdv_round_trip.push_ns(rtt_magnitude_ns);
if let Some(value) = pair.send_ipdv_ns { if let Some(value) = send_magnitude_ns {
self.ipdv_send.push_ns(value); self.ipdv_send.push_ns(value);
} }
if let Some(value) = pair.receive_ipdv_ns { if let Some(value) = receive_magnitude_ns {
self.ipdv_receive.push_ns(value); self.ipdv_receive.push_ns(value);
} }
updates.push(IpdvPairUpdate { updates.push(IpdvPairUpdate {
previous_seq: pair.previous_seq, previous_seq: pair.previous_seq,
current_seq: pair.current_seq, current_seq: pair.current_seq,
rtt_ipdv_ns: pair.rtt_ipdv_ns,
send_ipdv_ns: pair.send_ipdv_ns,
receive_ipdv_ns: pair.receive_ipdv_ns,
rtt_ipdv, rtt_ipdv,
send_ipdv, send_ipdv,
receive_ipdv, receive_ipdv,
+3 -7
View File
@@ -89,9 +89,9 @@ impl IpdvTracker {
Some(CompletedIpdvPair { Some(CompletedIpdvPair {
previous_seq, previous_seq,
current_seq, current_seq,
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns), rtt_ipdv_ns: current.rtt_primary_ns - previous.rtt_primary_ns,
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns), send_ipdv_ns: send_ipdv_ns(previous, current),
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns), receive_ipdv_ns: receive_ipdv_ns(previous, current),
}) })
} }
} }
@@ -161,10 +161,6 @@ fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX) i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
} }
fn abs_i128_ns(value: i128) -> i128 {
value.saturating_abs()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+9
View File
@@ -25,6 +25,9 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#![warn(rustdoc::missing_crate_level_docs)] #![warn(rustdoc::missing_crate_level_docs)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
use std::time::Duration; use std::time::Duration;
use irtt_client::ClientEvent; use irtt_client::ClientEvent;
@@ -520,6 +523,12 @@ pub struct IpdvPairUpdate {
pub previous_seq: u32, pub previous_seq: u32,
/// Sequence number of the later reply in the adjacent pair. /// Sequence number of the later reply in the adjacent pair.
pub current_seq: u32, pub current_seq: u32,
/// Signed round-trip delay change (current minus previous) in nanoseconds.
pub rtt_ipdv_ns: i128,
/// Signed send-side delay change in nanoseconds, when available for both replies.
pub send_ipdv_ns: Option<i128>,
/// Signed receive-side delay change in nanoseconds, when available for both replies.
pub receive_ipdv_ns: Option<i128>,
/// Absolute round-trip IPDV between the adjacent replies. /// Absolute round-trip IPDV between the adjacent replies.
pub rtt_ipdv: Duration, pub rtt_ipdv: Duration,
/// Absolute send-side IPDV when send one-way delay is available for both replies. /// Absolute send-side IPDV when send one-way delay is available for both replies.
+9 -4
View File
@@ -191,10 +191,15 @@ impl ReplySample {
client_receive_mono: received_at.mono, client_receive_mono: received_at.mono,
client_send_wall_ns: system_time_ns(sent_at.wall), client_send_wall_ns: system_time_ns(sent_at.wall),
client_receive_wall_ns: system_time_ns(received_at.wall), client_receive_wall_ns: system_time_ns(received_at.wall),
server_receive_mono_ns: server_timing.and_then(|timing| timing.receive_mono_ns), // Midpoint timestamps serve as both directional endpoints.
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns), server_receive_mono_ns: server_timing
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns), .and_then(|timing| timing.receive_mono_ns.or(timing.midpoint_mono_ns)),
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns), server_send_mono_ns: server_timing
.and_then(|timing| timing.send_mono_ns.or(timing.midpoint_mono_ns)),
server_receive_wall_ns: server_timing
.and_then(|timing| timing.receive_wall_ns.or(timing.midpoint_wall_ns)),
server_send_wall_ns: server_timing
.and_then(|timing| timing.send_wall_ns.or(timing.midpoint_wall_ns)),
}, },
} }
} }
+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 `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 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 The harness does not inspect upstream source or tests. It only invokes the
upstream executable. upstream executable.
## Remote or Existing Local Server ### Remote or Existing Local Server
Build `irtt-rs` and ensure an upstream `irtt` executable is available: 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 Remote comparisons are useful for smoke coverage, but Internet paths are noisy
and can make RTT/loss differences hard to interpret. 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 `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 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 Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
a `finally` path. a `finally` path.
### Scenarios #### Scenarios
The CI harness implements: 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 relative one-way shape is still useful for diagnostics, but the CI pass/fail
policy is based on RTT and packet behavior. policy is based on RTT and packet behavior.
### Pass/Fail Policy #### Pass/Fail Policy
Deterministic scenarios fail when: Deterministic scenarios fail when:
@@ -113,7 +147,7 @@ python3 scripts/run_netem_interop_ci.py \
--loss-rate-warning-pct 12 --loss-rate-warning-pct 12
``` ```
### Manual Linux Usage #### Manual Linux Usage
On Linux: On Linux:
+287 -139
View File
@@ -1,29 +1,31 @@
# irtt-client # irtt-client
## NAME ## Name
irtt-client - IRTT-compatible stream client irtt-client - IRTT-compatible stream client
## SYNOPSIS ## Synopsis
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]... `irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
`irtt-client` `--list-columns` `irtt-client` `--list-columns`
## DESCRIPTION ## Description
`irtt-client` probes one or more IRTT-compatible servers over UDP and prints Use `irtt-client` to probe one or more IRTT-compatible servers over UDP.
per-probe measurement events as they arrive, plus a final summary for It prints measurement events as they arrive, and a final summary when the
eligible runs. run qualifies for one.
## TARGETS ## Targets
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional Write targets as `[LABEL=]TARGET[@hmac=KEY]`. You can add a `LABEL=` prefix
`LABEL=` prefix assigns the logical target name used in output; without it, to choose the name shown in output. If you leave it out, the client uses
the target string itself is used as the label. Labeled and unlabeled targets the target string as its label. You can mix labeled and unlabeled targets
can be freely mixed in one argument list. Prefer explicit labels for in the same command.
long-running dynamic target sets: the label is the stable target identity
across replacements. 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 ```sh
irtt-client host-a:2112 irtt-client host-a:2112
@@ -37,28 +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 target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting. if its key needs shell quoting.
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 Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first 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 `=` lets an unlabeled target or a label contain that character. An `=` in
an HMAC value, including Base64 padding, does not need escaping. Positional an HMAC value does not need escaping. Positional target arguments may
target arguments may contain a raw comma; stdin target sets have the additional contain a raw comma; stdin target sets have the additional `\,` escape
`\,` escape described below. described below.
Shells process unquoted backslashes and characters such as semicolons before Shells process unquoted backslashes and characters such as semicolons before
the client sees them. Single-quote a target when it contains parser escapes, 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 for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
backslashes reach `irtt-client` unchanged. backslashes reach `irtt-client` unchanged.
Multiple targets are probed concurrently. By default, a multi-target session The client probes multiple targets concurrently. By default it staggers
uses **staggered** pacing, spacing active targets across the probe interval; them across the probe interval. If you want to send one probe to every
pass `--pacing burst` to send one probe to every active target back-to-back active target back-to-back once per interval, use `--pacing burst`.
once per interval instead. Final per-target summaries print in the order Final summaries follow the command-line target order, regardless of which
targets were supplied on the command line, not the order in which they target finished first or how their labels sort.
finish or their labels sort alphabetically.
## ADDRESS FAMILY ## Address family
By default, each target uses the first address returned by the system resolver, 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 whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
@@ -93,22 +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 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. double the number of probes per interval compared with one target per hostname.
## DYNAMIC TARGET SETS FROM STANDARD INPUT ## Local socket configuration
`--targets-stdin` is available only with continuous mode (`--duration 0`). If you need to control where probes leave the machine, use these socket
Positional targets remain an initial desired set, and may be omitted to options:
start empty:
- `--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, 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 OS rejects a socket option,
socket setup fails.
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
`--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 ~~~sh
irtt-client --duration 0 --targets-stdin irtt-client --duration 0 --targets-stdin
irtt-client --duration 0 --targets-stdin ams=ams.example:2112 irtt-client --duration 0 --targets-stdin ams=ams.example:2112
~~~ ~~~
Each non-empty stdin record declares the complete desired target set, rather Each non-empty stdin record gives the complete set of targets you want.
than a delta. A later record replaces an earlier set: targets absent from it A new record replaces the previous set: the client retires targets you leave
are retired, unchanged target configurations retain their generation, and a out, keeps the generation for unchanged configurations, and starts a fresh
changed address or HMAC setting creates a fresh generation. Under transient generation when an address or HMAC setting changes. If live-generation
live-generation backpressure, only the latest unapplied desired set is kept. capacity temporarily prevents an update, only the latest unapplied set is
kept.
The same address-family preparation applies to initial and stdin targets. The same address-family preparation applies to initial and stdin targets.
With `--dual-stack`, unchanged labels and endpoints retain their discovered With `--dual-stack`, unchanged labels and endpoints retain their discovered
@@ -117,11 +158,12 @@ and later adding it again performs fresh discovery. New declarations are
prepared asynchronously while existing measurements continue. A preparation prepared asynchronously while existing measurements continue. A preparation
error stops the stream gracefully without applying a partial set. error stops the stream gracefully without applying a partial set.
Commas frame targets in stdin records; escape a literal comma within one Separate targets in stdin records with commas, and escape a comma inside a
target as `\,`. Target parsing inside each element is otherwise the same as target as `\,`. Each target otherwise uses the same syntax as positional
for positional arguments, including the other escapes above, `@hmac=KEY`, and arguments, including the escapes above, `@hmac=KEY`, and `@hmac=`.
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
client is being controlled. For example, in one terminal: Keep the producer or pipe/FIFO writer open while controlling the client.
For example, run this in one terminal:
~~~sh ~~~sh
mkfifo /tmp/irtt-targets mkfifo /tmp/irtt-targets
@@ -129,8 +171,8 @@ irtt-client --duration 0 --targets-stdin --hmac default-secret \
< /tmp/irtt-targets < /tmp/irtt-targets
~~~ ~~~
Then open the FIFO once from another terminal and keep that descriptor open Then, in another terminal, open the FIFO once and keep that descriptor open
while publishing complete desired sets: while sending complete target sets:
~~~sh ~~~sh
exec 3> /tmp/irtt-targets exec 3> /tmp/irtt-targets
@@ -140,40 +182,129 @@ printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
exec 3>&- exec 3>&-
~~~ ~~~
The exact record `[]` selects an empty desired set. Empty records are Send exactly `[]` to select an empty target set. The client ignores empty
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss records, but otherwise treats whitespace as part of the input without
or closure of the control plane and requests a graceful stop. A desired-state trimming it.
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 Closing stdin sends EOF and asks the client to stop gracefully. An update
revision to become applicable. 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 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 desired set to 128 targets and retains at most 256 live target generations
while replacements drain. The 128-target limit applies after dual-stack while replacements drain. The 128-target limit applies after dual-stack
expansion: a hostname producing both families consumes two target slots. expansion: a hostname producing both families consumes two target slots.
## FINITE VERSUS CONTINUOUS OPERATION ## Negotiation only
`-n` / `--no-test` connects to each target, negotiates the requested parameters,
and exits without sending measurement probes. For example:
```sh
irtt-client --no-test host.example:2112 --duration 30s --interval 100ms
```
On successful negotiation, the `no_test_completed` event includes the
negotiated duration, interval, and packet length. Output format and column
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
`--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
(e.g. `500us` or `100ns`). A nonzero value requires a unit. The interval must
be greater than zero; a zero test duration, including `0s`, selects continuous
mode.
`--timeouts 1s,2s,4s,8s` sets the session-opening retry schedule. Each
comma-separated duration is the receive timeout for one open attempt; attempts
stop when a valid open reply arrives. The list must be nonempty, with each
value at least `200ms`, and uses the same duration syntax as `--interval`.
Omitting the option preserves the library default schedule.
`--duration` controls the run length: `--duration` controls the run length:
- Any nonzero duration (default `10s`) is a **finite** run: probing stops - A nonzero duration (default `10s`) gives you a finite run. Probing stops
after that long, and a final summary is always printed for table output after that long. If statistics are enabled and you're using table
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one). [output](#output), the client prints a final summary. CSV, TSV, and
- `--duration 0` is **continuous**: the client runs until interrupted JSON Lines never include a summary.
(`Ctrl-C`). For table output, a final summary is printed only if the run - With `--continuous` (or `--duration 0`), the client runs until interrupted
was interrupted. Stdin-controlled mode is the exception: EOF also requests (`Ctrl-C`). With statistics and table output enabled, interrupting the run
a graceful stop and prints the final retained summary. An uninterrupted prints a final summary. In stdin-controlled mode, EOF also stops the run
continuous run that exits for another reason does not get one. 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 The choice also affects which statistics are available and how much memory
distinction also determines what statistics can be computed and how much a long run retains. See [Measurements and memory](#measurements-and-memory).
memory a long run retains.
## OUTPUT ### Optional recovery and event-only runs
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:
```sh
cargo build --release -p irtt-rs --bin irtt-client --no-default-features --features client
```
Use `--features client,stats` to include statistics in a client-only build.
Default builds include statistics, and `tui` always enables them. Without
compiled statistics, `--no-stats` remains accepted, summaries and statistics
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.
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.
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` 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.
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.
For continuous CSV output from multiple targets without statistics:
```sh
irtt-client --continuous --reconnect --no-stats \
--reconnect-timeout 10s \
--reconnect-initial-delay 250ms --reconnect-max-delay 10s \
--format csv --header never \
--columns client_receive_wall_ns,target,seq,rd_us,sd_us \
reflector1 reflector2
```
## Output
### `--format FORMAT` ### `--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 - `table`: human-readable terminal output; the only format that prints a
final summary. final summary.
@@ -181,23 +312,36 @@ One of four event-row formats, default `table`:
- `tsv`: tab-separated output. - `tsv`: tab-separated output.
- `jsonl`: one JSON object per event. - `jsonl`: one JSON object per event.
CSV, TSV, and JSON Lines default to **all** columns for structured export; CSV, TSV, and JSON Lines include all columns by default for structured export;
`table` defaults to a compact column set and hides `echo_sent` rows. An `table` defaults to a compact column set and hides `echo_sent` events.
explicit `--columns` selection other than `default` shows all event rows, You can show those events by explicitly selecting them with `--events`
including `echo_sent`; `--columns default` is equivalent to omitting the or by using custom columns.
option and keeps `echo_sent` hidden for table output.
### `--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` ### `--columns COLUMNS` / `-c COLUMNS`
Comma-separated event row columns, or the special values `default` (the Choose event-row columns with a comma-separated list, or use `default`
format's default set) or `all` (every column). List every available column, for the format's default set and `all` for every column. To see the
its meaning, and its aliases with: available columns, their meanings, and aliases, run:
```sh ```sh
irtt-client --list-columns irtt-client --list-columns
``` ```
(`--list-columns` does not require a target.) You don't need a target for `--list-columns`.
### `--header auto|always|never` ### `--header auto|always|never`
@@ -206,10 +350,9 @@ Lines never prints a header line.
### `--verbose` ### `--verbose`
Includes extra fields in table output and final summaries. Has no effect on Include extra fields in table output and final summaries. CSV, TSV, and
CSV, TSV, or JSON Lines output either way — those formats show their default JSON Lines keep their default all-column set or your explicit `--columns`
all-column set or an explicit `--columns` selection exactly as given, selection regardless of `--verbose`.
regardless of `--verbose`.
### Examples ### Examples
@@ -219,13 +362,13 @@ irtt-client <server> --format jsonl
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us 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 ```sh
irtt-client <server> --format tsv --columns effective_rtt_us --header never irtt-client <server> --format tsv --columns effective_rtt_us --header never
``` ```
## MEASUREMENT FIELDS ## Measurement fields
Useful measurement columns: Useful measurement columns:
@@ -246,90 +389,95 @@ Useful measurement columns:
### Kernel timestamps (Linux) ### Kernel timestamps (Linux)
On Linux, one-way delay estimates prefer a kernel-captured timestamp over On Linux, the client prefers kernel timestamps for its own send/receive
userspace timing for this client's own send/receive endpoint, where the endpoint when calculating one-way delays, where the platform and socket
platform and socket support it, reducing the effect of scheduling and support them. This reduces the effect of scheduling and queuing delay.
queuing delay. The peer's reported endpoint gets the same preference when it The `irtt-server` applet makes the same choice for its endpoint on Linux.
is the `irtt-server` applet running on Linux; against another With another IRTT-compatible server, the client uses the wall-clock value
IRTT-compatible server, whatever wall-clock value it reports is used as-is. that server reports as-is.
This is best-effort: an unsupported or implausible kernel timestamp falls
back to userspace timing. `raw_rtt_us` is unaffected either way;
`adjusted_rtt_us`/`effective_rtt_us` can still shift slightly, since server
processing time is derived from the same server receive endpoint.
## MEASUREMENTS AND MEMORY 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.
Finite and continuous modes make a different tradeoff between exact ## Measurements and memory
statistics and bounded memory.
**Finite mode** retains exact timing samples (and adjacent-sequence IPDV If the `stats` feature is compiled in and you haven't used `--no-stats`,
state) so the final summary can report exact medians. Retained memory the client collects statistics as described here. Disabling collection
therefore grows with the number of probes sent, at a small constant cost per allocates no statistics collectors, though the client still keeps its
probe (on the order of a few hundred bytes of retained sample state per bounded reply-classification state.
probe per target, before allocator overhead) — a very long finite run at a
short interval is the case to plan capacity for. The client warns on stderr
when its own estimate of this retained state crosses roughly 128 MiB, and
again more strongly past roughly 512 MiB and 1 GiB; treat those numbers as
practical guidance, not a promised RSS ceiling, since allocator and OS
overhead sit outside the estimate.
**Continuous mode** (`--duration 0`) does not retain a growing sample In finite mode, the client keeps exact timing samples and adjacent-sequence
history: running statistics keep count/min/max/mean/variance only, so exact IPDV state so it can report exact medians in the final summary. Memory
medians are unavailable. It still keeps a bounded adjacent-sequence IPDV grows with the number of probes sent, at roughly a few hundred bytes per
store, capped at 4,096 sequences per target, so nearby replies can still probe per target before allocator overhead. So if you're planning a long
form IPDV pairs. Retained statistics state therefore approaches a fixed finite run with a short interval, allow for that growth.
per-target bound rather than growing with elapsed run time.
Reply classification (independent of the statistics choice above) is also The client prints a warning on stderr when its estimate of retained state
bounded per target: each of the pending, timed-out, and completed/duplicate crosses roughly 128 MiB, with stronger warnings past roughly 512 MiB and
sequence stores has its own 4,096-entry limit — not one shared total across 1 GiB. These are useful planning figures, but they don't promise an RSS
the three. A full pending store makes the client fail and drain that target ceiling: allocator and OS overhead are outside the estimate.
as resource-exhausted rather than wait for capacity to free. Timed-out and
completed state evict their oldest entries, so a sufficiently old late reply
can still be seen and counted, but no longer has retained send state for
measurements such as RTT.
Overall, retained state scales with the **number of configured targets** In continuous mode (`--duration 0`), running statistics keep only
and these fixed per-target limits, not with total probes sent in continuous count/min/max/mean/variance, so exact medians are unavailable. The client
mode. Output is written as events arrive rather than accumulated; the still keeps up to 4,096 sequences per target for adjacent-sequence IPDV
managed event stream feeding it is itself bounded and lossy (16,384 events), pairs. Statistics memory approaches a fixed limit per target as the run
so a sufficiently slow consumer can miss events, in which case the client continues.
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 For capacity planning on constrained devices (for example an OpenWrt
router): a finite run's statistics memory is roughly proportional to router): a finite run's statistics memory is roughly proportional to
`targets × probes_sent`, while a continuous run's statistics memory `targets × probes_sent`, while a continuous run's statistics memory
approaches a fixed multiple of `targets` regardless of how long it runs. 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. If a peer closes a finite run, the client treats that as successful
In continuous mode, an uninterrupted peer closure is treated as an error — completion. In continuous mode without `--reconnect`, a peer closure ends
the client exits nonzero — so a process supervisor can restart it; an an uninterrupted run with a nonzero exit status so a process supervisor
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever can restart it. If you interrupted the run (`Ctrl-C`), peer closure is
mode it was in. never reported as an error in either mode.
With `--targets-stdin`, peer closure is target-local: other desired targets With `--reconnect`, a desired target reopens automatically after peer closure.
and the stdin controller continue running. Including that target in a later Without recovery, `--targets-stdin` makes peer closure target-local: other
complete set starts a fresh generation. 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 The client exits `0` on success, including normal finite completion and
interrupted run of either mode). Nonzero if the managed driver failed, if an interrupted runs in either mode. It exits nonzero if the managed driver
uninterrupted continuous run ended by peer closure, or if the run was not fails, an uninterrupted continuous run ends by peer closure, or no target
interrupted and no target completed successfully. An interrupted run never completes successfully in an uninterrupted run.
fails on the last of these conditions — for example, interrupting a
continuous run whose only target never opened still exits `0`.
## 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` `--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating (zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
hexadecimal pattern). See `irtt-server(1)` SERVER FILL for what the server hexadecimal pattern). See the Server fill section in `irtt-server(1)` for
does with an unset or unparseable request. what the server does with an unset or unparseable request.
## EXAMPLES ## Examples
```sh ```sh
irtt-client netperf-eu.bufferbloat.net:2112 irtt-client netperf-eu.bufferbloat.net:2112
@@ -338,7 +486,7 @@ irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
irtt-client host-a:2112 host-b:2112 --pacing burst 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 `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 the full option list, including HMAC, clock/timestamp negotiation, DSCP, and
+38 -37
View File
@@ -1,10 +1,10 @@
# irtt-rs # irtt-rs
## NAME ## Name
irtt-rs - IRTT-compatible multi-applet dispatcher irtt-rs - IRTT-compatible multi-applet dispatcher
## SYNOPSIS ## Synopsis
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]... `irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
@@ -12,53 +12,54 @@ irtt-rs - IRTT-compatible multi-applet dispatcher
`irtt-rs` `server` [*OPTIONS*] `irtt-rs` `server` [*OPTIONS*]
## DESCRIPTION ## Description
`irtt-rs` is a multicall dispatcher: one binary that runs the client, TUI, `irtt-rs` lets you run the client, TUI, or server from one binary. You choose
or server applet depending on the subcommand given, or on the name it was the applet with a subcommand, or by the name you invoke or copy the binary
invoked/copied as. It exists for router-friendly single-binary deployment; as. This is useful on a router where you want a single binary to deploy.
it does not add behavior beyond what `irtt-client(1)`, `irtt-tui(1)`, and The applets behave the same as `irtt-client(1)`, `irtt-tui(1)`, and
`irtt-server(1)` already provide. `irtt-server(1)`.
Each subcommand behaves exactly like its dedicated binary — `irtt-rs client For example, `irtt-rs client ...` does the same thing as running `irtt-client ...` directly. See the
...` is equivalent to running `irtt-client ...` directly. See those manuals individual manuals for target syntax, options, output, and exit behavior.
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 The build also produces dedicated `irtt-client`, `irtt-tui`, and
`irtt-server`. A dedicated binary always runs its own role, regardless of `irtt-server` binaries. These always run their own applet, even if you copy
what it is invoked or copied as — it does not consult argv0 dispatch logic. 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 The `irtt-rs` dispatcher checks argv0 first. If you invoke it through a
one of the recognized applet names — `irtt-client`, `irtt-tui`, or symlink or copy named `irtt-client`, `irtt-tui`, or `irtt-server`, it runs
`irtt-server` — it runs that applet directly, without needing a subcommand. that applet without a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even
attempted — `irtt-custom server` is a hard error, not a fallthrough. Only
`irtt-rs` itself, or a name that doesn't start with `irtt-` at all, falls
through to ordinary `client`/`tui`/`server` subcommand parsing.
## FEATURE-DEPENDENT APPLET AVAILABILITY 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`, ## Feature-dependent applet availability
`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 Each applet has a Cargo feature on the `irtt-rs` package: `client`,
unavailable at runtime — running it (by subcommand or by binary name) `tui`, `server`. All three are enabled by default. If you disable one, the
reports which feature to rebuild with, and `irtt-rs --help` lists which build still produces `irtt-rs`, but you cannot run that applet. Trying it
applets the current build actually contains. 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 ```sh
cargo install irtt-rs --locked --no-default-features --features client cargo install irtt-rs --locked --no-default-features --features client,stats
``` ```
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server Use `--features client` to omit statistics. The default build includes the
`stats` feature, and `tui` always enables it.
`irtt-rs` is packaging and dispatch only. All probing, display, and server ## Relationship to irtt-client, irtt-tui, irtt-server
behavior lives in `irtt-client(1)`, `irtt-tui(1)`, and `irtt-server(1)` —
consult those manuals for anything observable about running a probe or
serving one.
## SEE ALSO 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
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`. `irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
+105 -104
View File
@@ -1,22 +1,22 @@
# irtt-server # irtt-server
## NAME ## Name
irtt-server - IRTT-compatible UDP server irtt-server - IRTT-compatible UDP server
## SYNOPSIS ## Synopsis
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*] `irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*] `irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
## DESCRIPTION ## Description
`irtt-server` answers IRTT echo requests over UDP. It is thin orchestration `irtt-server` answers IRTT echo requests over UDP. It uses the reusable
over the reusable `irtt-server` library: one current-thread Tokio runtime, `irtt-server` library for sessions and policy, with one current-thread
one or more listeners, and the library's own session/policy logic. Tokio runtime running one or more listeners.
Run with no arguments for the ordinary case: To start a server with the defaults, run:
```sh ```sh
irtt-server irtt-server
@@ -28,34 +28,31 @@ wildcard address for both address families, in this fixed order:
1. `[::]:2112` 1. `[::]:2112`
2. `0.0.0.0:2112` 2. `0.0.0.0:2112`
This is available wherever the platform supports safe wildcard reply-source The defaults work on platforms that support wildcard reply-source
selection (see [Wildcard binds](#wildcard-binds) below). Any explicit selection (see [Wildcard binds](#wildcard-binds)). If you supply any
`--bind` **replaces** this default pair entirely rather than adding to it — `--bind`, it replaces both defaults. For example,
`irtt-server --bind 127.0.0.1:2112` binds only that one address. `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 If the host has no support for one address family, for example because IPv6
family itself has no local support at all — IPv6 disabled on an otherwise is disabled, the server uses the other default listener and tells you on
IPv4-capable host, for example — the server falls back to serving just the startup. Other bind failures still stop startup. This includes a port
other one instead of failing outright, and says so on startup. Any other already in use, permission denied, or a platform without safe wildcard
reason a default listener fails — the port already in use, permission reply-source selection.
denied, no safe wildcard reply-source path — is not a family-support
problem and is not silently worked around; the invocation fails as it would
for an explicit `--bind`. This fallback applies only to the implicit default
pair: an explicit `--bind` list always keeps the ordinary all-or-nothing
behavior described below.
## OPTIONS 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` ### `--bind ADDR`
Local UDP address to bind, as `ADDR:PORT`, for example `127.0.0.1:2112` or Use `ADDR:PORT` to choose a local UDP address, for example `127.0.0.1:2112`
`[::1]:2112`. Host names are not resolved. Repeatable. or `[::1]:2112`. Hostnames are not resolved.
Repeat the option to serve several addresses from one process, in the order Repeat `--bind` to serve several addresses from one process, in the order
given. Every listener applies the same policy options, but each keeps its given. Every listener uses the same policy options, but keeps its own
own sessions and tokens, so a session belongs to the address it was opened sessions and tokens. A session belongs to the address it was opened on.
on. Binding is all-or-nothing: if any address cannot be bound, none are Binding is all-or-nothing: if any address cannot be bound, none are served.
served.
A port of `0` selects an unused port per listener, so two such binds get two A port of `0` selects an unused port per listener, so two such binds get two
different ports. different ports.
@@ -63,70 +60,75 @@ different ports.
With no `--bind` at all, see [Description](#description) above for the With no `--bind` at all, see [Description](#description) above for the
default wildcard pair and the address-family fallback it allows. default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure If you need DSCP/traffic-class marking on replies to IPv4 peers, use care
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers with a lone dual-stack `[::]:PORT` listener. On Linux, those peers use
served through that socket are IPv4-mapped and their replies cannot retain the IPv4-mapped addresses and their replies cannot retain the negotiated
negotiated marking. Configure the ordinary paired `[::]:PORT` and marking. Use paired `[::]:PORT` and `0.0.0.0:PORT` listeners on the same
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that port. The server makes the IPv6 listener IPv6-only for that pair, so each
same-port pair, so each family uses the correct socket option. This is a family gets the correct socket option. A lone IPv6 listener keeps its usual
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone behavior; the server does not force `IPV6_V6ONLY` on it.
IPv6 listener.
### `--hmac KEY` ### `--hmac KEY`
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured, You can use a plain-text key or a hexadecimal key prefixed with `0x`.
every request must carry a valid MAC and every reply is authenticated; For example, `secret` and `0x736563726574` are equivalent. The
without one, authenticated requests are dropped. The key is visible in the `IRTT_SERVER_HMAC` environment variable accepts the same syntax.
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` ### `--max-sessions COUNT`
Maximum number of simultaneously live sessions **per listener**, not per Set the maximum number of live sessions per listener. This limit applies
process. Once a listener's table is full, a session-creating open to it is to each listener separately, so the process can have more sessions in total.
dropped silently; nothing is evicted. Zero refuses every session-creating Once a listener's table is full, it silently drops opens that would create
open. a session and keeps the existing sessions. Zero refuses every open that
would create a session.
### `--max-packet-length BYTES` ### `--max-packet-length BYTES`
Maximum echo datagram size a session may negotiate, in bytes. A longer Set the maximum echo datagram size a session may negotiate, in bytes. The
requested length is reduced during negotiation, an open whose mandatory server reduces longer requests during negotiation and refuses an open if
field block would not fit is refused, and inbound echo requests are admitted its mandatory fields would not fit. Incoming echo requests must fit the
against the same limit. This is a resource bound, not an MTU. same limit. Use this to bound resource use; it does not set the network MTU.
### `--min-interval DURATION` ### `--min-interval DURATION`
Floor on the send interval a session may negotiate. The negotiated interval Set the minimum send interval a session may negotiate. The server then caps
is still capped at a quarter of the idle timeout afterwards, so a the negotiated interval at a quarter of the idle timeout, so a
`--min-interval` above that cap is not the value actually negotiated. `--min-interval` above that cap will be reduced.
A session's reply allowance refills at the shorter of this configured floor The session's reply allowance refills at the shorter of the configured
and the interval it actually negotiated. Ordinarily that is the configured minimum and the negotiated interval. Usually this is the configured
floor itself, since the negotiated interval is normally at least as long — minimum: a 10ms `--min-interval` with a 1s negotiated interval refills every
a 10ms `--min-interval` against a 1s negotiated interval refills every 10ms.
10ms. The two differ only when the idle-timeout cap above pulled the
negotiated interval below the configured floor, in which case the shorter, If the idle-timeout cap reduces the negotiated interval below the configured
negotiated value is what the allowance actually refills at: a server must minimum, the allowance refills at that shorter interval too. This keeps the
not enforce a cadence it did not advertise to the client. Use `0` for no server from enforcing a slower cadence than it told the client to use.
time-based throttling. Use `0` for no time-based throttling.
### `--burst COUNT` ### `--burst COUNT`
Echo replies one session may have answered before its allowance has to Set how many echo replies a session can send before its allowance needs to
refill. Use `0` for no allowance at all, which rate-limits every echo refill. Use `0` for no allowance, which rate-limits every
request whatever the interval is. echo request regardless of the interval.
### `--idle-timeout DURATION` ### `--idle-timeout DURATION`
Release a session after this long without a served or rate-limited echo 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 request. The timeout starts at open, and the server releases the session
expire a session at the next evaluation; it is not a way to disable expiry. without sending a close. If you use `0`, the session expires at the next
evaluation; zero does not disable expiry.
### `--max-duration DURATION` ### `--max-duration DURATION`
Maximum test duration a session may negotiate. A longer request is reduced Set the maximum test duration a session may negotiate. The server reduces
to it, and a continuous request is answered with it. Omit this flag for no longer requests to this value and uses it for continuous requests too.
maximum; a maximum of zero cannot be expressed, because a negotiated Leave the flag out if you don't want a maximum. You cannot set a maximum of
duration of zero means continuous. zero, since a negotiated duration of zero means continuous.
### `--timestamp-allowance MODE` ### `--timestamp-allowance MODE`
@@ -146,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: Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`. `ms`, `s`, or `m`.
## ENVIRONMENT ## Environment
Every option above can also be supplied through an environment variable, for You can also set each option through an environment variable, which is
deployments that configure by environment rather than by command line useful for containers, orchestrators, and unit files. A command-line flag
(containers, orchestrators, unit files). A flag given on the command line overrides the corresponding variable entirely. If neither is set, the
overrides the corresponding variable entirely; an unset variable leaves the server uses its built-in default.
built-in default in place.
| Variable | Option | | Variable | Option |
| --- | --- | | --- | --- |
@@ -175,10 +176,10 @@ more than one address needs repeated `--bind` options.
an empty value leave traffic-class marking enabled; any other value disables an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled. it, and an unset variable leaves it enabled.
## OUTPUT ## Output
Each bound endpoint is printed on startup, once every listener is up, which Once every listener is up, the server prints each bound endpoint. If you
also resolves a port of `0`: requested port `0`, this shows which port was assigned:
``` ```
irtt-server: listening on [::]:2112 irtt-server: listening on [::]:2112
@@ -187,21 +188,22 @@ irtt-server: listening on 0.0.0.0:2112
`Ctrl-C` stops the server gracefully. `Ctrl-C` stops the server gracefully.
## MULTIPLE LISTENERS ## Multiple listeners
Every invocation runs through the same multi-listener path, so one address — The server manages listeners the same way whether you have one or several,
whether from the default pair or a single explicit `--bind` — is an ordinary including when only one address from the default pair is available or you
set of one rather than a separate mode. The policy options above apply to supply a single `--bind`. Each listener uses the same policy options, but
every listener, but listeners are otherwise independent: each has its own keeps its own sessions and tokens. The `--max-sessions` limit applies to
sessions and tokens, and `--max-sessions` bounds each listener rather than each listener separately.
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 If any listener fails while running, the server shuts down the others too.
on one. 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 Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
does. does.
## SERVER FILL ## Server fill
Echo replies fill their payload according to the negotiated `ServerFill` Echo replies fill their payload according to the negotiated `ServerFill`
mode, which a client requests with `irtt-client`'s `--sfill` (see mode, which a client requests with `irtt-client`'s `--sfill` (see
@@ -211,11 +213,11 @@ mode, which a client requests with `irtt-client`'s `--sfill` (see
- `rand`: random bytes. - `rand`: random bytes.
- `pattern:HEX`: the given repeating hexadecimal pattern. - `pattern:HEX`: the given repeating hexadecimal pattern.
Every valid descriptor is honored. A descriptor the server cannot parse, and The server honors every valid descriptor. If it cannot parse the descriptor,
a client that expresses no fill preference at all, both get the default or the client sends no fill preference, it uses `pattern:69727474`: the
`pattern:69727474`, i.e. the repeating bytes `69 72 74 74` (`irtt`). repeating bytes `69 72 74 74` (`irtt`).
## WILDCARD BINDS ## Wildcard binds
An explicit interface address works on every supported system. A wildcard 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 bind such as `0.0.0.0:2112` or `[::]:2112` reads each request's local
@@ -223,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 same address, so a client on a multi-homed host is answered from the
endpoint it actually contacted. endpoint it actually contacted.
That reply-source selection is implemented on **Linux, macOS, and FreeBSD**. Reply-source selection is supported on Linux, macOS, and FreeBSD. On other
Elsewhere, a wildcard bind is refused at construction rather than served platforms, the server refuses wildcard binds because the routing table
from a routing-table-selected source address a client would discard — this could select a source address the client would discard. This also means
also applies to the zero-argument default, which will fail with a clear running without arguments fails on those platforms, with an error asking
error asking for an explicit `--bind` on such a platform. This correctness you to supply an explicit `--bind`.
rule is deliberate and is not weakened by the zero-argument default.
## EXAMPLES ## Examples
Run with the default wildcard listeners on the standard port: Run with the default wildcard listeners on the standard port:
@@ -282,19 +283,19 @@ Require authentication:
irtt-server --bind 192.0.2.10:2112 --hmac secret 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 `0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
fails while running. fails while running.
## LIMITATIONS ## Limitations
- No hostname resolution, interface expansion, daemonization, or - 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 - Wildcard reply-source selection is unavailable outside Linux, macOS, and
FreeBSD; such platforms must bind explicit addresses. 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 `irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
the full option list. the full option list.
+88 -78
View File
@@ -1,25 +1,24 @@
# irtt-tui # irtt-tui
## NAME ## Name
irtt-tui - IRTT-compatible terminal UI client irtt-tui - IRTT-compatible terminal UI client
## SYNOPSIS ## Synopsis
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*... `irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
## DESCRIPTION ## Description
`irtt-tui` is a live dashboard over the same probing engine as `irtt-tui` gives you a live graph and status dashboard using the same probing
`irtt-client(1)`: a graph and status view instead of a printed event stream. engine as `irtt-client(1)`. You need at least one target to start it.
At least one target is required.
Target syntax, multiple targets, and pacing are identical to Targets and pacing work the same as in `irtt-client(1)`. See that manual
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the for `[LABEL=]TARGET` syntax, multiple targets, and the
`--pacing staggered|burst` option. The full set of negotiation flags `--pacing staggered|burst` option. The configuration flags
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`, (`--interval`, `--timeouts`, `--length`, `--hmac`, `--clock`, `--tstamp`,
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as `--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are also shared with
well; consult that manual for their meaning. `irtt-client`, so the same descriptions apply.
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only, Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family `-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
@@ -36,36 +35,43 @@ 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. resolver's initial results, letting you compare the two paths directly.
See the client manual's address-family section for discovery errors and labels. 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** The TUI defaults to continuous mode (`--duration 0`) and runs until you
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for quit. If you're used to `irtt-client`, which defaults to a finite run,
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`):
```sh ```sh
irtt-tui <server> --duration 30s irtt-tui <server> --duration 30s
``` ```
Continuous runs reconnect after target-local failures, peer closure, or a In continuous mode, the TUI reconnects after a target fails, the peer closes
session completing because the server restricted its duration. After a the session, or the session ends because the server limited its duration.
1.5-second delay, one coalesced retry reopens completed targets while healthy After a 1.5-second delay, it groups completed targets into one retry while
sessions continue undisturbed. Even if every target fails, the TUI stays alive healthy sessions keep running. Even if every target fails, the TUI stays
until you quit. Explicit stops, removed or replaced targets, and no-test open until you quit.
completion do not trigger retries. Quitting cancels pending retry work.
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 With `--dual-stack`, a failed family reconnects independently while its healthy
sibling continues. Reconnect resolves the original hostname again within the sibling continues. Reconnect resolves the original hostname again within the
target's family. It does not discover additional families or pin the address target's family. It does not discover additional families or pin the address
returned by initial discovery. 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. session generation is accepted, including attempts that fail before opening.
Retained graph history and the age of the last actual reply remain; the graph The TUI keeps graph history and the age of the last actual reply, with a
leaves a gap between generations instead of connecting their samples. 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. - Run status, elapsed time, and finite duration or continuous mode.
- The selected target's label and remote address, plus latest signed - The selected target's label and remote address, plus latest signed
@@ -75,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 - A graph overlaying all targets, with matching target colors and a visible
live/history indicator and window size. live/history indicator and window size.
An active session with no primary replies shows `waiting`. Reply age makes a An active session shows `waiting` until it gets a primary reply. Check the
stale RTT distinguishable from a fresh measurement. Loss follows the shared reply age to see how fresh the displayed RTT is. The loss estimate comes
statistics collector: outstanding sends can temporarily contribute to the from the shared statistics collector, so outstanding sends can temporarily
cumulative loss estimate. Jitter is `-` until an IPDV pair is available. 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. Use `Tab` / `Shift-Tab` to select a target and bring its row into view.
Up to six target rows are shown; smaller terminals show fewer. Selecting a target The dashboard shows up to six target rows, with fewer on smaller terminals.
changes the identification, one-way summary and details; the graph continues to Selecting a target changes the identification, one-way summary, and details.
compare every target. The graph still compares every target.
`d` opens a details sheet in the graph's space. It contains the selected target's Press `d` to open details in the graph's space. You'll find the selected
session and negotiation, packet counters, progress, latest timing sample, full target's session and negotiation, packet counters, progress, latest timing
timing statistics, warnings, and recent events from all targets. Scroll it with sample, full timing statistics, and warnings, along with recent events
the arrows or page keys. Long lines and timing columns can be read with horizontal from all targets.
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`.
The minimum terminal size is 56 columns by 18 rows. Below that size the UI asks Scroll with the arrows or page keys. You can scroll horizontally to read
for a resize and continues acquiring measurements. Resize and important status long lines and timing columns, including labels and remote addresses that
redraws still work while the display is paused. 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 | | Key | Action |
| --- | --- | | --- | --- |
@@ -123,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 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. 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 Along with the statistics described in `irtt-client(1)`, the TUI keeps
retention described in `irtt-client(1)`: some history for the display:
- Up to 500,000 graph samples per target. - Up to 500,000 graph samples per target.
- Up to 80 recent status/log messages. - Up to 80 recent status/log messages.
At 500,000 replies per target, retention is approximately 13h53m20s at At 500,000 replies per target, you get approximately 13h53m20s of history
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph with 100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Each graph
samples store both clocks, six graph-ready values, and a session-boundary sample stores both clocks, six values ready for graphing, and a flag marking
flag; latest details keep the full typed timing values and sequence number. session boundaries. The latest details keep the full typed timing values
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target and sequence number.
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.
`r` clears graph history for all targets and returns the viewport to On 64-bit macOS, each retained sample takes 88 bytes, giving about 42 MiB
live; it does not change the 500,000-sample cap itself. These are per target at the cap. Statistics and temporary rendering buffers use
presentation bounds on top of the client's own statistics retention (see additional memory. History grows as needed and reserves exactly to the
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's cap on its final growth step, so you don't pay for the full history of
total memory is dominated by whichever of the two is larger for your target every target at startup.
count.
A **finite** `--duration` TUI run retains exact statistics samples exactly as Press `r` to clear graph history for all targets and return to the live
`irtt-client(1)` does in finite mode, so its retained state grows with the view. This leaves the 500,000-sample cap unchanged. Display history is kept
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI in addition to the client's statistics (see Measurements and memory in
prints **no** memory warning when that estimate gets large: it owns the `irtt-client(1)`). During a long continuous TUI session, whichever uses more
alternate screen, and a stderr warning there would be invisible or would memory for your target count will dominate the total.
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
## 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 ## Exit behavior
mode, peer closure and target failures trigger reconnect rather than ending the
run. A managed driver failure or an unexpected reconnect-update failure still Quitting with `q` or `Ctrl-C` counts as an interrupted, successful exit.
ends the TUI with an error. 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; 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 a peer closure is a normal session ending, while a run whose targets all fail
exits with an error. exits with an error.
## EXAMPLES ## Examples
```sh ```sh
irtt-tui host.example irtt-tui host.example
@@ -175,7 +185,7 @@ irtt-tui eu=host-a.example us=host-b.example --pacing burst
irtt-tui host.example --duration 30s irtt-tui host.example --duration 30s
``` ```
## SEE ALSO ## See also
`irtt-client(1)` for target syntax, pacing, negotiation flags, and `irtt-client(1)` for target syntax, pacing, negotiation flags, and
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`. finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.
+18 -18
View File
@@ -4,9 +4,9 @@ version = 4
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@@ -31,9 +31,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -76,9 +76,9 @@ dependencies = [
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
dependencies = [ dependencies = [
"cmov", "cmov",
] ]
@@ -97,9 +97,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
@@ -132,7 +132,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.1" version = "0.5.2"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
@@ -152,7 +152,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-server" name = "irtt-server"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"getrandom", "getrandom",
"irtt-proto", "irtt-proto",
@@ -174,9 +174,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
@@ -285,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.6" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -316,9 +316,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.2" version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [ dependencies = [
"libc", "libc",
"mio", "mio",
@@ -347,9 +347,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]] [[package]]
name = "wasi" name = "wasi"
+1 -1
View File
@@ -8,7 +8,7 @@ edition = "2021"
cargo-fuzz = true cargo-fuzz = true
[dependencies] [dependencies]
arbitrary = "1.4.2" arbitrary = "1.5.0"
libfuzzer-sys = "0.4.13" libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto] [dependencies.irtt-proto]
+23 -26
View File
@@ -215,7 +215,7 @@ def select_scenarios(args: argparse.Namespace) -> list[Scenario]:
def preflight(args: argparse.Namespace) -> None: def preflight(args: argparse.Namespace) -> None:
if platform.system() != "Linux": if platform.system() != "Linux":
raise RuntimeError("netem interop requires Linux network namespaces and tc netem") raise RuntimeError("netem interop requires Linux network namespaces and tc netem")
for binary in ["ip", "tc"]: for binary in ["ip", "tc", "nsenter"]:
if shutil.which(binary) is None: if shutil.which(binary) is None:
raise RuntimeError(f"required command not found in PATH: {binary}") raise RuntimeError(f"required command not found in PATH: {binary}")
if args.sudo and shutil.which(args.sudo) is None: if args.sudo and shutil.which(args.sudo) is None:
@@ -256,7 +256,7 @@ def sudo_prefix(args: argparse.Namespace) -> list[str]:
def netns(args: argparse.Namespace, namespace: str, command: list[str]) -> list[str]: def netns(args: argparse.Namespace, namespace: str, command: list[str]) -> list[str]:
return [*sudo_prefix(args), "ip", "netns", "exec", namespace, *command] return [*sudo_prefix(args), "nsenter", f"--net=/run/netns/{namespace}", "--", *command]
def run_checked( def run_checked(
@@ -300,12 +300,12 @@ def setup_namespaces(args: argparse.Namespace, run_dir: Path) -> None:
[*sudo_prefix(args), "ip", "link", "add", CLIENT_VETH, "type", "veth", "peer", "name", SERVER_VETH], [*sudo_prefix(args), "ip", "link", "add", CLIENT_VETH, "type", "veth", "peer", "name", SERVER_VETH],
[*sudo_prefix(args), "ip", "link", "set", CLIENT_VETH, "netns", CLIENT_NS], [*sudo_prefix(args), "ip", "link", "set", CLIENT_VETH, "netns", CLIENT_NS],
[*sudo_prefix(args), "ip", "link", "set", SERVER_VETH, "netns", SERVER_NS], [*sudo_prefix(args), "ip", "link", "set", SERVER_VETH, "netns", SERVER_NS],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "addr", "add", CLIENT_ADDR, "dev", CLIENT_VETH], netns(args, CLIENT_NS, ["ip", "addr", "add", CLIENT_ADDR, "dev", CLIENT_VETH]),
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "addr", "add", SERVER_ADDR, "dev", SERVER_VETH], netns(args, SERVER_NS, ["ip", "addr", "add", SERVER_ADDR, "dev", SERVER_VETH]),
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", "lo", "up"], netns(args, CLIENT_NS, ["ip", "link", "set", "lo", "up"]),
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", "lo", "up"], netns(args, SERVER_NS, ["ip", "link", "set", "lo", "up"]),
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", CLIENT_VETH, "up"], netns(args, CLIENT_NS, ["ip", "link", "set", CLIENT_VETH, "up"]),
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", SERVER_VETH, "up"], netns(args, SERVER_NS, ["ip", "link", "set", SERVER_VETH, "up"]),
] ]
for index, command in enumerate(commands): for index, command in enumerate(commands):
run_checked(command, run_dir / "setup" / f"{index:02d}") run_checked(command, run_dir / "setup" / f"{index:02d}")
@@ -409,12 +409,7 @@ def apply_netem(args: argparse.Namespace, scenario: Scenario, scenario_dir: Path
for namespace, dev, setting in commands: for namespace, dev, setting in commands:
if not setting: if not setting:
continue continue
command = [ command = netns(args, namespace, [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc", "tc",
"qdisc", "qdisc",
"replace", "replace",
@@ -423,25 +418,20 @@ def apply_netem(args: argparse.Namespace, scenario: Scenario, scenario_dir: Path
"root", "root",
"netem", "netem",
*shlex.split(setting), *shlex.split(setting),
] ])
run_checked(command, scenario_dir / "netem" / f"{namespace}-{dev}") run_checked(command, scenario_dir / "netem" / f"{namespace}-{dev}")
def delete_qdiscs(args: argparse.Namespace, log_dir: Path) -> None: def delete_qdiscs(args: argparse.Namespace, log_dir: Path) -> None:
for namespace, dev in [(CLIENT_NS, CLIENT_VETH), (SERVER_NS, SERVER_VETH)]: for namespace, dev in [(CLIENT_NS, CLIENT_VETH), (SERVER_NS, SERVER_VETH)]:
command = [ command = netns(args, namespace, [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc", "tc",
"qdisc", "qdisc",
"del", "del",
"dev", "dev",
dev, dev,
"root", "root",
] ])
run_checked( run_checked(
command, command,
log_dir / "qdisc-cleanup" / f"{namespace}-{dev}", log_dir / "qdisc-cleanup" / f"{namespace}-{dev}",
@@ -698,12 +688,19 @@ def cleanup(args: argparse.Namespace, quiet: bool) -> None:
return return
if args.sudo and shutil.which(args.sudo) is None: if args.sudo and shutil.which(args.sudo) is None:
return return
commands = [ commands = []
[*sudo_prefix(args), "ip", "netns", "exec", CLIENT_NS, "tc", "qdisc", "del", "dev", CLIENT_VETH, "root"], if shutil.which("nsenter") is not None:
[*sudo_prefix(args), "ip", "netns", "exec", SERVER_NS, "tc", "qdisc", "del", "dev", SERVER_VETH, "root"], commands.extend([
netns(args, CLIENT_NS, ["tc", "qdisc", "del", "dev", CLIENT_VETH, "root"]),
netns(args, SERVER_NS, ["tc", "qdisc", "del", "dev", SERVER_VETH, "root"]),
])
commands.extend([
[*sudo_prefix(args), "ip", "netns", "delete", CLIENT_NS], [*sudo_prefix(args), "ip", "netns", "delete", CLIENT_NS],
[*sudo_prefix(args), "ip", "netns", "delete", SERVER_NS], [*sudo_prefix(args), "ip", "netns", "delete", SERVER_NS],
] # A setup failure can leave either end of the pair in the host namespace.
[*sudo_prefix(args), "ip", "link", "delete", CLIENT_VETH],
[*sudo_prefix(args), "ip", "link", "delete", SERVER_VETH],
])
for command in commands: for command in commands:
completed = subprocess.run( completed = subprocess.run(
command, command,