Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee 04e1f11f3a feat(client): make reconnect exponential backoff configurable 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 fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 28s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m12s
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 / cargo-deny bans licenses sources (push) Successful in 17s
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 (arm-musleabihf) (push) Successful in 4m9s
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 (mips-musl) (push) Successful in 3m58s
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 check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
Netem Interop / netem interop (push) Successful in 1m20s
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 fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 29s
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 / cargo-deny bans licenses sources (push) Successful in 18s
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 (arm-musleabihf) (push) Successful in 4m0s
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 (mips-musl) (push) Successful in 3m57s
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 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
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m38s
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 fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 29s
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 / cargo-deny bans licenses sources (push) Failing after 4s
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 (arm-musleabihf) (push) Successful in 4m7s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m2s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m26s
Build binaries / Build (i686-musl) (push) Successful in 4m20s
Build binaries / Build (mips-musl) (push) Successful in 4m18s
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 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
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 fmt (push) Failing after 15s
Rust CI / cargo clippy (push) Successful in 30s
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 bans licenses sources (push) Failing after 3s
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 (arm-musleabihf) (push) Successful in 1m40s
Build binaries / Build (armv7-musleabihf) (push) Successful in 1m38s
Build binaries / Build (i686-musl) (push) Successful in 1m39s
Build binaries / Build (mips64-muslabi64) (push) Successful in 2m11s
Build binaries / Build (mips-musl) (push) Successful in 2m14s
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 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
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 fmt (push) Failing after 16s
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
Rust CI / cargo clippy (push) Successful in 37s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 36s
Netem Interop / netem interop (push) Failing after 14s
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 fmt (push) Successful in 16s
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
Rust CI / cargo clippy (push) Successful in 35s
Supply chain / cargo-deny advisories (push) Failing after 4s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 29s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Netem Interop / netem interop (push) Failing after 16s
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 fmt (push) Successful in 16s
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
Rust CI / cargo clippy (push) Successful in 35s
Supply chain / cargo-deny advisories (push) Failing after 4s
Supply chain / cargo-deny bans licenses sources (push) Failing after 3s
Rust CI / cargo doc (push) Successful in 30s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Netem Interop / netem interop (push) Failing after 17s
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 fmt (pull_request) Successful in 29s
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 test (x86_64-unknown-linux-musl) (pull_request) Failing after 9s
Rust CI / cargo doc (pull_request) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 33s
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
Supply chain / actionlint (pull_request) Successful in 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 check (x86_64-unknown-linux-musl) (push) Failing after 11s
Rust CI / cargo clippy (push) Failing after 33s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Supply chain / cargo-deny advisories (push) Failing after 3s
Supply chain / cargo-deny bans licenses sources (push) Failing after 3s
Rust CI / cargo doc (push) Successful in 27s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m20s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Netem Interop / netem interop (push) Failing after 13s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
2026-10-20 19:08:32 +00:00
Nils Andreas Svee 7da8d2141c chore(release): irtt-rs 0.9.0
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo clippy (push) Failing after 35s
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 / cargo-deny bans licenses sources (push) Failing after 5s
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 check (x86_64-unknown-linux-gnu) (push) Successful in 38s
Netem Interop / netem interop (push) Failing after 14s
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
Nils Andreas Svee 38e912632d chore(release): irtt-client 0.6.0, irtt-stats 0.5.2, irtt-rs 0.8.0
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 1m42s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 1m44s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Publish irtt-server image / publish (push) Successful in 1m8s
Semver / cargo-semver-checks (push) Successful in 54s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 33s
Netem Interop / netem interop (push) Successful in 1m8s
2026-10-07 15:35:32 +07:00
Nils Andreas Svee 39cbf7b6b5 Merge branch 'feat/dual-stack'
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 30s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Successful in 1m7s
2026-10-07 14:26:50 +07:00
Nils Andreas Svee eaa37d7568 Merge branch 'feat/refactor-tui-history' 2026-10-07 14:25:49 +07:00
Nils Andreas Svee da7472e10a feat(app): add address-family selection and dual-stack targets 2026-10-07 14:17:11 +07:00
Nils Andreas Svee 9e6c10c083 refactor(tui): compact graph history and retain 500k samples 2026-10-07 12:41:49 +07:00
Nils Andreas Svee 988a360fef Merge branch 'feat/reconnect'
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo clippy (push) Successful in 31s
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 10s
Rust CI / cargo doc (push) Successful in 29s
Supply chain / actionlint (push) Successful in 3s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / cargo-deny advisories (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
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) Successful in 1m13s
2026-10-06 17:59:41 +07:00
Nils Andreas Svee c28e63e67a feat(tui): enable continuous target re-provisioning with generation tracking
Allow targets to automatically restart after completing or failing, and ensure distinct state and statistics across successive target generations.
2026-10-06 17:39:55 +07:00
Nils Andreas Svee c0a4f7a6db Merge branch 'refactor/consolidate-proto-tests'
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 33s
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
Rust CI / cargo doc (push) Successful in 26s
Supply chain / actionlint (push) Successful in 3s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 30s
Netem Interop / netem interop (push) Successful in 1m7s
2026-10-06 17:06:05 +07:00
Nils Andreas Svee 5bc27ef894 refactor(proto): consolidate tests 2026-10-06 17:02:54 +07:00
Nils Andreas Svee 4c941a7d78 feat(tui): format last sample age concisely
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 31s
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 9s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m9s
2026-10-06 14:56:06 +07:00
Nils Andreas Svee b98d1001de perf(tui): optimize graph rendering and increase UI responsiveness
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 34s
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 8s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m5s
Efficiently sample data within the viewport for graph series and reduce the rendering interval for a smoother user experience.
2026-10-06 14:39:05 +07:00
Nils Andreas Svee 8e4424764d feat(tui): interpolate graph series at viewport boundaries
Ensure graph lines extend smoothly to the edges of the visible viewport, preventing abrupt truncations of data series.
2026-10-06 13:20:35 +07:00
Nils Andreas Svee 1e61f4168d refactor(tui): remove superfluous name field from chart series 2026-10-06 12:39:43 +07:00
Nils Andreas Svee 22cead20dc feat(tui): display wall-clock timestamps on graph x-axis
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 34s
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 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m4s
2026-10-06 12:10:52 +07:00
Nils Andreas Svee e8de15d60e refactor(tui): streamline TUI state management and graph data representation
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m4s
Consolidate target creation logic and store richer RTT sample types directly. Improve module encapsulation and enforce invariants for robustness.
2026-10-05 23:49:29 +07:00
77 changed files with 4148 additions and 2674 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
run: |
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
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
- name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with:
registry: ${{ env.GITEA_REGISTRY }}
# The automatic per-job token comes back unauthorized for package
@@ -55,7 +55,7 @@ jobs:
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ secrets.GHCR_USERNAME }}
+24 -1
View File
@@ -34,12 +34,35 @@ jobs:
with:
cache: false
- name: Install Go toolchain
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
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
run: |
ip -V
tc -V
nsenter --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
run: |
+5
View File
@@ -47,6 +47,11 @@ jobs:
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
- name: Install cargo-deny
run: |
set -euo pipefail
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set.
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
with:
persona: regular
inputs: |
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
if: matrix.musl
run: |
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
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.
scan-scheduled:
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:
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
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
+110
View File
@@ -34,6 +34,37 @@ release. The format is loosely based on
## irtt-client
### 0.6.0
#### Added
- Added `ManagedClientHandle::subscribe_status()` and `ManagedStatusSubscription` for observing durable lifecycle and completion state independently of the lossy event stream. The current snapshot is immediately readable, change notifications track subsequent updates, and terminal target status includes its completed outcome.
- Managed targets can override the shared address-family policy while retaining the original hostname for resolution on each new generation.
#### Changed
- `ClientConfig` is now reusable across endpoints: `Client::connect` and `AsyncClient::connect` take the endpoint separately, with configuration grouped into `socket`, `request` (`SessionRequest`), and `open` (`OpenPolicy`). `AddressFamily` replaces the separate socket family flags, and blocking receive timeouts are configured through `Client::set_recv_timeout`.
- Explicit `Authentication`, `HmacKey`, and managed `TargetAuth` replace `ClientConfig.hmac_key` and `ClientAuthConfig`. Key bytes are shared across clones and redacted from debug output. Switching between inherited and explicit authentication or address-family settings starts a new target generation, even when the effective settings are equal.
- `NegotiationResult` replaces `NegotiatedParams`, separating typed `AcceptedSessionParameters` from exact peer-returned wire parameters and accepted `NegotiationChange` records (formerly `NegotiationRestriction`). Open outcomes and lifecycle events now use tuple variants carrying `SessionStarted` or `NoTestCompleted` payloads.
- Low-level clients now leave probe cadence and run duration entirely to the caller. `next_send_deadline` and `is_run_complete` were removed; managed clients continue to provide scheduling and finite-run completion. Low-level callers can inspect `negotiation()`, `has_pending_probes()`, and `next_probe_timeout_deadline()` to drive their own event loop.
- Probe sends now return `Result<SendReceipt, SendProbeError>`, distinguishing failures before socket acceptance from failures after a probe was committed. Receipts convert to `ClientEvent::EchoSent`; its `scheduled_at` and `timer_error` fields are now optional and absent for low-level sends.
- Stop receipts resolve when the stop request is durably observed or the task becomes terminal; callers must still await task completion to observe finished cleanup.
#### Fixed
- Finite managed runs now reach their negotiated end under socket backpressure, even when no pending probe timeout remains to wake the driver. Staggered pacing no longer repeatedly wakes idle or draining targets that have no send deadline.
- Blocking receive operations now return lifecycle errors immediately when no session is open, instead of blocking for a datagram or returning empty success after a timeout.
#### Performance
- Managed drain-deadline lookup no longer scans retained timed-out probes, keeping lookup work bounded as retained probe state grows.
#### Compatibility
- The configuration, authentication, negotiation, lifecycle-event, and send APIs above are breaking changes without compatibility aliases. Low-level callers must enforce their own cadence and duration or use a managed client.
- `AcceptedSessionParameters::dscp` is a six-bit codepoint; `NegotiationResult::peer_params.dscp` remains the raw traffic-class byte. Accepted durations and intervals use Rust duration types rather than wire nanosecond integers.
- Tokio remains optional, and the default client build remains runtime-free. These API changes do not introduce a wire-protocol migration.
### 0.5.3
#### Added
@@ -119,6 +150,29 @@ release. The format is loosely based on
## irtt-stats
### 0.5.2
#### Changed
- `TimeStats` is now re-exported from `measurement-stats`, retaining the existing `irtt_stats::TimeStats` import path, fields, and methods.
- Timer-error statistics now omit sends without scheduling metadata instead of recording an artificial measurement.
#### Fixed
- Time-based rolling windows now expire backdated events correctly, including expired events inside retained arrival history. Older timestamps cannot move the expiry anchor backwards or restore expired history.
- Rolling upstream and downstream loss now use the server receive-count increase over the retained packet-event interval instead of comparing window counts against a cumulative server count.
- Rolling directional-loss estimates are unavailable when counter observations are missing, stale, discontinuous, or separated by an interior gap in the time window, rather than reporting misleading values.
#### Performance
- Indexed rolling-window expiry avoids full-window scans on insertion and reclaims expired entries without accumulating tombstones.
#### Compatibility
- This release consumes `irtt-client` 0.6 events; applications using both crates must upgrade them together.
- Unavailable rolling directional-loss packet estimates are `None`; their percentage fields remain `0.0`. Valid signed estimates remain possible, and raw server receive counts retain their cumulative meaning.
- Time-based rolling windows expire when new events arrive, retain events exactly at the cutoff, and have no event-count cap. Use `rolling_count` when event storage needs a fixed bound.
### 0.5.1
#### Added
@@ -138,6 +192,62 @@ release. The format is loosely based on
## irtt-rs
### 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
#### Added
- Added mutually exclusive `-4` / `--ipv4`, `-6` / `--ipv6`, and `--dual-stack` options to the client and TUI. Dual-stack hostnames expand into independent `/v4` and `/v6` targets with separate sessions, statistics, and failures; explicit IP literals remain single targets.
- Continuous TUI runs now reconnect independently after target failures, peer closure, or server-limited session completion while healthy targets continue uninterrupted. The TUI remains running even when every target fails.
- Added target selection and a scrollable details sheet showing negotiation, counters, timing statistics, warnings, and recent events.
- Graph axes now display local wall-clock timestamps, including fractional seconds or dates where appropriate.
#### Changed
- Replaced separate Graph and Dashboard views with one dashboard comparing all targets. Target rows show effective RTT, cumulative loss, jitter, and the age of the last primary reply. `Tab` / `Shift-Tab` selects a target; `d` opens details, with `g` retained as an alias.
- Reconnecting targets retain their logical row and graph history, reset session statistics for each new generation, and leave gaps between generations.
- Increased graph retention from 100,000 to 500,000 samples per target and extended the maximum graph window from one hour to 24 hours.
- Clearing graph history now clears all targets and returns to live view while preserving latest samples and statistics.
- Client and TUI frontends now react asynchronously to measurements, status changes, input, and shutdown, removing periodic polling delays.
- The application library's `run_stream`, `run_tui`, and `prepare_managed_run` entry points are now asynchronous.
#### Performance
- Graph rendering selects samples within the visible viewport instead of scanning the full retained history, with interpolation at viewport boundaries.
- Reduced graph-history memory usage per sample substantially, allowing retention to increase from 100,000 to 500,000 samples per target without proportional memory growth.
- Increased regular TUI refresh frequency to 100 ms for smoother updates.
#### Compatibility
- Automatic reconnect applies to continuous TUI runs, not ordinary `irtt-client` runs or finite TUI runs. Explicit stops and no-test completion do not trigger retries.
- Dual-stack expansion changes target labels and can double probe traffic per hostname. The stdin limit of 128 desired targets applies after expansion; generated labels must remain unique.
- Address families are discovered when a declaration is added. Each new session resolves the original hostname within its assigned family; newly appearing families are not added automatically. Unchanged stdin declarations retain their discovered families, while removal and re-addition performs fresh discovery.
- New stdin declarations are prepared asynchronously while existing measurements continue. Preparation errors stop the stream gracefully without applying a partial target set.
- The larger graph cap permits higher eventual memory use: approximately 42 MiB per target for retained graph samples at the cap on 64-bit macOS, excluding statistics and rendering buffers.
- Application-library callers must await client/TUI run and preparation functions and supply the new shutdown argument to the run functions.
- `ClientArgs::prepare` and `TuiArgs::prepare` are also asynchronous; `TargetPreparation::prepare` replaces `prepare_managed_targets`, and `parse_stdin_target_set` now returns `TargetSpec` values for preparation.
- The application's former `TargetAuth` type was replaced by `irtt_client::managed::TargetAuth`.
- The public output wrappers `EventRenderStats` and `IpdvPair` were removed in favor of `irtt_stats::EventStatsUpdate` and `IpdvPairUpdate`.
- `DEFAULT_RECV_TIMEOUT`, `is_shutdown_requested`, and `TuiArgs::timestamp_mode` were removed. `install_signal_handler` is now available only with the `server` feature; client/TUI embedding uses `install_async_signal_handler`.
- MSRV remains Rust 1.88. Existing output column names and formats are preserved.
### 0.7.1
#### Fixed
Generated
+364 -142
View File
@@ -17,6 +17,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -114,11 +123,11 @@ dependencies = [
[[package]]
name = "bit-set"
version = "0.8.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
dependencies = [
"bit-vec 0.8.0",
"bit-vec 0.10.1",
]
[[package]]
@@ -129,9 +138,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit-vec"
version = "0.8.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
dependencies = [
"borsh",
"serde",
]
[[package]]
name = "bitflags"
@@ -172,6 +185,30 @@ dependencies = [
"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]]
name = "bumpalo"
version = "3.20.3"
@@ -190,6 +227,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
@@ -199,6 +242,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -211,6 +264,28 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -221,6 +296,15 @@ dependencies = [
"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]]
name = "clap_builder"
version = "4.6.7"
@@ -242,7 +326,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -292,6 +376,18 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -301,6 +397,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -377,9 +482,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865"
dependencies = [
"cmov",
]
@@ -404,7 +509,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -415,7 +520,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -426,9 +531,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "deranged"
version = "0.5.8"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "derive_more"
@@ -497,15 +602,15 @@ dependencies = [
[[package]]
name = "either"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -553,6 +658,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "finl_unicode"
version = "1.5.0"
@@ -632,17 +743,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"rand_core 0.10.1",
]
[[package]]
@@ -677,6 +778,12 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "humantime"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -686,6 +793,30 @@ dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -693,10 +824,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
name = "indexmap"
version = "2.14.2"
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 = [
"rustversion",
]
@@ -711,12 +852,12 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
name = "irtt-client"
version = "0.5.3"
version = "0.6.0"
dependencies = [
"irtt-proto",
"irtt-server",
@@ -749,12 +890,16 @@ dependencies = [
[[package]]
name = "irtt-rs"
version = "0.7.1"
version = "0.9.0"
dependencies = [
"chrono",
"clap",
"clap-num",
"crossterm",
"ctrlc",
"futures-core",
"hex",
"humantime",
"irtt-client",
"irtt-proto",
"irtt-server",
@@ -778,7 +923,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"irtt-client",
"measurement-stats",
@@ -819,11 +964,11 @@ dependencies = [
[[package]]
name = "kasuari"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [
"hashbrown 0.16.1",
"hashbrown",
"portable-atomic",
"thiserror 2.0.21",
]
@@ -842,9 +987,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.190"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libm"
@@ -869,9 +1014,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litrs"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
@@ -894,16 +1039,16 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.17.1",
"hashbrown",
]
[[package]]
name = "mac_address"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a"
checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
dependencies = [
"nix 0.30.1",
"nix 0.31.3",
"windows-sys",
]
@@ -974,19 +1119,6 @@ dependencies = [
"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]]
name = "nix"
version = "0.31.3"
@@ -1038,18 +1170,18 @@ dependencies = [
[[package]]
name = "num_threads"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -1139,19 +1271,21 @@ dependencies = [
[[package]]
name = "pest"
version = "2.9.2"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23"
dependencies = [
"memchr",
"psm",
"stacker",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.9.2"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44"
dependencies = [
"pest",
"pest_generator",
@@ -1159,22 +1293,22 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.2"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
name = "pest_meta"
version = "2.9.2"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710"
dependencies = [
"pest",
]
@@ -1245,17 +1379,17 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "powerfmt"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"zerocopy",
"toml_edit",
]
[[package]]
@@ -1269,16 +1403,17 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bit-set 0.11.1",
"bit-vec 0.10.1",
"bitflags 2.13.2",
"chacha20",
"core_detect",
"num-traits",
"rand 0.9.5",
"rand_chacha",
"rand 0.10.3",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -1286,6 +1421,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
dependencies = [
"cc",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -1324,22 +1468,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.5"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[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",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1350,20 +1484,17 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
"rand_core 0.9.5",
"rand_core 0.10.1",
]
[[package]]
@@ -1391,7 +1522,7 @@ dependencies = [
"bitflags 2.13.2",
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"hashbrown",
"itertools",
"kasuari",
"lru",
@@ -1454,7 +1585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags 2.13.2",
"hashbrown 0.17.1",
"hashbrown",
"indoc",
"instability",
"itertools",
@@ -1590,7 +1721,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -1600,10 +1731,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1649,9 +1786,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "socket2"
@@ -1663,6 +1800,19 @@ dependencies = [
"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]]
name = "static_assertions"
version = "1.1.0"
@@ -1726,9 +1876,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -1861,7 +2011,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -1887,9 +2037,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tokio"
version = "1.53.2"
version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"libc",
"mio",
@@ -1907,7 +2057,37 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"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]]
@@ -1930,9 +2110,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.26"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-segmentation"
@@ -1965,9 +2145,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.27.0"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"atomic",
"getrandom 0.4.3",
@@ -2046,7 +2226,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
"wasm-bindgen-shared",
]
@@ -2153,12 +2333,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2168,28 +2401,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
+2 -3
View File
@@ -14,13 +14,12 @@ edition = "2021"
license = "Apache-2.0"
rust-version = "1.88"
repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs"
[workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
irtt-client = { version = "0.6.0", path = "crates/irtt-client" }
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" }
[profile.release]
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"]
+8 -1
View File
@@ -84,12 +84,15 @@ cargo install irtt-rs --locked
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
isolation with `--no-default-features` plus the applet's own feature, for
example a lightweight client-only install:
example a lightweight client-only install without statistics:
```sh
cargo install irtt-rs --locked --no-default-features --features client
```
Add `stats` (`--features client,stats`) to retain statistics in a client-only
build. Statistics are enabled by default; the TUI always enables them.
For development, or to install directly from a checkout:
```sh
@@ -104,6 +107,9 @@ Probe a server:
```sh
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`):
@@ -162,6 +168,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
| --- | --- |
| `--no-default-features` | `irtt-rs` |
| `--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 tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
+12 -14
View File
@@ -1,12 +1,11 @@
[package]
name = "irtt-rs"
version = "0.7.1"
version = "0.9.0"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
@@ -41,32 +40,31 @@ required-features = ["server"]
doc = false
[features]
default = ["client", "server", "tui"]
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
server = ["dep:irtt-server", "dep:tokio"]
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"]
default = ["client", "server", "tui", "stats"]
client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto"]
stats = ["dep:irtt-stats"]
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]
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
clap = { version = "4.6.7", features = ["derive", "env"] }
clap-num = "=1.2.0"
crossterm = { version = "0.29.0", features = ["event-stream"], 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"] }
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-server = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.30.2", optional = true }
tokio = { version = "1.53.2", default-features = false, features = ["rt", "time", "sync", "macros"], optional = true }
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
[dev-dependencies]
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]
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-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs`
installs everything.
The `client`, `server`, `tui`, and `stats` features are enabled by default, so a
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
+2
View File
@@ -55,6 +55,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
};
match command {
"-V" | "--version" => Ok(AppletDispatch::Help(crate::version::app())),
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client,
@@ -78,6 +79,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
pub fn dispatcher_help() -> String {
let mut command = Command::new("irtt-rs")
.version(env!("CARGO_PKG_VERSION"))
.about("IRTT-compatible multi-applet dispatcher")
.subcommand(Command::new("client").about(client_applet_about()))
.subcommand(Command::new("tui").about(tui_applet_about()))
+76 -11
View File
@@ -9,14 +9,18 @@ use clap::{Parser, ValueEnum};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[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 {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
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>,
@@ -27,6 +31,15 @@ pub struct ClientArgs {
)]
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.
#[arg(
long,
@@ -38,13 +51,43 @@ pub struct ClientArgs {
#[arg(
long,
short = 'd',
default_value = "10s",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
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."
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
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,
/// 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)]
pub common: CommonClientArgs,
@@ -52,6 +95,7 @@ pub struct ClientArgs {
#[arg(
long,
value_enum,
short = 'f',
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."
)]
@@ -83,7 +127,7 @@ pub struct ClientArgs {
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)]
#[arg(short = 'v', long)]
pub verbose: bool,
}
@@ -92,23 +136,44 @@ impl ClientArgs {
///
/// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned());
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.reconnect && !self.is_continuous() {
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.duration,
if self.continuous {
Duration::ZERO
} else {
self.duration
},
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: self.targets_stdin,
},
)
.await?;
if self.no_test {
setup.client.request.run_mode = irtt_client::RunMode::NoTest;
}
Ok(setup)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
self.continuous || self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
+1
View File
@@ -1,6 +1,7 @@
pub mod args;
pub mod output;
pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat};
+21 -12
View File
@@ -9,6 +9,7 @@ use irtt_client::{
ServerTiming, SignedDuration, WarningKind,
};
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
use super::args::{HeaderMode, OutputFormat};
@@ -48,10 +49,12 @@ impl OutputConfig {
})
}
#[cfg(feature = "stats")]
pub(super) fn prints_summary(&self) -> bool {
self.format.prints_summary()
}
#[cfg(feature = "stats")]
pub(super) fn summary_verbose(&self) -> bool {
self.verbose
}
@@ -96,15 +99,22 @@ impl OutputConfig {
&self,
event: &ClientEvent,
target: Option<&str>,
stats: Option<&EventStatsUpdate>,
#[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>,
) -> Option<String> {
let row = OutputRow::from_event(event);
if self.default_table_rows && row.is_default_table_hidden() {
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 {
stats,
ipdv_ns,
target,
verbose: self.verbose,
};
@@ -155,7 +165,7 @@ impl OutputConfig {
#[derive(Debug, Clone, Copy)]
struct RenderContext<'a> {
stats: Option<&'a EventStatsUpdate>,
ipdv_ns: Option<i128>,
target: Option<&'a str>,
verbose: bool,
}
@@ -541,7 +551,7 @@ columns! {
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");
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");
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
Bytes("bytes", [], 10, right, "packet bytes for packet events");
@@ -817,14 +827,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
.reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
.map(|value| CellValue::Integer(signed_duration_us(value))),
Column::Ipdv => row
.seq()
.and_then(|seq| ipdv_pair(context.stats, seq))
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
Column::IpdvUs => row
.seq()
.and_then(|seq| ipdv_pair(context.stats, seq))
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
Column::Ipdv => context.ipdv_ns.map(|value| {
CellValue::text(format_signed_duration(SignedDuration::from_nanos(value)))
}),
Column::IpdvUs => context
.ipdv_ns
.map(|value| CellValue::Integer(value / 1_000)),
Column::ServerProcessing => row
.reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
@@ -931,6 +939,7 @@ fn server_timing_i64(
.map(|value| CellValue::Integer(i128::from(value)))
}
#[cfg(feature = "stats")]
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
let stats = stats?;
stats
+254 -83
View File
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
collections::{HashSet, VecDeque},
io::{self, BufRead, Write},
sync::Arc,
thread,
@@ -7,8 +7,8 @@ use std::{
use irtt_client::{
managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
@@ -16,26 +16,25 @@ use irtt_client::{
use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{
expected_probe_count, parse_stdin_target_set,
parse_stdin_target_set,
session::{
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary,
request_managed_stop_once,
},
worker::ManagedWorker,
ManagedRunSetup, 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};
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;
pub async fn run_stream(
@@ -46,9 +45,11 @@ pub async fn run_stream(
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
};
#[cfg(feature = "stats")]
let multi_target = setup.is_multi_target();
let output_config = OutputConfig::new(
args.format,
@@ -57,34 +58,55 @@ pub async fn run_stream(
args.verbose,
)
.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 {
return run_stdin_stream(setup, output_config, shutdown).await;
return run_stdin_stream(setup, output_config, shutdown, &args).await;
}
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}");
}
if *shutdown.borrow() {
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 status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats,
config: output_config,
header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false,
out: &mut stdout,
};
#[cfg(feature = "stats")]
let mut stats = setup
.targets
.iter()
.filter(|_| !args.no_stats)
.map(|target| {
(
target.label.clone(),
@@ -119,7 +141,7 @@ pub async fn run_stream(
_ = shutdown.changed(), if !interrupted => {}
changed = status.changed() => { if changed.is_err() { break; } }
event = events.recv() => match event {
Ok(event) => process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?,
Ok(event) => process_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?,
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
@@ -132,7 +154,13 @@ pub async fn run_stream(
}
let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| {
process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
process_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)
})?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
@@ -166,21 +194,25 @@ pub async fn run_stream(
})
}),
};
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.show_running_only_summary_note =
continuous && interrupted && stream_output.print_final_summary;
for target in &setup.targets {
let target_stats = stats.get(&target.label).expect(
"every prepared target must have a stats collector inserted before the run begins",
);
if multi_target
&& stream_output.print_final_summary
&& stream_output.config.prints_summary()
{
writeln!(stream_output.out)?;
writeln!(stream_output.out, "target: {}", target.label)?;
#[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 =
continuous && interrupted && stream_output.print_final_summary;
for target in &setup.targets {
let Some(target_stats) = stats.get(&target.label) else {
continue;
};
if multi_target
&& stream_output.print_final_summary
&& stream_output.config.prints_summary()
{
writeln!(stream_output.out)?;
writeln!(stream_output.out, "target: {}", target.label)?;
}
stream_output.print_summary(target_stats)?;
}
stream_output.print_summary(target_stats)?;
}
stream_output.out.flush()?;
if let Some(error) = terminal_error {
@@ -191,7 +223,7 @@ pub async fn run_stream(
#[derive(Clone)]
enum StdinUpdate {
Targets(Vec<ManagedTargetConfig>),
Targets(Vec<TargetSpec>),
Stop(StdinStop),
}
@@ -286,9 +318,7 @@ fn read_stdin_target_sets<R: BufRead>(
}
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => {
updates.send_replace(StdinUpdate::Targets(
targets.into_iter().map(|target| target.managed).collect(),
));
updates.send_replace(StdinUpdate::Targets(targets));
}
Err(error) => {
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
@@ -357,21 +387,33 @@ impl BoundedTargetSet {
fn process_stdin_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
#[cfg(feature = "stats")] stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> {
match event {
ManagedEvent::TargetStateChanged { target, .. } => {
#[cfg(feature = "stats")]
ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => {
stats
.entry(target)
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
ManagedEvent::Client { target, event } => {
let collector = stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
let update = collector.process(&event);
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| {
let collector = stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
if matches!(event, ClientEvent::SessionStarted(_)) {
*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 }
if terminal_targets.insert(outcome.target.clone()) =>
@@ -383,6 +425,7 @@ fn process_stdin_event<W: Write>(
Ok(())
}
#[cfg(feature = "stats")]
fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
@@ -399,10 +442,15 @@ fn reconcile_stdin_stats(
});
}
#[cfg(feature = "stats")]
fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
enabled: bool,
) -> BTreeSet<TargetInstance> {
if !enabled {
return BTreeSet::new();
}
let targets = status
.targets
.iter()
@@ -427,11 +475,21 @@ async fn run_stdin_stream(
setup: ManagedRunSetup,
output_config: OutputConfig,
mut shutdown: tokio::sync::watch::Receiver<bool>,
args: &ClientArgs,
) -> Result<(), Box<dyn std::error::Error>> {
if *shutdown.borrow() {
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 status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?;
@@ -440,15 +498,25 @@ async fn run_stdin_stream(
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats,
config: output_config,
header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false,
out: &mut stdout,
};
#[cfg(feature = "stats")]
let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut stdin_changed = false;
let mut preparation = setup.preparation.clone();
let mut desired_specs = None;
let mut preparing = None;
let mut desired = None;
let mut pending = None;
let mut submitted = Vec::new();
@@ -456,6 +524,7 @@ async fn run_stdin_stream(
let mut dropped_events = 0_u64;
let mut stop_requested = false;
let mut stop = None;
#[cfg(feature = "stats")]
let mut summary_targets = None;
loop {
let snapshot = status.borrow_and_update().clone();
@@ -465,13 +534,25 @@ async fn run_stdin_stream(
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
stdin_changed = false;
match stdin.borrow_and_update().clone() {
StdinUpdate::Targets(targets) => {
desired = Some(targets);
StdinUpdate::Targets(specs) => {
desired_specs = Some(specs);
desired = None;
retry_after = None;
}
StdinUpdate::Stop(reason) => stop = Some(reason),
}
}
if stop.is_none() && preparing.is_none() {
if let Some(specs) = desired_specs.take() {
// Only one preparation runs at a time. Newer records supersede
// its result without piling up uncancellable system DNS jobs.
preparing = Some(Box::pin(
preparation
.clone()
.prepare(specs, Some(STDIN_MAX_DESIRED_TARGETS)),
));
}
}
if stop.is_none()
&& pending.is_none()
&& !retry_after
@@ -494,12 +575,21 @@ async fn run_stdin_stream(
}
}
if stop.is_some() {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
preparing = None;
#[cfg(feature = "stats")]
{
summary_targets = Some(snapshot_stdin_summary_targets(
&mut stats,
&snapshot,
!args.no_stats,
));
}
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
}
#[cfg(feature = "stats")]
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
if snapshot.final_outcome.is_some() {
break;
@@ -508,6 +598,28 @@ async fn run_stdin_stream(
_ = shutdown.changed(), if stop.is_none() => {}
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
changed = status.changed() => { if changed.is_err() { break; } }
result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
preparing = None;
// Let the next loop consume any newer record, including EOF,
// before considering this prepared revision for submission.
if desired_specs.is_none() && !stdin.has_changed().unwrap_or(true) {
match result {
Ok((targets, next_preparation)) => {
preparation = next_preparation;
desired = Some(targets.into_iter().map(|target| target.managed).collect());
retry_after = None;
}
Err(error) => {
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
#[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()); }
}
}
}
}
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
pending = None;
match result {
@@ -515,7 +627,7 @@ async fn run_stdin_stream(
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
// A newer desired set supersedes this rejected set. Otherwise,
// retry only after the driver's status advances from submission.
if desired.is_none() {
if desired.is_none() && desired_specs.is_none() && preparing.is_none() {
desired = Some(std::mem::take(&mut submitted));
} else {
retry_after = None;
@@ -523,14 +635,18 @@ async fn run_stdin_stream(
}
Err(_) => {
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()); }
}
}
}
event = events.recv() => match event {
Ok(event) => {
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?;
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());
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
@@ -543,7 +659,13 @@ async fn run_stdin_stream(
let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| {
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
process_stdin_event(
event,
&mut stream_output,
#[cfg(feature = "stats")]
&mut stats,
&mut terminal_targets,
)
})?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
@@ -560,24 +682,28 @@ async fn run_stdin_stream(
);
}
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
stream_output.print_final_summary = print_summary;
stream_output.show_running_only_summary_note = print_summary;
if let Some(summary_targets) = summary_targets {
let multi_target = summary_targets.len() > 1;
for target in summary_targets {
let stats = stats
.get(&target)
.expect("summary snapshot retains every selected target collector");
if multi_target && stream_output.config.prints_summary() {
writeln!(stream_output.out)?;
writeln!(
stream_output.out,
"target: {} (generation {})",
target.id, target.generation
)?;
#[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.show_running_only_summary_note = print_summary;
if let Some(summary_targets) = summary_targets {
let multi_target = summary_targets.len() > 1;
for target in summary_targets {
let stats = stats
.get(&target)
.expect("summary snapshot retains every selected target collector");
if multi_target && stream_output.config.prints_summary() {
writeln!(stream_output.out)?;
writeln!(
stream_output.out,
"target: {} (generation {})",
target.id, target.generation
)?;
}
stream_output.print_summary(stats)?;
}
stream_output.print_summary(stats)?;
}
}
stream_output.out.flush()?;
@@ -596,16 +722,28 @@ async fn run_stdin_stream(
fn process_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
#[cfg(feature = "stats")] stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
) -> io::Result<()> {
match event {
ManagedEvent::Client { target, event } => {
let collector = stats
.entry(target.id.as_str().to_owned())
.or_insert_with(|| StatsCollector::new(stats_config(false)));
let update = collector.process(&event);
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| {
let collector = stats
.get_mut(target.id.as_str())
.expect("prepared target has a statistics collector");
if matches!(event, ClientEvent::SessionStarted(_)) {
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 } => {
terminal_targets.insert(outcome.target.clone());
@@ -639,13 +777,24 @@ fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
messages
}
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> {
#[cfg(feature = "stats")]
continuous: bool,
#[cfg(feature = "stats")]
collect_stats: bool,
config: OutputConfig,
header_printed: bool,
#[cfg(feature = "stats")]
print_final_summary: bool,
#[cfg(feature = "stats")]
show_running_only_summary_note: bool,
out: &'a mut W,
}
@@ -654,10 +803,15 @@ impl<W: Write> StreamOutput<'_, W> {
&mut self,
event: &ClientEvent,
target: Option<&str>,
update: &EventStatsUpdate,
#[cfg(feature = "stats")] update: Option<&EventStatsUpdate>,
) -> io::Result<()> {
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}")?;
}
Ok(())
@@ -673,6 +827,7 @@ impl<W: Write> StreamOutput<'_, W> {
Ok(())
}
#[cfg(feature = "stats")]
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if self.print_final_summary && self.config.prints_summary() {
write!(
@@ -691,6 +846,7 @@ impl<W: Write> StreamOutput<'_, W> {
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
@@ -698,8 +854,18 @@ fn stats_config(continuous: bool) -> StatsConfig {
StatsConfig::finite()
}
}
#[cfg(feature = "stats")]
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;
}
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
@@ -726,3 +892,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}"))
}
#[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`].
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(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 {
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
/// [`ABSENT`].
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() {
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)
}
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
fn absent() -> String {
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
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)]
#[derive(Clone, Parser)]
#[command(
name = "irtt-server",
version,
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."
)]
@@ -28,13 +29,14 @@ pub struct ServerArgs {
)]
pub bind: Vec<SocketAddr>,
// Decode during configuration preparation so Clap cannot echo rejected keys.
/// HMAC key; every request must then carry a valid MAC.
#[arg(
long,
value_name = "KEY",
value_parser = parse_hmac_key,
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>,
@@ -126,6 +128,23 @@ pub struct ServerArgs {
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`].
///
/// 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.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
pub fn server_config(&self) -> Result<ServerConfig, String> {
let mut config = ServerConfig::default();
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 {
config = config.with_max_sessions(value);
@@ -198,19 +216,10 @@ impl ServerArgs {
if self.no_dscp {
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.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" {
+1 -1
View File
@@ -36,7 +36,7 @@ async fn serve(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config();
let config = args.server_config()?;
let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
+21 -7
View File
@@ -10,7 +10,11 @@ use crate::shared::client::{
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[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 {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg(
@@ -18,7 +22,7 @@ pub struct TuiArgs {
num_args = 1..,
required = true,
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>,
@@ -33,13 +37,18 @@ pub struct TuiArgs {
#[arg(
long,
short = 'd',
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
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,
/// Run continuously until interrupted (equivalent to --duration 0).
#[arg(long, conflicts_with = "duration")]
pub continuous: bool,
#[command(flatten)]
pub common: CommonClientArgs,
}
@@ -49,20 +58,25 @@ impl TuiArgs {
///
/// The TUI always requires a target: the parser rejects an empty target
/// list, and this rejects a target set that cannot be labelled uniquely.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
if self.continuous {
Duration::ZERO
} else {
self.duration
},
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: false,
},
)
.await
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
self.continuous || self.duration == Duration::ZERO
}
}
+91 -43
View File
@@ -1,13 +1,10 @@
use std::{collections::HashSet, future::poll_fn, io, time::Duration};
use std::{future::poll_fn, io, time::Duration};
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{
session::{
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once,
},
session::{drain_final_events, request_managed_stop_once},
worker::ManagedWorker,
},
};
@@ -17,7 +14,10 @@ use crossterm::{
};
use futures_core::Stream;
use irtt_client::{
managed::{ManagedClient, ManagedEndReason, ManagedEvent, TargetInstance},
managed::{
ManagedClient, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy,
ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetLifecycle,
},
ClientEvent,
};
use ratatui::layout::Rect;
@@ -26,15 +26,18 @@ use tokio::{
time::{sleep, Instant},
};
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const RETRY_DELAY: Duration = Duration::from_millis(1500);
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
pub async fn run_tui(
args: TuiArgs,
mut shutdown: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
};
let continuous = args.is_continuous();
if *shutdown.borrow() {
return Ok(());
@@ -46,8 +49,12 @@ pub async fn run_tui(
);
state.set_status(TuiStatus::Opening);
terminal.draw(&state)?;
let (task, handle) = match ManagedClient::task(setup.managed_config(), setup.managed_targets())
{
let desired_targets = setup.managed_targets();
let mut config = setup.managed_config();
if continuous {
config.completion = ManagedCompletionPolicy::ExplicitStop;
}
let (task, handle) = match ManagedClient::task(config, desired_targets.clone()) {
Ok(value) => value,
Err(error) => {
state.set_error(error.to_string());
@@ -75,7 +82,10 @@ pub async fn run_tui(
tokio::pin!(render);
let mut interrupted = false;
let mut stop_requested = false;
let mut terminal_targets = HashSet::new();
let retry = sleep(RETRY_DELAY);
tokio::pin!(retry);
let mut retry_armed = false;
let mut update_receipt = None;
let mut dropped_events = 0_u64;
let mut events_closed = false;
let mut status_closed = false;
@@ -92,18 +102,12 @@ pub async fn run_tui(
}
changed = status.changed(), if !status_closed => {
status_closed = changed.is_err();
let snapshot = status.borrow_and_update().clone();
if request_managed_stop_for_peer_close(
continuous, interrupted, snapshot.peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
state.process_managed_status(&status.borrow_and_update());
force_render = true;
}
event = events.recv(), if !events_closed => match event {
Ok(event) => force_render = process_tui_event(
event, &mut state, &mut terminal_targets,
event, &mut state,
),
Err(RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
@@ -138,11 +142,55 @@ pub async fn run_tui(
_ => {}
}
}
_ = &mut retry, if retry_armed && !interrupted && !*shutdown.borrow() => {
state.process_managed_status(&status.borrow());
retry_armed = false;
update_receipt = match handle.update_targets(desired_targets.clone()) {
Ok(receipt) => Some(receipt),
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed)
if stop_requested || *shutdown.borrow() => None,
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
};
}
result = async { update_receipt.as_mut().unwrap().await },
if update_receipt.is_some() => {
update_receipt = None;
match result {
Ok(ack) => {
state.process_managed_status(&ack.status);
force_render = true;
}
Err(ManagedCommandApplyError::Stopping
| ManagedCommandApplyError::AcknowledgementDisconnected)
if stop_requested || *shutdown.borrow() => {}
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
}
}
_ = &mut render, if !state.paused => force_render = true,
}
if interrupted && request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
if continuous
&& !interrupted
&& !stop_requested
&& !retry_armed
&& update_receipt.is_none()
&& status.borrow().targets.iter().any(|target| {
target.desired
&& target.outcome.as_ref().is_some_and(|outcome| {
matches!(
outcome.end_reason,
ManagedTargetEndReason::Failed(_)
| ManagedTargetEndReason::PeerClosed
| ManagedTargetEndReason::TestComplete
)
})
})
{
retry.as_mut().reset(Instant::now() + RETRY_DELAY);
retry_armed = true;
}
if force_render {
terminal.draw(&state)?;
// Schedule from the completed draw, never catch up missed frames.
@@ -160,37 +208,32 @@ pub async fn run_tui(
}
};
drain_final_events(&mut events, &mut dropped_events, |event| {
process_tui_event(event, &mut state, &mut terminal_targets);
process_tui_event(event, &mut state);
Ok::<(), std::convert::Infallible>(())
})?;
state.mark_dropped_managed_events(dropped_events);
if outcome.discarded_target_outcomes != 0 {
if !continuous && outcome.discarded_target_outcomes != 0 {
state.set_run_error(format!(
"{} final target outcomes were discarded",
outcome.discarded_target_outcomes
));
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
state.process_target_outcome(target);
}
state.process_target_outcome(target);
}
interrupted |= *shutdown.borrow();
let error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
.or_else(|| {
(!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
_ => (!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
}),
};
if let Some(error) = error {
@@ -203,11 +246,7 @@ pub async fn run_tui(
Ok(())
}
fn process_tui_event(
event: ManagedEvent,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
) -> bool {
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
match event {
ManagedEvent::Client { target, event } => {
let force_render = matches!(
@@ -221,10 +260,19 @@ fn process_tui_event(
force_render
}
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
state.process_target_outcome(&outcome);
true
}
ManagedEvent::TargetStateChanged {
target,
lifecycle:
ManagedTargetLifecycle::Pending
| ManagedTargetLifecycle::Connecting
| ManagedTargetLifecycle::Opening,
} => {
state.process_target_opening(&target);
true
}
_ => false,
}
}
File diff suppressed because it is too large. Load diff
+5 -9
View File
@@ -89,11 +89,10 @@ fn with_shutdown_flag(
#[cfg(feature = "client")]
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let shutdown = crate::signal::install_async_signal_handler()
.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()
.enable_all()
.build()?
@@ -107,10 +106,9 @@ fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
#[cfg(feature = "server")]
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
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)
})
}
@@ -122,11 +120,9 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
#[cfg(feature = "tui")]
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let shutdown = crate::signal::install_async_signal_handler()
.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()
.enable_all()
.build()?
+2
View File
@@ -6,3 +6,5 @@ pub mod cmd;
pub mod entry;
pub mod shared;
pub mod signal;
mod version;
+80 -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 irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
@@ -6,16 +6,33 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Clone, clap::Args)]
pub struct CommonClientArgs {
/// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
/// Use IPv4 only.
#[arg(short = '4', long, conflicts_with_all = ["ipv6", "dual_stack"])]
pub ipv4: bool,
/// Use IPv6 only.
#[arg(short = '6', long, conflicts_with_all = ["ipv4", "dual_stack"])]
pub ipv6: bool,
/// Probe available IPv4 and IPv6 paths separately for each hostname.
#[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,
/// Positive probe interval (e.g. 2.5s, 1m30s, 500us, 100ns, 1h).
#[arg(short = 'i', long, default_value = "1s", value_parser = parse_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.
#[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,
/// HMAC key.
#[arg(long, value_parser = parse_hmac_key)]
// Decode during configuration preparation so Clap cannot echo rejected keys.
/// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
#[arg(long)]
pub hmac: Option<String>,
/// Clock mode to request.
@@ -58,6 +75,38 @@ pub struct CommonClientArgs {
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
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.
#[arg(long)]
pub loose: bool,
@@ -65,8 +114,13 @@ pub struct CommonClientArgs {
impl fmt::Debug for CommonClientArgs {
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("ipv6", &self.ipv6)
.field("dual_stack", &self.dual_stack)
.field("interval", &self.interval)
.field("timeouts", &self.timeouts)
.field("length", &self.length)
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
.field("clock", &self.clock)
@@ -75,8 +129,14 @@ impl fmt::Debug for CommonClientArgs {
.field("server_fill", &self.server_fill)
.field("dscp", &self.dscp)
.field("ttl", &self.ttl)
.field("loose", &self.loose)
.finish()
.field("bind_addr", &self.bind_addr);
#[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()
}
}
@@ -137,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> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
let duration = parse_test_duration(input)?;
if duration.is_zero() {
return Err("duration must be greater than zero".to_owned());
}
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"
)),
}
Ok(duration)
}
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
let (number, unit) = split_duration(input)?;
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))
humantime::parse_duration(input).map_err(|error| error.to_string())
}
pub fn parse_length(input: &str) -> Result<u32, String> {
@@ -206,13 +229,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
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> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
+39 -15
View File
@@ -1,26 +1,42 @@
use std::time::Duration;
use irtt_client::{
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
SocketConfig,
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
SessionRequest, SocketConfig,
};
use super::args::CommonClientArgs;
impl CommonClientArgs {
pub fn address_family(&self) -> AddressFamily {
if self.ipv4 {
AddressFamily::Ipv4
} else if self.ipv6 {
AddressFamily::Ipv6
} else {
AddressFamily::Any
}
}
/// Build the shared client configuration template for a managed run.
///
/// The config is endpoint-independent; managed targets own their addresses.
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
ClientConfig {
open: OpenPolicy {
negotiation: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
..Default::default()
pub fn to_client_config(&self, duration: Duration) -> Result<ClientConfig, String> {
let mut open = OpenPolicy {
negotiation: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
..Default::default()
};
if !self.timeouts.is_empty() {
open.timeouts.clone_from(&self.timeouts);
}
Ok(ClientConfig {
address_family: self.address_family(),
open,
request: SessionRequest {
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
@@ -34,16 +50,24 @@ impl CommonClientArgs {
},
auth: self
.hmac
.as_ref()
.as_deref()
.map(crate::shared::decode_hmac_key)
.transpose()?
.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key.as_bytes()))
Authentication::Hmac(HmacKey::new(key))
}),
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,
..SocketConfig::default()
},
..ClientConfig::default()
}
})
}
}
+2 -2
View File
@@ -15,6 +15,6 @@ pub use prepare::{
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
};
pub use targets::{
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
PreparedTarget, TargetArg, TargetSpec,
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
TargetPreparation, TargetSpec,
};
+14 -4
View File
@@ -16,7 +16,7 @@ use irtt_client::{
use super::{
args::CommonClientArgs,
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
PreparedTarget,
};
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
pub pacing: ManagedPacing,
/// Whether this stream may replace its desired set from stdin.
pub stdin_controlled: bool,
/// Preparation policy and family selections for later stdin declarations.
pub preparation: TargetPreparation,
}
impl ManagedRunSetup {
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
///
/// Targets are validated before any configuration is built, so a bad target set
/// fails before the caller can act on a half-prepared run.
pub fn prepare_managed_run(
pub async fn prepare_managed_run(
common: &CommonClientArgs,
duration: Duration,
selection: TargetSelection<'_>,
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
} else {
target_specs(selection.targets)?
};
let targets = prepare_managed_targets(specs)?;
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
.prepare(
specs,
selection
.stdin_controlled
.then_some(STDIN_MAX_DESIRED_TARGETS),
)
.await?;
Ok(ManagedRunSetup {
targets,
client: common.to_client_config(duration),
client: common.to_client_config(duration)?,
pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled,
preparation,
})
}
+155 -16
View File
@@ -1,9 +1,13 @@
use std::{collections::HashSet, fmt};
use std::{
collections::{HashMap, HashSet},
fmt, io,
net::{IpAddr, SocketAddr},
};
use clap::ValueEnum;
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
Authentication, HmacKey,
AddressFamily, Authentication, HmacKey,
};
/// One raw positional target captured by Clap.
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field("address_family", &self.managed.address_family)
.field("auth", &self.managed.auth)
.finish()
}
@@ -83,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum TargetParseError {
EmptyLabel,
EmptyAddress,
InvalidEscape,
EmptyTarget,
InvalidHmac(String),
}
impl fmt::Display for TargetParseError {
@@ -98,6 +104,7 @@ impl fmt::Display for TargetParseError {
Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty",
Self::InvalidHmac(message) => message,
};
f.write_str(message)
}
@@ -192,7 +199,10 @@ fn parse_target_syntax(
let auth = if value.is_empty() {
TargetAuth::Override(Authentication::Unauthenticated)
} 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)
}
@@ -257,17 +267,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = spec.auth;
targets.push(PreparedTarget {
label: spec.label,
managed,
});
/// Family selections for the current desired declarations, never resolved IPs.
/// Unchanged stdin declarations retain their selections; removed declarations
/// are forgotten, so adding them again performs fresh discovery.
#[derive(Debug, Clone)]
pub struct TargetPreparation {
family: AddressFamily,
dual_stack: bool,
discovered: HashMap<String, (String, [bool; 2])>,
}
impl TargetPreparation {
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
Self {
family,
dual_stack,
discovered: HashMap::new(),
}
}
Ok(targets)
pub async fn prepare(
mut self,
specs: Vec<TargetSpec>,
maximum_targets: Option<usize>,
) -> Result<(Vec<PreparedTarget>, Self), String> {
let mut targets = Vec::with_capacity(specs.len());
let mut discovered = HashMap::new();
let mut labels = HashSet::new();
for (index, spec) in specs.into_iter().enumerate() {
let families = if let Some(ip) = literal_ip(&spec.addr) {
match (self.family, ip) {
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
return Err(format!(
"target {}: IPv6 literal conflicts with --ipv4",
index + 1
))
}
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
return Err(format!(
"target {}: IPv4 literal conflicts with --ipv6",
index + 1
))
}
_ => None,
}
} else if self.dual_stack {
let families = match self.discovered.get(&spec.label) {
Some((addr, families)) if *addr == spec.addr => *families,
_ => {
let endpoint = discovery_endpoint(&spec.addr)
.map_err(|error| format!("target {}: {error}", index + 1))?;
discovery_families(tokio::net::lookup_host(endpoint).await)
.map_err(|error| format!("target {}: {error}", index + 1))?
}
};
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
Some(families)
} else {
None
};
for target in expand_target(spec, families) {
if !labels.insert(target.label.clone()) {
return Err("duplicate target label after address-family expansion".to_owned());
}
targets.push(target);
}
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
return Err(format!(
"target set exceeds the {maximum}-target limit after address-family expansion"
));
}
}
self.discovered = discovered;
Ok((targets, self))
}
}
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
endpoint
.parse::<SocketAddr>()
.map(|addr| addr.ip())
.ok()
.or_else(|| endpoint.parse().ok())
.or_else(|| {
if endpoint.starts_with('[') && endpoint.ends_with(']') {
format!("{endpoint}:2112")
.parse::<SocketAddr>()
.ok()
.map(|addr| addr.ip())
} else {
None
}
})
}
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
match endpoint.rsplit_once(':') {
Some((host, port)) => {
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
if host.is_empty() || host.contains(':') || host.starts_with('[') {
return Err("invalid server endpoint".to_owned());
}
Ok((host, port))
}
None => Ok((endpoint, 2112)),
}
}
fn discovery_families(
result: io::Result<impl Iterator<Item = SocketAddr>>,
) -> Result<[bool; 2], String> {
let mut families = [false; 2];
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
families[usize::from(addr.is_ipv6())] = true;
}
if families == [false; 2] {
return Err("DNS lookup returned no usable addresses".to_owned());
}
Ok(families)
}
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
let selections = match families {
None => vec![(spec.label, None)],
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
.into_iter()
.zip(families)
.filter(|(_, exists)| *exists)
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
.collect(),
};
selections
.into_iter()
.map(|(label, family)| {
let mut managed =
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
managed.address_family = family;
managed.auth = spec.auth.clone();
PreparedTarget { label, managed }
})
.collect()
}
/// Parse one complete stdin target set after its line terminator was removed.
@@ -277,7 +416,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
) -> Result<Vec<TargetSpec>, String> {
if record == "[]" {
return Ok(Vec::new());
}
@@ -288,5 +427,5 @@ pub fn parse_stdin_target_set(
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
prepare_managed_targets(target_specs_with_empty(&args, true)?)
target_specs_with_empty(&args, true)
}
+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"))]
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]
name = "irtt-client"
version = "0.5.3"
version = "0.6.0"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["network-programming"]
@@ -15,7 +14,7 @@ categories = ["network-programming"]
irtt-proto.workspace = true
socket2 = { version = "0.6.5", features = ["all"] }
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]
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
+9 -1
View File
@@ -59,9 +59,17 @@ Configuration is grouped by ownership:
probe tracking policy.
Clone one config to connect to multiple endpoints. Managed targets retain their
own ID, endpoint, and authentication inheritance/override; shared
own ID, endpoint, address-family override, and authentication inheritance/override; shared
`ManagedClientConfig.client` is reusable without endpoint mutation.
`ManagedTargetConfig.address_family` defaults to `None`, inheriting the shared
family policy. `Some(AddressFamily::Ipv4)`, `Some(AddressFamily::Ipv6)`, or
`Some(AddressFamily::Any)` overrides it for that target. Every new generation
resolves the original endpoint again; callers can constrain a hostname without
replacing it with a resolved IP. Changing the family specification replaces
that target's generation, including switching between inheritance and an equal
explicit override. Hostname expansion and discovery are frontend policies.
Blocking receive policy belongs to `Client`:
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
+10 -23
View File
@@ -93,8 +93,6 @@ pub(crate) struct AsyncOpenState {
deadline: Option<Instant>,
deadline_timer: Option<Pin<Box<time::Sleep>>>,
request_submitted: bool,
stop_after_current_attempt: bool,
stopped_after_current_attempt: bool,
buffer: [u8; MAX_OPEN_PACKET_SIZE],
cleanup: Option<AsyncOpenCleanup>,
}
@@ -106,8 +104,6 @@ impl AsyncOpenState {
deadline: None,
deadline_timer: None,
request_submitted: false,
stop_after_current_attempt: false,
stopped_after_current_attempt: false,
buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
cleanup: None,
}
@@ -128,21 +124,12 @@ impl AsyncOpenState {
self.deadline = None;
self.deadline_timer = None;
self.request_submitted = false;
self.stopped_after_current_attempt = self.stop_after_current_attempt;
}
pub(crate) fn has_in_flight_work(&self) -> bool {
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 {
self.deadline
.expect("an open attempt has an active deadline")
@@ -324,10 +311,6 @@ impl AsyncClient {
if state.attempt >= attempt_count {
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() {
let timeout = self.machine.config().open.timeouts[state.attempt];
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(Ok(false)) => {
state.finish_attempt();
if state.stopped_after_current_attempt() {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
continue;
}
Poll::Ready(Ok(true)) => {}
@@ -396,9 +376,6 @@ impl AsyncClient {
};
if datagram.received_at.mono > state.deadline() {
state.finish_attempt();
if state.stopped_after_current_attempt() {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
continue;
}
@@ -778,6 +755,16 @@ impl AsyncClient {
.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(
&self,
state: &mut AsyncOpenState,
+3
View File
@@ -47,6 +47,9 @@
//!
#![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(feature = "tokio")]
mod async_client;
mod auth;
+28 -3
View File
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
use thiserror::Error;
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
use super::TargetId;
@@ -48,11 +48,16 @@ impl TargetAuth {
}
}
/// Endpoint and authentication specification for one managed target.
/// Endpoint, address-family and authentication specification for one managed target.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetConfig {
pub id: TargetId,
pub server_addr: String,
/// Override the shared address-family policy. `None` inherits it.
///
/// The endpoint remains a hostname when supplied as one, so every new
/// generation resolves it again using the effective family policy.
pub address_family: Option<AddressFamily>,
pub auth: TargetAuth,
}
@@ -61,6 +66,7 @@ impl ManagedTargetConfig {
Self {
id: id.into(),
server_addr: server_addr.into(),
address_family: None,
auth: TargetAuth::Inherit,
}
}
@@ -86,7 +92,7 @@ pub enum ManagedCompletionPolicy {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedClientConfig {
/// Reusable session/socket configuration shared across target generations.
/// Each target supplies its endpoint and resolves its authentication policy.
/// Each target supplies its endpoint and resolves its family and authentication policies.
pub client: ClientConfig,
pub pacing: ManagedPacing,
pub completion: ManagedCompletionPolicy,
@@ -95,6 +101,15 @@ pub struct ManagedClientConfig {
pub outcome_history_limit: usize,
pub max_live_target_generations: usize,
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 {
@@ -108,6 +123,9 @@ impl Default for ManagedClientConfig {
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
reconnect_timeout: None,
reconnect_initial_delay: Duration::from_millis(250),
reconnect_max_delay: Duration::from_secs(10),
}
}
}
@@ -358,6 +376,13 @@ pub struct ManagedTargetFailure {
/// Configuration failure detected before any runtime work begins.
#[derive(Debug, Error)]
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")]
EmptyInitialTargets,
#[error("duplicate managed target id {id}")]
+7
View File
@@ -327,7 +327,14 @@ impl ManagedClientTask {
let mut non_send_deadline = None;
let mut send_deadline = None;
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() {
TargetPhase::Retrying { deadline } => {
non_send_deadline = non_send_deadline.into_iter().chain(Some(*deadline)).min();
}
TargetPhase::Active {
client,
schedule,
@@ -367,6 +367,35 @@ fn build_task(
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 {
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
}
@@ -397,6 +426,9 @@ fn build_task(
.checked_add(1)
.ok_or(ManagedConfigError::GenerationExhausted)?;
let mut client_config = config.client.clone();
client_config.address_family = target
.address_family
.unwrap_or(config.client.address_family);
client_config.auth = target.auth.resolve(&config.client.auth);
validate_target_config(&client_config).map_err(|source| {
ManagedConfigError::InvalidTarget {
@@ -414,6 +446,10 @@ fn build_task(
server_addr: Arc::from(target.server_addr),
remote: None,
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 }),
});
}
@@ -206,6 +206,12 @@ impl ManagedClientTask {
};
self.targets[index].sync_packets_sent(&client);
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 {
client,
schedule,
+153 -63
View File
@@ -5,7 +5,7 @@ use std::{
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Instant,
time::{Duration, Instant},
};
use crate::{
@@ -27,6 +27,9 @@ type ConnectFuture =
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
pub(super) enum TargetPhase {
Retrying {
deadline: Instant,
},
Pending {
client_config: ClientConfig,
},
@@ -65,6 +68,7 @@ pub(super) enum TargetPhase {
impl TargetPhase {
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
match self {
Self::Retrying { .. } => ManagedTargetLifecycle::Pending,
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
@@ -182,11 +186,30 @@ pub(super) struct TargetRuntime {
pub(super) server_addr: Arc<str>,
pub(super) remote: Option<std::net::SocketAddr>,
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.
pub(super) phase: Option<TargetPhase>,
}
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 {
self.phase
.as_ref()
@@ -218,16 +241,18 @@ impl TargetRuntime {
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
}
/// Adopt the underlying client's authoritative sent count.
///
/// The client owns `packets_sent`; this runtime only mirrors it so a
/// terminal outcome can report it after the client is gone.
/// Combine the current client's authoritative sent count with retired
/// sessions, so one target generation keeps durable accounting on retries.
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) {
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.
@@ -284,6 +309,30 @@ impl ManagedClientTask {
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
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.publish_event(ManagedEvent::Client {
target: self.targets[index].instance.clone(),
@@ -295,9 +344,33 @@ impl ManagedClientTask {
fn finish_target(
&mut self,
index: usize,
end_reason: ManagedTargetEndReason,
mut end_reason: ManagedTargetEndReason,
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));
// Finalize history before the phase publisher installs Terminal, updates
// pacing and publishes one complete status followed by the lifecycle event.
@@ -311,6 +384,12 @@ impl ManagedClientTask {
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) {
let failure = classify_client_error(phase, &error);
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
@@ -332,6 +411,9 @@ impl ManagedClientTask {
};
client.discard_prepared_probe();
self.targets[index].sync_live_packets_sent();
if self.can_reconnect(index) {
return self.begin_close(index, primary_end, None, now);
}
match cleanup {
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
@@ -359,6 +441,27 @@ impl ManagedClientTask {
let retirement = self.effective_retirement(index);
let stopping = self.phase.is_stopping() || retirement.is_some();
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 { .. } => {
if stopping {
self.finish_target(
@@ -409,12 +512,12 @@ impl ManagedClientTask {
let retirement = retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped);
if !open.has_in_flight_work() {
self.targets[index].sync_live_packets_sent();
self.finish_target(index, retirement, None);
return false;
}
open.request_stop_after_current_attempt();
let cleanup_failure = client.cancel_open(open).map(|error| {
classify_client_error(ManagedTargetFailurePhase::Opening, &error)
});
self.targets[index].sync_live_packets_sent();
self.finish_target(index, retirement, cleanup_failure);
return false;
}
match client.poll_open(open, cx) {
Poll::Pending => false,
@@ -438,64 +541,25 @@ impl ManagedClientTask {
index,
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();
self.targets[index].restore_phase(TargetPhase::Active {
client: previous.into_client(),
schedule,
send_waiting: false,
});
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
true
}
let previous = self.targets[index].take_phase();
self.targets[index].restore_phase(TargetPhase::Active {
client: previous.into_client(),
schedule,
send_waiting: false,
});
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
true
}
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
self.publish_client_events(
index,
vec![ClientEvent::NoTestCompleted(completed)],
);
let end_reason = if stopping {
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped)
} else {
ManagedTargetEndReason::NoTestComplete
};
self.finish_target(index, end_reason, None);
self.finish_target(index, ManagedTargetEndReason::NoTestComplete, None);
false
}
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
}
}
@@ -504,6 +568,16 @@ impl ManagedClientTask {
if stopping {
client.discard_prepared_probe();
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(
index,
retirement
@@ -539,6 +613,22 @@ impl ManagedClientTask {
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
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 {
client, schedule, ..
} = self.targets[index].phase_mut()
@@ -45,7 +45,8 @@ struct UpdatePlan {
fn synchronously_retireable(phase: &TargetPhase) -> bool {
match phase {
TargetPhase::Pending { .. }
TargetPhase::Retrying { .. }
| TargetPhase::Pending { .. }
| TargetPhase::Connecting { .. }
| TargetPhase::Terminal { .. } => true,
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
@@ -140,6 +141,10 @@ impl ManagedClientTask {
membership: TargetMembership::Desired,
remote: None,
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 {
client_config: planned.client_config,
}),
@@ -191,6 +196,9 @@ impl ManagedClientTask {
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
}
let mut client_config = self.config.client.clone();
client_config.address_family = target
.address_family
.unwrap_or(self.config.client.address_family);
client_config.auth = target.auth.resolve(&self.config.client.auth);
validate_target_config(&client_config).map_err(|source| {
ManagedCommandApplyError::InvalidTarget {
+1 -1
View File
@@ -10,4 +10,4 @@ publish = false
irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
irtt-server.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"] }
+1 -2
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "protocol"]
categories = ["network-programming"]
@@ -18,4 +17,4 @@ subtle = "2.6.1"
thiserror = "2.0.21"
[dev-dependencies]
proptest = "1.11.0"
proptest = "1.12.0"
-169
View File
@@ -304,172 +304,3 @@ fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
*pos += TIMESTAMP_SIZE;
value
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
hmac,
params::{Clock, ReceivedStats, StampAt},
write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
};
fn default_params() -> Params {
Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
}
}
fn params_with_payload_space(payload_space: usize) -> Params {
let header_len = PacketLayout::echo(false, &Params::default()).header_len();
Params {
length: (header_len + payload_space) as i64,
..Params::default()
}
}
/// Zero-fills every negotiated reply field, matching what a peer that
/// reports no optional values would put on the wire.
fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
if layout.recv_count {
out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
}
if layout.recv_window {
out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
}
for _ in 0..layout.timestamp_count() {
out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
}
}
#[test]
fn echo_reply_decodes_default_fields() {
let packet = [
0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
0x00, 0x00, 0x00, 0x00,
];
let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.sequence, 2);
assert_eq!(reply.recv_count, Some(3));
assert_eq!(reply.recv_window, Some(7));
assert!(reply.timestamps.recv_wall.is_some());
assert!(reply.timestamps.midpoint_wall.is_none());
}
#[test]
fn echo_reply_decodes_exact_negotiated_length_with_payload() {
let params = params_with_payload_space(4);
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
packet.extend_from_slice(&[1, 2, 3, 4]);
let reply = decode_echo_reply(&packet, &params, None).unwrap();
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
}
#[test]
fn echo_reply_rejects_shorter_than_negotiated_length() {
let params = params_with_payload_space(4);
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
assert_eq!(
decode_echo_reply(&packet, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: 20,
actual: 16,
})
);
}
#[test]
fn echo_reply_rejects_longer_than_negotiated_length() {
let params = Params::default();
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
packet.push(0);
assert_eq!(
decode_echo_reply(&packet, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: 16,
actual: 17,
})
);
}
#[test]
fn hmac_echo_reply_decodes_default_fields_after_hmac() {
let params = default_params();
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(layout.header_len());
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
push_zeroed_layout_tail(layout, &mut packet);
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
assert_eq!(packet.len(), 76);
let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.sequence, 2);
assert_eq!(reply.recv_count, Some(0));
assert_eq!(reply.recv_window, Some(0));
assert_eq!(reply.payload.len(), 0);
}
#[test]
fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
let mut params = Params {
length: 48,
..Params::default()
};
params.received_stats = ReceivedStats::Both;
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(echo_packet_len(true, &params).unwrap());
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
push_zeroed_layout_tail(layout, &mut packet);
packet.extend_from_slice(&[1, 2, 3, 4]);
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
}
#[test]
fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
let params = Params::default();
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
packet.push(0);
assert_eq!(
decode_echo_reply(&packet, &params, Some(b"testkey")),
Err(ProtoError::PacketLengthMismatch {
expected: 32,
actual: 33,
})
);
}
}
-98
View File
@@ -136,101 +136,3 @@ fn with_hmac_flag(flags: u8, authenticated: bool) -> u8 {
flags & !FLAG_HMAC
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
flags::{FLAG_OPEN, FLAG_REPLY},
MAGIC,
};
const KEY: &[u8] = b"testkey";
const OPEN_REQUEST_RULES: &[FlagRule] =
&[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)];
fn header(flags: u8) -> Vec<u8> {
let mut packet = MAGIC.to_vec();
packet.push(flags);
packet
}
#[test]
fn decode_rejects_bad_magic_and_reserved_flags() {
assert_eq!(
decode(&[0, MAGIC[1], MAGIC[2], FLAG_OPEN], None, &[]),
Err(ProtoError::BadMagic)
);
assert_eq!(
decode(&header(FLAG_OPEN | 0x10), None, &[]),
Err(ProtoError::ReservedFlags(0x10))
);
}
#[test]
fn decode_enforces_required_and_rejected_flags() {
assert_eq!(
decode(&header(0), None, OPEN_REQUEST_RULES),
Err(ProtoError::MissingFlag(FLAG_OPEN))
);
assert_eq!(
decode(&header(FLAG_OPEN | FLAG_REPLY), None, OPEN_REQUEST_RULES),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
);
}
#[test]
fn decode_rejects_hmac_presence_mismatch() {
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
authenticated.extend_from_slice(&[0; HMAC_SIZE]);
assert_eq!(
decode(&authenticated, None, OPEN_REQUEST_RULES),
Err(ProtoError::HmacPresenceMismatch)
);
assert_eq!(
decode(&header(FLAG_OPEN), Some(KEY), OPEN_REQUEST_RULES),
Err(ProtoError::HmacPresenceMismatch)
);
}
#[test]
fn structural_decode_reports_hmac_presence_without_a_key() {
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
authenticated.extend_from_slice(&[0xff; HMAC_SIZE]);
let envelope = decode_structural(&authenticated).unwrap();
assert!(envelope.hmac_present);
assert_eq!(envelope.body_offset, HEADER_SIZE + HMAC_SIZE);
let envelope = decode_structural(&header(FLAG_OPEN)).unwrap();
assert!(!envelope.hmac_present);
assert_eq!(envelope.body_offset, HEADER_SIZE);
}
#[test]
fn verify_rejects_bad_hmac() {
let mut packet = begin_checked(FLAG_OPEN, Some(KEY), OPEN_REQUEST_RULES, 22).unwrap();
packet.extend_from_slice(&[1, 2]);
let mut packet = finish(packet, Some(KEY)).unwrap();
*packet.last_mut().unwrap() ^= 0x80;
decode(&packet, Some(KEY), OPEN_REQUEST_RULES).unwrap();
assert_eq!(verify(&packet, Some(KEY)), Err(ProtoError::BadHmac));
}
#[test]
fn encoder_key_authoritatively_normalizes_hmac_flag() {
let plain =
begin_checked(FLAG_OPEN | FLAG_HMAC, None, OPEN_REQUEST_RULES, HEADER_SIZE).unwrap();
assert_eq!(plain[3], FLAG_OPEN);
let authenticated = begin_checked(
FLAG_OPEN,
Some(KEY),
OPEN_REQUEST_RULES,
HEADER_SIZE + HMAC_SIZE,
)
.unwrap();
assert_eq!(authenticated[3], FLAG_OPEN | FLAG_HMAC);
}
}
-73
View File
@@ -96,76 +96,3 @@ pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
pub(crate) fn hmac_offset() -> usize {
HEADER_SIZE
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
flags::{FLAG_HMAC, FLAG_OPEN},
MAGIC,
};
fn reference_copy_zero_hmac(
key: &[u8],
packet: &[u8],
hmac_offset: usize,
) -> Result<[u8; HMAC_SIZE]> {
if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE {
return Err(ProtoError::InvalidHmacOffset);
}
let mut copy = packet.to_vec();
copy[hmac_offset..hmac_offset + HMAC_SIZE].fill(0);
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC accepts keys of any size");
mac.update(&copy);
let bytes = mac.finalize().into_bytes();
let mut out = [0u8; HMAC_SIZE];
out.copy_from_slice(&bytes);
Ok(out)
}
fn packet_with_hmac_field(hmac_bytes: [u8; HMAC_SIZE]) -> Vec<u8> {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN | FLAG_HMAC);
packet.extend_from_slice(&hmac_bytes);
packet.extend_from_slice(&[1, 2, 3, 4]);
packet
}
#[test]
fn compute_and_verify_hmac() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(
verify_hmac(b"wrong", &packet, hmac_offset()),
Err(ProtoError::BadHmac)
);
}
#[test]
fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
let reference = reference_copy_zero_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(digest, reference);
}
#[test]
fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
packet[hmac_offset() + HMAC_SIZE + 1] ^= 0x01;
assert_eq!(
verify_hmac(b"testkey", &packet, hmac_offset()),
Err(ProtoError::BadHmac)
);
}
}
-191
View File
@@ -162,194 +162,3 @@ pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
};
Ok(header_len.max(requested))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{Clock, ReceivedStats, StampAt};
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
Params {
received_stats: stats,
stamp_at,
clock,
..Params::default()
}
}
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
let stats_len = match stats {
ReceivedStats::None => 0,
ReceivedStats::Count => RECV_COUNT_SIZE,
ReceivedStats::Window => RECV_WINDOW_SIZE,
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
let timestamp_groups = match stamp_at {
StampAt::None => 0,
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
StampAt::Both => 2,
};
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
}
#[test]
fn verified_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::None, StampAt::None, Clock::Both)
),
16
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Count, StampAt::Send, Clock::Wall)
),
28
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Window, StampAt::Receive, Clock::Monotonic)
),
32
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Both, StampAt::Midpoint, Clock::Both)
),
44
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Both, StampAt::Both, Clock::Both)
),
60
);
assert_eq!(
echo_header_len(
true,
&params(ReceivedStats::Both, StampAt::Both, Clock::Both)
),
76
);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for hmac in [false, true] {
let params = params(stats, stamp_at, clock);
let layout = PacketLayout::echo(hmac, &params);
let expected_len = HEADER_SIZE
+ TOKEN_SIZE
+ SEQ_SIZE
+ if hmac { HMAC_SIZE } else { 0 }
+ expected_optional_len(stats, stamp_at, clock);
assert_eq!(
layout.header_len(),
expected_len,
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
);
assert_eq!(layout.recv_count, stats.has_count());
assert_eq!(layout.recv_window, stats.has_window());
assert_eq!(
layout.recv_wall,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& clock.has_wall()
);
assert_eq!(
layout.recv_mono,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& clock.has_mono()
);
assert_eq!(
layout.midpoint_wall,
matches!(stamp_at, StampAt::Midpoint) && clock.has_wall()
);
assert_eq!(
layout.midpoint_mono,
matches!(stamp_at, StampAt::Midpoint) && clock.has_mono()
);
assert_eq!(
layout.send_wall,
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_wall()
);
assert_eq!(
layout.send_mono,
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_mono()
);
if hmac {
assert_eq!(
echo_header_len(true, &params) - echo_header_len(false, &params),
HMAC_SIZE
);
}
}
}
}
}
}
#[test]
fn negotiated_length_is_floored_at_the_required_field_block() {
// The 60-byte header of this layout is the floor. A negative negotiated
// length is not an error: it asks for nothing beyond the mandatory
// fields, exactly as zero does.
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
p.length = length;
assert_eq!(
echo_packet_len(false, &p),
Ok(expected),
"unexpected packet length for negotiated length {length}"
);
}
}
/// A positive length wider than `usize` must stay an error rather than
/// becoming a saturated buffer size. Only reachable where `usize` is
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
#[cfg(target_pointer_width = "32")]
#[test]
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 5_000_000_000;
assert_eq!(
echo_packet_len(false, &p),
Err(ProtoError::PacketLengthUnrepresentable {
length: 5_000_000_000
})
);
}
}
+3
View File
@@ -77,6 +77,9 @@
//!
#![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod echo;
mod envelope;
pub mod error;
-50
View File
@@ -98,53 +98,3 @@ pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenR
params: Params::decode(&packet[pos..])?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
hmac,
params::{Clock, ReceivedStats, StampAt},
FLAG_HMAC, HMAC_SIZE,
};
fn default_params() -> Params {
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
}
}
#[test]
fn open_reply_decodes_token() {
let mut packet = vec![0x14, 0xa7, 0x5b, 0x03];
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&default_params().encode());
let reply = decode_open_reply(&packet, None).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.params, default_params());
}
#[test]
fn hmac_open_reply_decodes_token_after_hmac_field() {
let mut packet = vec![0x14, 0xa7, 0x5b, FLAG_OPEN | FLAG_REPLY | FLAG_HMAC];
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&default_params().encode());
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
assert_eq!(
packet.len(),
4 + HMAC_SIZE + TOKEN_SIZE + default_params().encode().len()
);
let reply = decode_open_reply(&packet, Some(b"testkey")).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.params, default_params());
}
}
-770
View File
@@ -368,773 +368,3 @@ fn read_int(input: &[u8], pos: &mut usize) -> Result<i64> {
*pos += used;
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_round_trip(params: Params) {
assert_eq!(Params::decode(&params.encode()), Ok(params));
}
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(value.len() as u64, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(tag, &mut encoded);
varint::encode_varint(value, &mut encoded);
encoded
}
#[test]
fn params_round_trip() {
let params = Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
server_fill: Some(ServerFill {
value: "pattern:abc".to_owned(),
}),
};
assert_round_trip(params);
}
#[test]
fn params_round_trip_negotiated_option_modes() {
for received_stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
assert_round_trip(Params {
protocol_version: 1,
received_stats,
..Params::default()
});
}
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
assert_round_trip(Params {
protocol_version: 1,
stamp_at,
clock: Clock::Both,
..Params::default()
});
}
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params {
protocol_version: 1,
stamp_at: StampAt::Both,
clock,
..Params::default()
});
}
}
#[test]
fn params_round_trip_dscp_values_are_encoded_without_shifting() {
// `Params::dscp` is a raw wire byte; encode/decode carry whatever
// value is set without interpreting or shifting it, regardless of
// whether it happens to look like a codepoint (46) or a raw TOS byte
// (184).
for dscp in [0, 46, 63, 64, 184, -1] {
let params = Params {
protocol_version: 1,
dscp,
..Params::default()
};
assert_round_trip(params);
}
let params = Params {
protocol_version: 1,
dscp: 46,
..Params::default()
};
let encoded = params.encode();
assert!(
encoded.windows(2).any(|bytes| bytes == [8, 92]),
"dscp value 46 must be encoded as param value 46"
);
assert!(
!encoded.windows(3).any(|bytes| bytes == [8, 0xf0, 0x02]),
"Params::encode must not shift dscp 46 to 184; any codepoint shift is a caller concern"
);
assert_eq!(Params::decode(&encoded).unwrap().dscp, 46);
}
#[test]
fn server_fill_absent_short_and_max_length_round_trip() {
assert_round_trip(Params {
protocol_version: 1,
server_fill: None,
..Params::default()
});
assert_round_trip(Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
..Params::default()
});
assert_round_trip(Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
}),
..Params::default()
});
}
#[test]
fn server_fill_decode_accepts_max_length() {
let value = b"0123456789abcdef0123456789abcdef";
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
assert_eq!(
params.server_fill,
Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
})
);
}
#[test]
fn server_fill_decode_rejects_oversized_length() {
let value = b"0123456789abcdef0123456789abcdefx";
assert_eq!(
Params::decode(&encode_server_fill_value(value)),
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
);
}
#[test]
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
let params = Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
..Params::default()
};
let encoded = params.encode();
assert!(encoded
.windows(6)
.any(|bytes| bytes == [9, 4, b'r', b'a', b'n', b'd']));
}
#[test]
fn unknown_tags_are_ignored() {
let mut encoded = Vec::new();
varint::encode_uvarint(99, &mut encoded);
varint::encode_varint(123, &mut encoded);
varint::encode_uvarint(1, &mut encoded);
varint::encode_varint(1, &mut encoded);
let params = Params::decode(&encoded).unwrap();
assert_eq!(params.protocol_version, 1);
}
#[test]
fn invalid_received_stats_value_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(5, &mut encoded);
varint::encode_varint(4, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "ReceivedStats",
value: 4,
})
);
}
#[test]
fn invalid_timestamp_value_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(6, &mut encoded);
varint::encode_varint(5, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "StampAt",
value: 5,
})
);
}
#[test]
fn explicit_clock_zero_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(7, &mut encoded);
varint::encode_varint(0, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
assert_eq!(
Params::decode_with_presence(&encoded),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
#[test]
fn explicit_clock_values_one_to_three_are_accepted() {
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
assert_eq!(decoded.params.clock, expected);
assert!(decoded.presence.clock);
}
}
#[test]
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
let decoded = Params::decode_with_presence(&[]).unwrap();
assert_eq!(decoded.params, Params::default());
assert_eq!(decoded.params.protocol_version, 0);
assert_eq!(decoded.params.duration_ns, 0);
assert_eq!(decoded.params.interval_ns, 0);
assert_eq!(decoded.params.length, 0);
assert_eq!(decoded.params.dscp, 0);
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
assert_eq!(decoded.params.stamp_at, StampAt::None);
// The wire default clock is zero, not `Both`.
assert_eq!(decoded.params.clock, Clock::Unspecified);
assert!(!decoded.params.clock.has_wall());
assert!(!decoded.params.clock.has_mono());
assert_eq!(decoded.params.server_fill, None);
assert_eq!(decoded.presence, ParamPresence::default());
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: false,
duration_ns: false,
interval_ns: false,
length: false,
received_stats: false,
stamp_at: false,
clock: false,
dscp: false,
server_fill: false,
}
);
}
#[test]
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
// Duration and Interval are the pair a server must tell apart: absent is
// accepted, explicitly zero is not.
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
assert_eq!(absent.params.duration_ns, 0);
assert!(!absent.presence.duration_ns);
assert_eq!(absent.params.interval_ns, 0);
assert!(!absent.presence.interval_ns);
assert_eq!(absent.params.clock, Clock::Unspecified);
assert!(!absent.presence.clock);
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
assert_eq!(explicit_duration.params.duration_ns, 0);
assert!(explicit_duration.presence.duration_ns);
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
assert_eq!(explicit_interval.params.interval_ns, 0);
assert!(explicit_interval.presence.interval_ns);
}
#[test]
fn every_known_tag_reports_its_own_presence() {
let mut encoded = Vec::new();
for (tag, value) in [
(1, 1),
(2, 5),
(3, 6),
(4, 7),
(5, 3),
(6, 4),
(7, 3),
(8, 184),
] {
encoded.extend_from_slice(&encode_int(tag, value));
}
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
let decoded = Params::decode_with_presence(&encoded).unwrap();
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: true,
duration_ns: true,
interval_ns: true,
length: true,
received_stats: true,
stamp_at: true,
clock: true,
dscp: true,
server_fill: true,
}
);
}
#[test]
fn unspecified_clock_is_encoded_by_omission() {
let params = Params {
protocol_version: 1,
clock: Clock::Unspecified,
..Params::default()
};
let encoded = params.encode();
assert_eq!(encoded, encode_int(1, 1));
assert!(
!encoded.windows(2).any(|bytes| bytes[0] == 7),
"an unspecified clock must omit the tag rather than encode an explicit zero"
);
assert_round_trip(params);
}
#[test]
fn decode_and_decode_with_presence_agree() {
let mut payloads = vec![
Vec::new(),
encode_int(1, 1),
encode_int(2, 0),
encode_server_fill_value(b"rand"),
];
payloads.push(
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
}
.encode(),
);
// Malformed payloads must fail identically through both entry points.
payloads.push(vec![1, 0x80]);
payloads.push(encode_int(7, 0));
for payload in payloads {
assert_eq!(
Params::decode(&payload),
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
"decode and decode_with_presence disagreed on {payload:02x?}"
);
}
}
#[test]
fn malformed_server_fill_is_rejected() {
let encoded = [9, 4, b'a', b'b'];
assert_eq!(Params::decode(&encoded), Err(ProtoError::MalformedParams));
}
#[test]
fn non_utf8_server_fill_is_rejected() {
let encoded = [9, 1, 0xff];
assert_eq!(Params::decode(&encoded), Err(ProtoError::InvalidUtf8));
}
#[cfg(target_pointer_width = "32")]
#[test]
fn server_fill_length_too_large_for_usize_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(u64::from(u32::MAX) + 1, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::ParameterLengthTooLarge {
tag: 9,
length: u64::from(u32::MAX) + 1,
})
);
}
#[test]
fn truncated_varint_parameter_is_rejected() {
let encoded = [1, 0x80];
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
}
#[test]
fn truncated_unknown_parameter_value_is_rejected() {
let encoded = [99];
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
}
mod properties {
use super::*;
use proptest::prelude::*;
fn received_stats_strategy() -> impl Strategy<Value = ReceivedStats> {
prop_oneof![
Just(ReceivedStats::None),
Just(ReceivedStats::Count),
Just(ReceivedStats::Window),
Just(ReceivedStats::Both),
]
}
fn stamp_at_strategy() -> impl Strategy<Value = StampAt> {
prop_oneof![
Just(StampAt::None),
Just(StampAt::Send),
Just(StampAt::Receive),
Just(StampAt::Both),
Just(StampAt::Midpoint),
]
}
/// A `Clock` valid for direct construction, including `Unspecified`
/// (wire omission). Never produces an explicit zero on the wire; see
/// `explicit_clock_strategy` for that.
fn clock_strategy() -> impl Strategy<Value = Clock> {
prop_oneof![
Just(Clock::Unspecified),
Just(Clock::Wall),
Just(Clock::Monotonic),
Just(Clock::Both),
]
}
/// A `Clock` value valid to encode *explicitly* on the wire (tag 7).
/// Excludes `Unspecified`/zero, which is only ever reached by omission.
fn explicit_clock_value_strategy() -> impl Strategy<Value = i64> {
1_i64..=3
}
fn server_fill_strategy() -> impl Strategy<Value = Option<ServerFill>> {
prop_oneof![
Just(None),
proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some(
ServerFill {
value: chars.into_iter().collect(),
}
)),
]
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
received_stats_strategy(),
stamp_at_strategy(),
clock_strategy(),
server_fill_strategy(),
)
.prop_map(
|(
protocol_version,
duration_ns,
interval_ns,
length,
dscp,
received_stats,
stamp_at,
clock,
server_fill,
)| Params {
protocol_version,
duration_ns,
interval_ns,
length,
received_stats,
stamp_at,
clock,
dscp,
server_fill,
},
)
}
fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec<u8>) {
varint::encode_uvarint(tag, out);
varint::encode_varint(value, out);
}
fn encode_tagged_server_fill(value: &str, out: &mut Vec<u8>) {
varint::encode_uvarint(9, out);
varint::encode_uvarint(value.len() as u64, out);
out.extend_from_slice(value.as_bytes());
}
proptest! {
#[test]
fn valid_params_round_trip(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode(&encoded).unwrap();
prop_assert_eq!(decoded, params);
}
#[test]
fn encoded_param_presence_matches_omission_rules(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0);
prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0);
prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0);
prop_assert_eq!(decoded.presence.length, params.length != 0);
prop_assert_eq!(decoded.presence.dscp, params.dscp != 0);
prop_assert_eq!(
decoded.presence.received_stats,
params.received_stats != ReceivedStats::None
);
prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None);
prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified);
prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some());
}
#[test]
fn repeated_protocol_version_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(1, first, &mut encoded);
encode_tagged_int(1, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.protocol_version, second);
prop_assert!(decoded.presence.protocol_version);
}
#[test]
fn repeated_duration_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(2, first, &mut encoded);
encode_tagged_int(2, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.duration_ns, second);
prop_assert!(decoded.presence.duration_ns);
}
#[test]
fn repeated_interval_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(3, first, &mut encoded);
encode_tagged_int(3, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.interval_ns, second);
prop_assert!(decoded.presence.interval_ns);
}
#[test]
fn repeated_length_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(4, first, &mut encoded);
encode_tagged_int(4, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.length, second);
prop_assert!(decoded.presence.length);
}
#[test]
fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) {
let mut encoded = Vec::new();
encode_tagged_int(5, first, &mut encoded);
encode_tagged_int(5, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap());
prop_assert!(decoded.presence.received_stats);
}
#[test]
fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) {
let mut encoded = Vec::new();
encode_tagged_int(6, first, &mut encoded);
encode_tagged_int(6, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap());
prop_assert!(decoded.presence.stamp_at);
}
#[test]
fn repeated_clock_is_last_wins(
first in explicit_clock_value_strategy(),
second in explicit_clock_value_strategy(),
) {
let mut encoded = Vec::new();
encode_tagged_int(7, first, &mut encoded);
encode_tagged_int(7, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap());
prop_assert!(decoded.presence.clock);
}
#[test]
fn repeated_dscp_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(8, first, &mut encoded);
encode_tagged_int(8, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.dscp, second);
prop_assert!(decoded.presence.dscp);
}
#[test]
fn repeated_server_fill_is_last_wins(
first in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
second in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
) {
let mut encoded = Vec::new();
encode_tagged_server_fill(&first, &mut encoded);
encode_tagged_server_fill(&second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(
decoded.params.server_fill,
Some(ServerFill { value: second })
);
prop_assert!(decoded.presence.server_fill);
}
#[test]
fn unknown_scalar_tag_prefix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut prefixed = Vec::new();
varint::encode_uvarint(unknown_tag, &mut prefixed);
varint::encode_uvarint(unknown_value, &mut prefixed);
prefixed.extend_from_slice(&params.encode());
let decoded = Params::decode(&prefixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
#[test]
fn unknown_scalar_tag_suffix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut suffixed = params.encode();
varint::encode_uvarint(unknown_tag, &mut suffixed);
varint::encode_uvarint(unknown_value, &mut suffixed);
let decoded = Params::decode(&suffixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
}
/// Omitted-vs-explicit-zero presence, for the fields where explicit
/// zero is valid on the wire. `Clock` is deliberately excluded: an
/// explicit zero there is rejected outright, not merely "present with
/// the default value" (see `explicit_clock_zero_is_rejected`).
#[test]
fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() {
let scalar_tags = [1_u64, 2, 3, 4, 8];
for tag in scalar_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
assert_eq!(explicit.params, Params::default());
let (omitted_presence, explicit_presence) = match tag {
1 => (
omitted.presence.protocol_version,
explicit.presence.protocol_version,
),
2 => (omitted.presence.duration_ns, explicit.presence.duration_ns),
3 => (omitted.presence.interval_ns, explicit.presence.interval_ns),
4 => (omitted.presence.length, explicit.presence.length),
8 => (omitted.presence.dscp, explicit.presence.dscp),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
let enum_tags = [5_u64, 6];
for tag in enum_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
let (omitted_presence, explicit_presence) = match tag {
5 => (
omitted.presence.received_stats,
explicit.presence.received_stats,
),
6 => (omitted.presence.stamp_at, explicit.presence.stamp_at),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
// `Clock` deliberately breaks the pattern above: explicit zero is
// rejected rather than accepted-but-present.
let omitted = Params::decode_with_presence(&[]).unwrap();
assert_eq!(omitted.params.clock, Clock::Unspecified);
assert!(!omitted.presence.clock);
let mut explicit_clock_zero = Vec::new();
encode_tagged_int(7, 0, &mut explicit_clock_zero);
assert_eq!(
Params::decode_with_presence(&explicit_clock_zero),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
}
}
-109
View File
@@ -59,112 +59,3 @@ pub fn decode_varint(input: &[u8]) -> Result<(i64, usize)> {
let (value, used) = decode_uvarint(input)?;
Ok((zigzag_decode(value), used))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uvarint_round_trips() {
for value in [
0,
1,
2,
127,
128,
255,
16_384,
u32::MAX as u64,
i64::MAX as u64,
u64::MAX,
] {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn signed_varint_round_trips() {
for value in [
i64::MIN,
-9_223_372_036,
-2,
-1,
0,
1,
2,
1_000_000_000,
3_000_000_000,
i64::MAX,
] {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn verified_byte_examples() {
let cases: &[(i64, &[u8])] = &[
(1, &[0x02]),
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
(1472, &[0x80, 0x17]),
(3, &[0x06]),
(184, &[0xf0, 0x02]),
];
for (value, expected) in cases {
let mut encoded = Vec::new();
encode_varint(*value, &mut encoded);
assert_eq!(&encoded, expected);
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
}
let mut tag = Vec::new();
encode_uvarint(9, &mut tag);
assert_eq!(tag, [0x09]);
tag.clear();
encode_uvarint(24, &mut tag);
assert_eq!(tag, [0x18]);
}
mod properties {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_uvarint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_varint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn zigzag_round_trips_from_signed(value: i64) {
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
#[test]
fn zigzag_round_trips_from_unsigned(value: u64) {
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
use irtt_proto::{compute_hmac, compute_hmac_in_place, verify_hmac, ProtoError};
fn captured_open() -> Vec<u8> {
// Captured vector 6; provenance and independently checked digest are in
// docs/protocol/test-vectors/README.md. This is an oracle for the primitive.
"14 a7 5b 09 ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36
01 02 02 80 d0 ac f3 0e 03 80 a8 d6 b9 07 05 06 06 06 07 06"
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect()
}
#[test]
fn computing_hmac_ignores_existing_digest_and_only_replaces_its_field() {
let expected = captured_open();
let mut packet = expected.clone();
packet[4..20].fill(0xa5);
let before = packet.clone();
assert_eq!(
compute_hmac(b"testkey", &packet, 4).unwrap().as_slice(),
&expected[4..20]
);
assert_eq!(packet, before);
compute_hmac_in_place(b"testkey", &mut packet, 4).unwrap();
assert_eq!(packet, expected);
}
#[test]
fn verification_rejects_wrong_key_and_modified_authenticated_bytes() {
let packet = captured_open();
verify_hmac(b"testkey", &packet, 4).unwrap();
assert_eq!(verify_hmac(b"wrong", &packet, 4), Err(ProtoError::BadHmac));
for offset in [0, 4, 21, packet.len() - 1] {
let mut modified = packet.clone();
modified[offset] ^= 1;
assert_eq!(
verify_hmac(b"testkey", &modified, 4),
Err(ProtoError::BadHmac)
);
}
}
#[test]
fn incomplete_or_out_of_bounds_digest_fields_are_rejected_without_mutation() {
for offset in [5, 20, 21, usize::MAX] {
let mut packet = vec![0xa5; 20];
assert_eq!(
compute_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
verify_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
compute_hmac_in_place(b"key", &mut packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(packet, [0xa5; 20]);
}
}
+165
View File
@@ -0,0 +1,165 @@
use irtt_proto::params::{Clock, ReceivedStats, StampAt};
use irtt_proto::{
echo_header_len, echo_packet_len, PacketLayout, Params, HMAC_SIZE, RECV_COUNT_SIZE,
RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
};
const HEADER_SIZE: usize = 4;
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
Params {
received_stats: stats,
stamp_at,
clock,
..Params::default()
}
}
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
let stats_len = match stats {
ReceivedStats::None => 0,
ReceivedStats::Count => RECV_COUNT_SIZE,
ReceivedStats::Window => RECV_WINDOW_SIZE,
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
let timestamp_groups = match stamp_at {
StampAt::None => 0,
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
StampAt::Both => 2,
};
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
}
#[test]
fn open_and_close_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for hmac in [false, true] {
let params = params(stats, stamp_at, clock);
let layout = PacketLayout::echo(hmac, &params);
let expected_len = HEADER_SIZE
+ TOKEN_SIZE
+ SEQ_SIZE
+ if hmac { HMAC_SIZE } else { 0 }
+ expected_optional_len(stats, stamp_at, clock);
assert_eq!(
layout.header_len(),
expected_len,
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
);
assert_eq!(
layout.recv_count,
matches!(stats, ReceivedStats::Count | ReceivedStats::Both)
);
assert_eq!(
layout.recv_window,
matches!(stats, ReceivedStats::Window | ReceivedStats::Both)
);
assert_eq!(
layout.recv_wall,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.recv_mono,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.midpoint_wall,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.midpoint_mono,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.send_wall,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.send_mono,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
if hmac {
assert_eq!(
echo_header_len(true, &params) - echo_header_len(false, &params),
HMAC_SIZE
);
}
}
}
}
}
}
#[test]
fn negotiated_length_is_floored_at_the_required_field_block() {
// The 60-byte header of this layout is the floor. A negative negotiated
// length is not an error: it asks for nothing beyond the mandatory
// fields, exactly as zero does.
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
p.length = length;
assert_eq!(
echo_packet_len(false, &p),
Ok(expected),
"unexpected packet length for negotiated length {length}"
);
}
}
/// A positive length wider than `usize` must stay an error rather than
/// becoming a saturated buffer size. Only reachable where `usize` is
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
#[cfg(target_pointer_width = "32")]
#[test]
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 5_000_000_000;
assert_eq!(
echo_packet_len(false, &p),
Err(irtt_proto::ProtoError::PacketLengthUnrepresentable {
length: 5_000_000_000
})
);
}
+399
View File
@@ -0,0 +1,399 @@
use irtt_proto::{
varint, Clock, ParamPresence, Params, ProtoError, ReceivedStats, ServerFill, StampAt,
};
use proptest::prelude::*;
fn int(tag: u64, value: i64) -> Vec<u8> {
let mut out = Vec::new();
varint::encode_uvarint(tag, &mut out);
varint::encode_varint(value, &mut out);
out
}
fn fill(value: &[u8]) -> Vec<u8> {
let mut out = vec![9];
varint::encode_uvarint(value.len() as u64, &mut out);
out.extend_from_slice(value);
out
}
fn presence(p: ParamPresence) -> [bool; 9] {
[
p.protocol_version,
p.duration_ns,
p.interval_ns,
p.length,
p.received_stats,
p.stamp_at,
p.clock,
p.dscp,
p.server_fill,
]
}
fn assert_error(bytes: &[u8], expected: ProtoError) {
assert_eq!(Params::decode(bytes).unwrap_err(), expected, "{bytes:02x?}");
assert_eq!(
Params::decode_with_presence(bytes).unwrap_err(),
expected,
"{bytes:02x?}"
);
}
#[test]
fn known_encodings_preserve_raw_dscp_and_utf8_byte_lengths() {
for (params, bytes) in [
(
Params {
dscp: 46,
..Params::default()
},
vec![8, 92],
),
(
Params {
dscp: 184,
..Params::default()
},
vec![8, 0xf0, 2],
),
(
Params {
server_fill: Some(ServerFill {
value: "rand".into(),
}),
..Params::default()
},
vec![9, 4, b'r', b'a', b'n', b'd'],
),
(
Params {
server_fill: Some(ServerFill { value: "é".into() }),
..Params::default()
},
vec![9, 2, 0xc3, 0xa9],
),
] {
assert_eq!(params.encode(), bytes);
assert_eq!(Params::decode(&bytes).unwrap(), params);
}
}
#[test]
fn wire_defaults_and_explicit_zero_presence() {
let defaults = Params {
protocol_version: 0,
duration_ns: 0,
interval_ns: 0,
length: 0,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Unspecified,
dscp: 0,
server_fill: None,
};
let absent = Params::decode_with_presence(&[]).unwrap();
assert_eq!(absent.params, defaults);
assert_eq!(presence(absent.presence), [false; 9]);
assert_eq!(defaults.encode(), Vec::<u8>::new());
assert!(!defaults.clock.has_wall());
assert!(!defaults.clock.has_mono());
// Clock is the sole integer tag for which explicit zero is invalid.
for tag in [1, 2, 3, 4, 5, 6, 8] {
let bytes = int(tag, 0);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, defaults);
let mut expected = [false; 9];
expected[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected, "tag {tag}");
assert_eq!(Params::decode(&bytes).unwrap(), decoded.params);
}
let empty_fill = Params::decode_with_presence(&[9, 0]).unwrap();
assert_eq!(
empty_fill.params.server_fill,
Some(ServerFill {
value: String::new()
})
);
assert_eq!(
presence(empty_fill.presence),
[false, false, false, false, false, false, false, false, true]
);
assert_eq!(empty_fill.params.encode(), [9, 0]);
assert_eq!(
Params {
protocol_version: 1,
..defaults
}
.encode(),
[1, 2]
);
}
#[test]
fn known_tags_map_to_their_fields_and_report_isolated_presence() {
for (tag, value, expected) in [
(
1,
1,
Params {
protocol_version: 1,
..Params::default()
},
),
(
2,
5,
Params {
duration_ns: 5,
..Params::default()
},
),
(
3,
6,
Params {
interval_ns: 6,
..Params::default()
},
),
(
4,
7,
Params {
length: 7,
..Params::default()
},
),
(
8,
184,
Params {
dscp: 184,
..Params::default()
},
),
] {
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
assert_eq!(decoded.params, expected);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
for (tag, modes) in [
(
5,
vec![
ReceivedStats::None as i64,
ReceivedStats::Count as i64,
ReceivedStats::Window as i64,
ReceivedStats::Both as i64,
],
),
(
6,
vec![
StampAt::None as i64,
StampAt::Send as i64,
StampAt::Receive as i64,
StampAt::Both as i64,
StampAt::Midpoint as i64,
],
),
(
7,
vec![
Clock::Wall as i64,
Clock::Monotonic as i64,
Clock::Both as i64,
],
),
] {
for (index, mode) in modes.into_iter().enumerate() {
let value = index as i64 + if tag == 7 { 1 } else { 0 };
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
let actual = match tag {
5 => decoded.params.received_stats as i64,
6 => decoded.params.stamp_at as i64,
7 => decoded.params.clock as i64,
_ => unreachable!(),
};
assert_eq!(actual, mode);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
}
}
#[test]
fn malformed_values_and_invalid_enums_are_rejected_by_both_decoders() {
for (tag, value, name) in [
(5, 4, "ReceivedStats"),
(6, 5, "StampAt"),
(7, 0, "Clock"),
(7, 4, "Clock"),
(5, -1, "ReceivedStats"),
(6, -1, "StampAt"),
(7, -1, "Clock"),
] {
assert_error(&int(tag, value), ProtoError::InvalidEnum { name, value });
}
for bytes in [&[1, 0x80][..], &[99], &[0x80], &[9, 0x80]] {
assert_error(bytes, ProtoError::TruncatedVarint);
}
}
#[test]
fn server_fill_accepts_valid_bytes_and_enforces_length_and_utf8_boundaries() {
for value in [
"",
"rand",
"0123456789abcdef0123456789abcdef",
"éééééééééééééééé",
] {
let expected = Params {
server_fill: Some(ServerFill {
value: value.into(),
}),
..Params::default()
};
let bytes = fill(value.as_bytes());
assert_eq!(expected.encode(), bytes);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, expected);
assert!(decoded.presence.server_fill);
assert_eq!(Params::decode(&bytes).unwrap(), expected);
}
assert_error(
&fill(b"0123456789abcdef0123456789abcdefx"),
ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 },
);
assert_error(&[9, 4, b'a', b'b'], ProtoError::MalformedParams);
assert_error(&[9, 1, 0xff], ProtoError::InvalidUtf8);
}
#[cfg(target_pointer_width = "32")]
#[test]
fn server_fill_length_too_large_for_usize_is_rejected() {
let mut bytes = vec![9];
let length = u64::from(u32::MAX) + 1;
varint::encode_uvarint(length, &mut bytes);
assert_error(
&bytes,
ProtoError::ParameterLengthTooLarge { tag: 9, length },
);
}
fn string_strategy() -> impl Strategy<Value = String> {
// Eight arbitrary Unicode scalars occupy at most the allowed 32 bytes.
proptest::collection::vec(any::<char>(), 0..=8).prop_map(|chars| chars.into_iter().collect())
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<[i64; 5]>(),
prop::sample::select(vec![
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
]),
prop::sample::select(vec![
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
]),
prop::sample::select(vec![
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
]),
prop::option::of(string_strategy()),
)
.prop_map(
|(scalar, received_stats, stamp_at, clock, server_fill)| Params {
protocol_version: scalar[0],
duration_ns: scalar[1],
interval_ns: scalar[2],
length: scalar[3],
dscp: scalar[4],
received_stats,
stamp_at,
clock,
server_fill: server_fill.map(|value| ServerFill { value }),
},
)
}
fn all_tags(params: &Params) -> Vec<u8> {
let mut out = Vec::new();
// Deliberately bypass encode's omission: duplicate zero values must be present.
for (tag, value) in [
(1, params.protocol_version),
(2, params.duration_ns),
(3, params.interval_ns),
(4, params.length),
(5, params.received_stats as i64),
(6, params.stamp_at as i64),
(7, params.clock as i64),
(8, params.dscp),
] {
out.extend_from_slice(&int(tag, value));
}
out.extend_from_slice(&fill(params.server_fill.as_ref().unwrap().value.as_bytes()));
out
}
proptest! {
#[test]
fn valid_params_round_trip_with_omission_presence(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(Params::decode(&encoded).unwrap(), params.clone());
prop_assert_eq!(presence(decoded.presence), [
params.protocol_version != 0, params.duration_ns != 0,
params.interval_ns != 0, params.length != 0,
params.received_stats != ReceivedStats::None, params.stamp_at != StampAt::None,
params.clock != Clock::Unspecified, params.dscp != 0, params.server_fill.is_some(),
]);
}
#[test]
fn repeated_known_tags_are_last_wins(
mut first in params_strategy(), mut second in params_strategy(),
first_clock in 1_i64..=3, second_clock in 1_i64..=3,
first_fill in string_strategy(), second_fill in string_strategy(),
) {
first.clock = Clock::try_from(first_clock).unwrap();
second.clock = Clock::try_from(second_clock).unwrap();
first.server_fill = Some(ServerFill { value: first_fill });
second.server_fill = Some(ServerFill { value: second_fill });
let mut bytes = all_tags(&first);
bytes.extend_from_slice(&all_tags(&second));
let decoded = Params::decode_with_presence(&bytes).unwrap();
prop_assert_eq!(&decoded.params, &second);
prop_assert_eq!(Params::decode(&bytes).unwrap(), second);
prop_assert_eq!(presence(decoded.presence), [true; 9]);
}
#[test]
fn unknown_scalar_tags_do_not_disturb_known_params(
params in params_strategy(), tag in 10_u64..=u16::MAX as u64, value: u64,
) {
let encoded = params.encode();
let expected = Params::decode_with_presence(&encoded).unwrap();
let mut unknown = Vec::new();
varint::encode_uvarint(tag, &mut unknown);
varint::encode_uvarint(value, &mut unknown);
for bytes in [[unknown.as_slice(), &encoded].concat(), [&encoded, unknown.as_slice()].concat()] {
prop_assert_eq!(Params::decode_with_presence(&bytes).unwrap(), expected.clone());
prop_assert_eq!(Params::decode(&bytes).unwrap(), params.clone());
}
}
}
@@ -54,33 +54,41 @@ fn echo_reply(params: &Params, flags: u8) -> EchoReply {
}
#[test]
fn authenticated_reply_encoders_set_hmac_from_key() {
fn reply_encoders_normalize_hmac_flags_from_the_key() {
let params = params();
let open_reply = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_open_reply(&packet, Some(KEY)),
Ok(OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC,
..open_reply
})
);
let echo_reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&echo_reply, &params, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_echo_reply(&packet, &params, Some(KEY)),
Ok(EchoReply {
flags: FLAG_REPLY | FLAG_HMAC,
..echo_reply
})
);
for key in [None, Some(KEY)] {
for supplied_hmac in [0, FLAG_HMAC] {
let auth = if key.is_some() { FLAG_HMAC } else { 0 };
let open = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | supplied_hmac,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open, key).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | auth);
let offset = if key.is_some() { 20 } else { 4 };
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(
decode_open_reply(&packet, key).unwrap(),
OpenReply {
flags: packet[3],
..open
}
);
let echo = echo_reply(&params, FLAG_REPLY | supplied_hmac);
let packet = encode_echo_reply(&echo, &params, key).unwrap();
assert_eq!(packet[3], FLAG_REPLY | auth);
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(&packet[offset + 8..offset + 12], &u32::MAX.to_le_bytes());
assert_eq!(
decode_echo_reply(&packet, &params, key).unwrap(),
EchoReply {
flags: packet[3],
..echo
}
);
}
}
}
#[test]
@@ -95,6 +103,12 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
};
let packet = encode_open_reply(&rejected, None).unwrap();
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
let mut zero_without_close = packet.clone();
zero_without_close[3] &= !FLAG_CLOSE;
assert_eq!(
decode_open_reply(&zero_without_close, None),
Err(ProtoError::ZeroToken)
);
assert_eq!(
encode_open_reply(
@@ -119,63 +133,51 @@ fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() {
}
#[test]
fn reply_encoders_supply_their_packet_type_rules() {
fn reply_codecs_enforce_packet_type_and_reserved_flag_rules() {
let params = Params::default();
assert_eq!(
encode_open_reply(
&OpenReply {
flags: FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
None,
for (flags, expected) in [
(FLAG_REPLY, ProtoError::MissingFlag(FLAG_OPEN)),
(FLAG_OPEN, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY | 0x10,
ProtoError::ReservedFlags(0x10),
),
Err(ProtoError::MissingFlag(FLAG_OPEN))
);
assert_eq!(
encode_echo_reply(
&EchoReply {
flags: 0,
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::MissingFlag(FLAG_REPLY))
);
}
/// Every packet the reply encoders produce must be rejected by the inbound
/// request decoder: a server never answers a datagram carrying `FLAG_REPLY`.
#[test]
fn encoded_replies_are_never_admitted_as_inbound_requests() {
let params = Params::default();
for reply_flags in [FLAG_OPEN | FLAG_REPLY, FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_open_reply(
] {
let open = OpenReply {
flags,
token: TOKEN,
params: params.clone(),
};
assert_eq!(encode_open_reply(&open, None).unwrap_err(), expected);
let mut packet = encode_open_reply(
&OpenReply {
flags: reply_flags,
token: if reply_flags & FLAG_CLOSE == 0 {
TOKEN
} else {
0
},
params: params.clone(),
flags: FLAG_OPEN | FLAG_REPLY,
..open
},
None,
)
.unwrap();
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
);
packet[3] = flags;
assert_eq!(decode_open_reply(&packet, None).unwrap_err(), expected);
}
for reply_flags in [FLAG_REPLY, FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_echo_reply(&echo_reply(&params, reply_flags), &params, None).unwrap();
for (flags, expected) in [
(0, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY,
ProtoError::UnexpectedFlag(FLAG_OPEN),
),
(FLAG_REPLY | 0x10, ProtoError::ReservedFlags(0x10)),
] {
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
encode_echo_reply(&echo_reply(&params, flags), &params, None).unwrap_err(),
expected
);
let mut packet =
encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
packet[3] = flags;
assert_eq!(
decode_echo_reply(&packet, &params, None).unwrap_err(),
expected
);
}
}
@@ -207,7 +209,12 @@ fn echo_layout_combinations_round_trip() {
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for key in [None, Some(KEY)] {
let params = Params {
protocol_version: 1,
@@ -265,44 +272,48 @@ fn echo_reply_requires_exact_negotiated_optional_fields() {
#[test]
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
let params = Params {
length: 20,
..Params::default()
};
let packet = encode_request(echo_request(&params, &[1, 2]), None).unwrap();
assert_eq!(&packet[16..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), None),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
};
let packet = encode_echo_reply(&reply, &params, None).unwrap();
assert_eq!(&packet[16..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, None).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, None).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
for stats in [ReceivedStats::None, ReceivedStats::Both] {
for key in [None, Some(KEY)] {
let mut params = Params {
received_stats: stats,
..Params::default()
};
let offset = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (offset + 4) as i64;
let packet = encode_request(echo_request(&params, &[1, 2]), key).unwrap();
assert_eq!(&packet[offset..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), key),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
};
let packet = encode_echo_reply(&reply, &params, key).unwrap();
assert_eq!(&packet[offset..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, key).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, key).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
..reply
},
&params,
key
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
}
}
}
#[test]
@@ -321,6 +332,14 @@ fn a_negative_negotiated_length_still_encodes_in_both_directions() {
let request = encode_request(echo_request(&params, &[]), None)
.expect("a negative negotiated length must still encode a request");
assert_eq!(request.len(), 16);
assert_eq!(
decode_request(&request).unwrap().kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &[]
}
);
let reply = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None)
.expect("a negative negotiated length must still encode a reply");
@@ -505,12 +524,14 @@ fn midpoint_dual_field_reply_authenticates_over_full_packet() {
let reply = decode_echo_reply(&packet, &params, Some(KEY)).unwrap();
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
let mut corrupted = packet;
corrupted[HMAC_OFFSET] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
for offset in [HMAC_OFFSET, PacketLayout::echo(true, &params).header_len()] {
let mut corrupted = packet.clone();
corrupted[offset] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
}
}
#[test]
@@ -539,61 +560,92 @@ fn only_the_exact_compat_length_is_accepted_as_an_extension() {
}
#[test]
fn midpoint_both_clock_reply_has_no_additional_extension() {
let params = midpoint_params(Clock::Both, 0);
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
fn other_layouts_have_no_longer_form_exception() {
for (stamp_at, clock) in [
(StampAt::Midpoint, Clock::Both),
(StampAt::Receive, Clock::Wall),
] {
let params = Params {
stamp_at,
clock,
..Params::default()
};
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len()
})
);
}
}
#[test]
fn non_midpoint_stamp_at_has_no_longer_form_exception() {
let params = Params {
received_stats: ReceivedStats::None,
stamp_at: StampAt::Receive,
clock: Clock::Wall,
..Params::default()
};
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
}
/// The midpoint compatibility length is a *reply* concept. An inbound ECHO
/// request of that length is just a longer request, which a receiver accepts
/// without applying any negotiated length rule.
#[test]
fn echo_request_decoding_has_no_compat_length_rule() {
let params = midpoint_params(Clock::Wall, 0);
let packet = encode_request(echo_request(&params, &[]), None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
let request = decode_request(&too_long).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &too_long[16..],
fn reply_decoders_enforce_hmac_presence_and_authenticate_the_body() {
let params = params();
for key in [None, Some(KEY)] {
let open = encode_open_reply(
&OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
key,
)
.unwrap();
let echo = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
let mismatched_key = if key.is_some() { None } else { Some(KEY) };
assert_eq!(
decode_open_reply(&open, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
assert_eq!(
decode_echo_reply(&echo, &params, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
if let Some(key) = key {
let mut bad_open = open;
let mut bad_echo = echo;
*bad_open.last_mut().unwrap() ^= 1;
*bad_echo.last_mut().unwrap() ^= 1;
assert_eq!(
decode_open_reply(&bad_open, Some(key)),
Err(ProtoError::BadHmac)
);
assert_eq!(
decode_echo_reply(&bad_echo, &params, Some(key)),
Err(ProtoError::BadHmac)
);
}
);
}
}
#[test]
fn echo_reply_length_errors_precede_hmac_verification() {
for key in [None, Some(KEY)] {
let mut params = Params::default();
let header = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (header + 4) as i64;
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
for actual in [header - 1, header, packet.len() - 1, packet.len() + 1] {
let mut malformed = packet.clone();
malformed.resize(actual, 0);
// Truncating or extending the signed packet also invalidates its MAC.
let expected = if actual < header {
ProtoError::PacketTooShort {
needed: header,
actual,
}
} else {
ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual,
}
};
assert_eq!(decode_echo_reply(&malformed, &params, key), Err(expected));
}
}
}
+44 -171
View File
@@ -47,8 +47,6 @@ fn token_and_sequence() -> Vec<u8> {
body
}
// ---------- A. Classification ----------
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
match kind {
DecodedRequestKind::Open { no_test: false, .. } => "open",
@@ -71,6 +69,34 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
];
for no_test in [false, true] {
for key in [None, Some(KEY)] {
let params = Params::default();
let packet = encode_request(
RequestToEncode::Open {
params: &params,
no_test,
},
key,
)
.unwrap();
let expected_flags = FLAG_OPEN
| if no_test { FLAG_CLOSE } else { 0 }
| if key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(packet[3], expected_flags);
assert_eq!(
decode_request(&packet).unwrap().kind,
DecodedRequestKind::Open {
no_test,
params: &[]
}
);
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
for (flags, hmac_present, expected) in cases {
// Long enough for any kind, so only the flags decide classification.
let packet = raw(flags, &token_and_sequence(), 64);
@@ -82,6 +108,10 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
request.hmac_present, hmac_present,
"flags 0x{flags:02x} HMAC presence"
);
if let DecodedRequestKind::Open { params, .. } = request.kind {
let offset = if hmac_present { 20 } else { 4 };
assert_eq!(params, &packet[offset..]);
}
assert_eq!(
classify(&request.kind),
expected,
@@ -90,8 +120,6 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
}
}
// ---------- B. Invalid header / flags ----------
#[test]
fn structurally_invalid_headers_are_rejected() {
assert_eq!(
@@ -135,8 +163,6 @@ fn any_reply_flagged_datagram_is_rejected_as_a_request() {
}
}
// ---------- C. Structural minimum lengths ----------
#[test]
fn structural_minimum_lengths_are_enforced_per_kind() {
// (flags, minimum accepted length)
@@ -172,8 +198,6 @@ fn structural_minimum_lengths_are_enforced_per_kind() {
}
}
// ---------- D. Field offsets ----------
#[test]
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
for hmac in [false, true] {
@@ -211,69 +235,25 @@ fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
}
}
// ---------- E. Open parameter slice ----------
#[test]
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
let encoded = params().encode();
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let expected_offset = if hmac { 4 + HMAC_SIZE } else { 4 };
let packet = raw(flags, &encoded, expected_offset + encoded.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[expected_offset..]);
assert_eq!(Params::decode(params).unwrap(), self::params());
// Structural parsing accepts even malformed Params; semantic errors belong
// to Params::decode and are covered in params.rs.
for body in [params().encode(), Vec::new(), vec![1, 0x80]] {
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let offset = if hmac { 20 } else { 4 };
let packet = raw(flags, &body, offset + body.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[offset..]);
}
other => panic!("expected an open request, got {other:?}"),
}
other => panic!("expected an open request, got {other:?}"),
}
}
}
#[test]
fn empty_and_malformed_open_params_decode_structurally() {
let empty = header(FLAG_OPEN);
match decode_request(&empty).unwrap().kind {
DecodedRequestKind::Open { params, .. } => assert!(params.is_empty()),
other => panic!("expected an open request, got {other:?}"),
}
// A truncated varint: structurally fine, semantically not. A receiver must
// be able to authenticate before spending effort on this.
let malformed = raw(FLAG_OPEN, &[1, 0x80], 6);
match decode_request(&malformed).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &[1, 0x80]);
assert_eq!(Params::decode(params), Err(ProtoError::TruncatedVarint));
}
other => panic!("expected an open request, got {other:?}"),
}
}
// ---------- F. Open precedence ----------
#[test]
fn open_precedence_beats_close_and_an_echo_shaped_body() {
// An OPEN|CLOSE datagram whose body looks exactly like a token and
// sequence number is still a no-test open, and those bytes are parameters.
let packet = raw(FLAG_OPEN | FLAG_CLOSE, &token_and_sequence(), 16);
assert_eq!(
decode_request(&packet).unwrap(),
DecodedRequest {
hmac_present: false,
kind: DecodedRequestKind::Open {
no_test: true,
params: &packet[4..],
},
}
);
}
// ---------- G. Trailing data ----------
#[test]
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
@@ -303,8 +283,6 @@ fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
}
}
// ---------- H. Zero token ----------
#[test]
fn a_zero_token_is_structurally_valid() {
assert_eq!(
@@ -323,8 +301,6 @@ fn a_zero_token_is_structurally_valid() {
);
}
// ---------- HMAC presence is not authentication ----------
#[test]
fn hmac_presence_is_reported_independently_of_mac_validity() {
// A structurally complete authenticated echo whose MAC is garbage.
@@ -378,106 +354,3 @@ fn packet_verification_requires_a_present_and_complete_hmac_field() {
Err(ProtoError::BadMagic)
);
}
// ---------- Encode -> decode cross-checks ----------
#[test]
fn encoded_requests_decode_back_to_their_sender_side_identity() {
for key in [None, Some(KEY)] {
for no_test in [false, true] {
let packet = encode_request(
RequestToEncode::Open {
params: &params(),
no_test,
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Open {
no_test: decoded,
params: encoded,
} => {
assert_eq!(decoded, no_test);
assert_eq!(Params::decode(encoded).unwrap(), params());
}
other => panic!("expected an open request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
let packet = encode_request(RequestToEncode::Close { token: TOKEN }, key).unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
assert_eq!(request.kind, DecodedRequestKind::Close { token: TOKEN });
// The ECHO tail is deliberately not compared against the logical
// payload: the sender placed it at a negotiated offset the receiver
// cannot recover without `Params`.
let echo_params = Params {
length: 96,
..params()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &echo_params,
payload: &[1, 2, 3, 4],
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Echo {
token, sequence, ..
} => {
assert_eq!(token, TOKEN);
assert_eq!(sequence, SEQUENCE);
}
other => panic!("expected an echo request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
#[test]
fn echo_encoding_floors_a_negative_negotiated_length_at_the_field_block() {
// A negative negotiated length is accepted during open and echoed back
// unchanged, so a real session can carry one. It must therefore stay
// encodable: it asks for no space beyond the mandatory fields.
let params = Params {
length: -1,
..Params::default()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &params,
payload: &[],
},
None,
)
.expect("a negative negotiated length must still encode");
// header (4) + token (8) + sequence (4).
assert_eq!(packet.len(), 16);
let request = decode_request(&packet).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: SEQUENCE,
tail: &[],
}
);
}
+108
View File
@@ -0,0 +1,108 @@
use irtt_proto::{varint::*, ProtoError};
use proptest::prelude::*;
#[test]
fn boundary_values_round_trip() {
for value in [
0,
1,
2,
127,
128,
255,
16_384,
u32::MAX as u64,
i64::MAX as u64,
u64::MAX,
] {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
for value in [
i64::MIN,
-9_223_372_036,
-2,
-1,
0,
1,
2,
1_000_000_000,
3_000_000_000,
i64::MAX,
] {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn verified_byte_examples() {
let cases: &[(i64, &[u8])] = &[
(1, &[0x02]),
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
(1472, &[0x80, 0x17]),
(3, &[0x06]),
(184, &[0xf0, 0x02]),
];
for (value, expected) in cases {
let mut encoded = Vec::new();
encode_varint(*value, &mut encoded);
assert_eq!(&encoded, expected);
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
}
for tag in [9, 24] {
let mut encoded = Vec::new();
encode_uvarint(tag, &mut encoded);
assert_eq!(encoded, [tag as u8]);
}
}
#[test]
fn truncated_and_overflowing_varints_are_rejected() {
// Incomplete input differs from a tenth byte whose bits cannot fit u64,
// or an eleventh byte after ten continuation bytes.
for (bytes, expected) in [
(vec![], ProtoError::TruncatedVarint),
(vec![0x80], ProtoError::TruncatedVarint),
(vec![0x80; 10], ProtoError::TruncatedVarint),
(
[vec![0x80; 9], vec![2]].concat(),
ProtoError::VarintOverflow,
),
(
[vec![0x80; 10], vec![0]].concat(),
ProtoError::VarintOverflow,
),
] {
assert_eq!(decode_uvarint(&bytes), Err(expected), "{bytes:02x?}");
assert_eq!(
decode_varint(&bytes),
decode_uvarint(&bytes).map(|(v, n)| (zigzag_decode(v), n))
);
}
}
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_uvarint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_varint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
}
+1 -2
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "server"]
categories = ["network-programming"]
@@ -16,7 +15,7 @@ irtt-proto.workspace = true
getrandom = "0.4.3"
socket2 = { version = "0.6.5", features = ["all"] }
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
# 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.
#![forbid(unsafe_code)]
/// Cargo package version of this compiled library.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
mod clock;
mod config;
mod core;
+2 -3
View File
@@ -1,12 +1,11 @@
[package]
name = "irtt-stats"
version = "0.5.1"
version = "0.5.2"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"]
@@ -19,4 +18,4 @@ irtt-client.workspace = true
name = "finite_vs_continuous"
[dev-dependencies]
proptest = "1.11.0"
proptest = "1.12.0"
+12 -10
View File
@@ -175,29 +175,31 @@ impl CoreStats {
let mut updates = Vec::new();
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;
};
let send_ipdv = pair
.send_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
let receive_ipdv = pair
.receive_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
let send_ipdv = send_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
let receive_ipdv = receive_magnitude_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);
}
if let Some(value) = pair.receive_ipdv_ns {
if let Some(value) = receive_magnitude_ns {
self.ipdv_receive.push_ns(value);
}
updates.push(IpdvPairUpdate {
previous_seq: pair.previous_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,
send_ipdv,
receive_ipdv,
+3 -7
View File
@@ -89,9 +89,9 @@ impl IpdvTracker {
Some(CompletedIpdvPair {
previous_seq,
current_seq,
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns),
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns),
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns),
rtt_ipdv_ns: current.rtt_primary_ns - previous.rtt_primary_ns,
send_ipdv_ns: send_ipdv_ns(previous, current),
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)
}
fn abs_i128_ns(value: i128) -> i128 {
value.saturating_abs()
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -25,6 +25,9 @@
#![warn(missing_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 irtt_client::ClientEvent;
@@ -520,6 +523,12 @@ pub struct IpdvPairUpdate {
pub previous_seq: u32,
/// Sequence number of the later reply in the adjacent pair.
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.
pub rtt_ipdv: Duration,
/// 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_send_wall_ns: system_time_ns(sent_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),
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns),
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns),
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns),
// Midpoint timestamps serve as both directional endpoints.
server_receive_mono_ns: server_timing
.and_then(|timing| timing.receive_mono_ns.or(timing.midpoint_mono_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)),
},
}
}
+132 -4
View File
@@ -37,12 +37,13 @@ overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting.
Global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text keys or hexadecimal keys prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent.
Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first
`=` is how an unlabeled target or a label can contain that character. An `=` in
an HMAC value, including Base64 padding, does not need escaping. Positional
`=` is how an unlabeled target or a label can contain that character. An `=` in an HMAC value does not need escaping. Positional
target arguments may contain a raw comma; stdin target sets have the additional
`\,` escape described below.
@@ -58,6 +59,60 @@ once per interval instead. Final per-target summaries print in the order
targets were supplied on the command line, not the order in which they
finish or their labels sort alphabetically.
## ADDRESS FAMILY
By default, each target uses the first address returned by the system resolver,
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
`-6` / `--ipv6` to select IPv6 only. Hostnames are resolved when each session
connects; these flags do not perform an extra discovery lookup. An explicit
IP literal from the other family is rejected during preparation.
Use `--dual-stack` to compare IPv4 and IPv6 paths as separate measurements:
```sh
irtt-client -6 host.example
irtt-client --dual-stack cov=uk-cov1.irtt.lochnair.net
```
For each hostname, the client discovers which address families the system
resolver currently returns. IPv4 results create `cov/v4`, IPv6 results create
`cov/v6`, and both create both targets, in that order. Without an alias,
`host.example` becomes `host.example/v4` and/or `host.example/v6`. Multiple
addresses within one family still produce only one target. Explicit IP literals
keep their original label and remain single targets.
Each target has its own session, statistics, and failures. Its original hostname
is retained: every new session resolves it again and selects the first address
matching its family. Families are discovered only when a declaration is added,
so a newly appearing family does not create another target automatically.
Discovery failure or a successful lookup returning no addresses rejects the
whole target set. A missing family is normal and simply creates no target for
that family. Final labels must be unique, including generated `/v4` and `/v6`
labels; choose another alias if one collides with an explicit label.
`-4`, `-6`, and `--dual-stack` are mutually exclusive. Expanded targets use
ordinary multi-target pacing and each sends its own probes, so dual-stack can
double the number of probes per interval compared with one target per hostname.
## LOCAL SOCKET CONFIGURATION
The client supports configuring its local UDP sockets using the following options:
- `--bind-addr IP[:PORT]` binds sockets to the specified local IP address and optional port. If no port is specified, or the port is `0`, the operating system assigns an ephemeral port. IPv6 addresses with an explicit port must use `[ADDR]:PORT` syntax. The address family must match the target's resolved address family.
- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a network interface using `SO_BINDTODEVICE`.
- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark using `SO_MARK`, allowing traffic to be selected by policy routing or firewall rules. Accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit integer.
- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. The FIB is specified as a decimal unsigned 32-bit integer.
For example, bind IPv4 probes to a specific source address and interface, with a firewall mark:
```sh
irtt-client -4 --bind-addr 192.0.2.10 --bind-device wan0 --mark 1234 host.example
```
Setting a firewall mark or binding to a network device may require elevated privileges or appropriate capabilities. If the operating system rejects a socket option, socket setup fails.
A fixed local port may cause binding conflicts when multiple target sockets are created. Use an ephemeral port unless a specific source port is required. A family-specific bind address is also incompatible with targets using the other address family.
## DYNAMIC TARGET SETS FROM STANDARD INPUT
`--targets-stdin` is available only with continuous mode (`--duration 0`).
@@ -75,6 +130,13 @@ are retired, unchanged target configurations retain their generation, and a
changed address or HMAC setting creates a fresh generation. Under transient
live-generation backpressure, only the latest unapplied desired set is kept.
The same address-family preparation applies to initial and stdin targets.
With `--dual-stack`, unchanged labels and endpoints retain their discovered
families across records, including HMAC-only changes. Removing a declaration
and later adding it again performs fresh discovery. New declarations are
prepared asynchronously while existing measurements continue. A preparation
error stops the stream gracefully without applying a partial set.
Commas frame targets in stdin records; escape a literal comma within one
target as `\,`. Target parsing inside each element is otherwise the same as
for positional arguments, including the other escapes above, `@hmac=KEY`, and
@@ -107,10 +169,42 @@ revision to become applicable.
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
desired set to 128 targets and retains at most 256 live target generations
while replacements drain.
while replacements drain. The 128-target limit applies after dual-stack
expansion: a hostname producing both families consumes two target slots.
## 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
```
The `no_test_completed` event reports successful negotiation, including the
negotiated duration, interval, and packet length. Output format and column
selection work as usual; no measurement summary or statistics-memory warning
is printed. Negotiation failures use the normal target diagnostics and exit
policy: an uninterrupted run exits nonzero if no target succeeds.
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:
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
@@ -126,6 +220,40 @@ See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
distinction also determines what statistics can be computed and how much
memory a long run retains.
### Optional recovery and event-only runs
`--no-stats` disables statistics collection and final summaries while preserving individual measurement output. Statistics-dependent columns (such as IPDV) are unavailable. This runtime option does not remove the `irtt-stats` dependency.
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.
`--reconnect` enables automatic recovery for individual targets in continuous mode (`--continuous` or `--duration 0`). Failed connections, closed sessions, and unresponsive servers are retried without interrupting other targets. Retries double the delay after each failed attempt, from 250 ms up to 10 seconds by default. A valid reply resets the delay to the initial value; each target backs off independently.
`--reconnect-initial-delay DURATION` (default: `250ms`) and `--reconnect-max-delay DURATION` (default: `10s`) configure these delays. Both require `--reconnect`, must be positive and schedulable, and the maximum must be at least twice the initial delay. For example, `--reconnect-initial-delay 1s --reconnect-max-delay 5s` retries after `1s`, `2s`, `4s`, `5s`, then `5s` thereafter.
`--reconnect-timeout DURATION` controls how long a session can go without valid replies before reconnection is attempted (default: `10s`). The timeout applies only while actively sending probes and should account for the probe interval and expected packet loss.
**Statistics reset on reconnection**, so final summaries only reflect the latest session per target. A warning is emitted when reconnection is enabled with statistics collection. Use `--no-stats` to disable collection and suppress the warning.
Example for continuous, multi-target CSV output without statistics:
```sh
irtt-client --continuous --reconnect --no-stats \
--format csv --header never \
--columns client_receive_wall_ns,target,seq,rd_us,sd_us \
reflector1 reflector2
```
## OUTPUT
### `--format FORMAT`
@@ -261,7 +389,7 @@ approaches a fixed multiple of `targets` regardless of how long it runs.
## PEER CLOSE AND EXIT BEHAVIOR
A peer closing a finite run is accepted as a terminal, successful outcome.
In continuous mode, an uninterrupted peer closure is treated as an error —
Without `--reconnect`, in continuous mode, an uninterrupted peer closure is treated as an error —
the client exits nonzero — so a process supervisor can restart it; an
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
mode it was in.
+4 -1
View File
@@ -49,9 +49,12 @@ reports which feature to rebuild with, and `irtt-rs --help` lists which
applets the current build actually contains.
```sh
cargo install irtt-rs --locked --no-default-features --features client
cargo install irtt-rs --locked --no-default-features --features client,stats
```
Use `--features client` to omit statistics. The default build includes the
`stats` feature, and `tui` always enables it.
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
`irtt-rs` is packaging and dispatch only. All probing, display, and server
+3 -2
View File
@@ -74,8 +74,9 @@ IPv6 listener.
### `--hmac KEY`
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
every request must carry a valid MAC and every reply is authenticated;
Accepts a plain-text key or a hexadecimal key prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent. The `IRTT_SERVER_HMAC` environment variable uses the same syntax.
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.
+59 -10
View File
@@ -16,21 +16,56 @@ At least one target is required.
Target syntax, multiple targets, and pacing are identical to
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
`--pacing staggered|burst` option. The full set of negotiation flags
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
`--pacing staggered|burst` option. The client configuration flags
(`--interval`, `--timeouts`, `--length`, `--hmac`, `--clock`, `--tstamp`,
`--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are shared with `irtt-client` as
well; consult that manual for their meaning.
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
of a hostname its own measurement row and graph series. These switches are
mutually exclusive. Explicit IP literals remain single targets; a literal
conflicting with `-4` or `-6` is rejected before the dashboard starts.
```sh
irtt-tui -4 uk-cov1.irtt.lochnair.net
irtt-tui --dual-stack cov=uk-cov1.irtt.lochnair.net
```
The second command creates `cov/v4` and/or `cov/v6` according to the system
resolver's initial results, letting you compare the two paths directly.
See the client manual's address-family section for discovery errors and labels.
## CONTINUOUS DEFAULT
Unlike `irtt-client`, **the TUI defaults to continuous mode**
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
a finite run:
a finite run. 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
irtt-tui <server> --duration 30s
```
Continuous runs reconnect after target-local failures, peer closure, or a
session completing because the server restricted its duration. After a
1.5-second delay, one coalesced retry reopens completed targets while healthy
sessions continue undisturbed. Even if every target fails, the TUI stays alive
until you quit. Explicit stops, removed or replaced targets, and no-test
completion do not trigger retries. Quitting cancels pending retry work.
With `--dual-stack`, a failed family reconnects independently while its healthy
sibling continues. Reconnect resolves the original hostname again within the
target's family. It does not discover additional families or pin the address
returned by initial discovery.
Each reconnect keeps the same logical target row. Statistics reset when a new
session generation is accepted, including attempts that fail before opening.
Retained graph history and the age of the last actual reply remain; the graph
leaves a gap between generations instead of connecting their samples.
## DASHBOARD
One dashboard is used for every target count and terminal size. It shows:
@@ -88,7 +123,7 @@ redraws still work while the display is paused.
The six graph metrics are effective RTT, raw RTT, adjusted RTT,
client-to-server delay, server-to-client delay, and server processing time.
Signed effective/adjusted RTT and one-way values are preserved. Graph windows
range from 5 seconds to one hour 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.
## RETAINED HISTORY
@@ -96,11 +131,20 @@ controls appropriate to the currently open graph or details sheet.
The TUI keeps its own bounded presentation state on top of the statistics
retention described in `irtt-client(1)`:
- Up to 100,000 graph samples per target.
- Up to 500,000 graph samples per target.
- Up to 80 recent status/log messages.
At 500,000 replies per target, retention is approximately 13h53m20s at
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph
samples store both clocks, six graph-ready values, and a session-boundary
flag; latest details keep the full typed timing values and sequence number.
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target
at the cap, excluding statistics and temporary rendering buffers. Histories
grow on demand and reserve exactly to the cap on their final growth step;
they do not preallocate the full history for every target at startup.
`r` clears graph history for all targets and returns the viewport to
live; it does not change the 100,000-sample cap itself. These are
live; it does not change the 500,000-sample cap itself. These are
presentation bounds on top of the client's own statistics retention (see
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
total memory is dominated by whichever of the two is larger for your target
@@ -116,9 +160,14 @@ corrupt the display. Size a long finite TUI run from the figures in
## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
exit status follows the same peer-close and driver-failure rules as
`irtt-client(1)`.
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. In continuous
mode, peer closure and target failures trigger reconnect rather than ending the
run. A managed driver failure or an unexpected reconnect-update failure still
ends the TUI with an error.
Finite runs do not reconnect. They complete after their target sessions end;
a peer closure is a normal session ending, while a run whose targets all fail
exits with an error.
## EXAMPLES
+16 -16
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "arbitrary"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
[[package]]
name = "autocfg"
@@ -31,9 +31,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.6.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -76,9 +76,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865"
dependencies = [
"cmov",
]
@@ -97,9 +97,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "getrandom"
@@ -174,9 +174,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.190"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libfuzzer-sys"
@@ -285,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -316,9 +316,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.53.2"
version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"libc",
"mio",
@@ -347,9 +347,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasi"
+1 -1
View File
@@ -8,7 +8,7 @@ edition = "2021"
cargo-fuzz = true
[dependencies]
arbitrary = "1.4.2"
arbitrary = "1.5.0"
libfuzzer-sys = "0.4.13"
[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:
if platform.system() != "Linux":
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:
raise RuntimeError(f"required command not found in PATH: {binary}")
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]:
return [*sudo_prefix(args), "ip", "netns", "exec", namespace, *command]
return [*sudo_prefix(args), "nsenter", f"--net=/run/netns/{namespace}", "--", *command]
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", "set", CLIENT_VETH, "netns", CLIENT_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],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "addr", "add", SERVER_ADDR, "dev", SERVER_VETH],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", CLIENT_VETH, "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", SERVER_VETH, "up"],
netns(args, CLIENT_NS, ["ip", "addr", "add", CLIENT_ADDR, "dev", CLIENT_VETH]),
netns(args, SERVER_NS, ["ip", "addr", "add", SERVER_ADDR, "dev", SERVER_VETH]),
netns(args, CLIENT_NS, ["ip", "link", "set", "lo", "up"]),
netns(args, SERVER_NS, ["ip", "link", "set", "lo", "up"]),
netns(args, CLIENT_NS, ["ip", "link", "set", CLIENT_VETH, "up"]),
netns(args, SERVER_NS, ["ip", "link", "set", SERVER_VETH, "up"]),
]
for index, command in enumerate(commands):
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:
if not setting:
continue
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
command = netns(args, namespace, [
"tc",
"qdisc",
"replace",
@@ -423,25 +418,20 @@ def apply_netem(args: argparse.Namespace, scenario: Scenario, scenario_dir: Path
"root",
"netem",
*shlex.split(setting),
]
])
run_checked(command, scenario_dir / "netem" / f"{namespace}-{dev}")
def delete_qdiscs(args: argparse.Namespace, log_dir: Path) -> None:
for namespace, dev in [(CLIENT_NS, CLIENT_VETH), (SERVER_NS, SERVER_VETH)]:
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
command = netns(args, namespace, [
"tc",
"qdisc",
"del",
"dev",
dev,
"root",
]
])
run_checked(
command,
log_dir / "qdisc-cleanup" / f"{namespace}-{dev}",
@@ -698,12 +688,19 @@ def cleanup(args: argparse.Namespace, quiet: bool) -> None:
return
if args.sudo and shutil.which(args.sudo) is None:
return
commands = [
[*sudo_prefix(args), "ip", "netns", "exec", CLIENT_NS, "tc", "qdisc", "del", "dev", CLIENT_VETH, "root"],
[*sudo_prefix(args), "ip", "netns", "exec", SERVER_NS, "tc", "qdisc", "del", "dev", SERVER_VETH, "root"],
commands = []
if shutil.which("nsenter") is not None:
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", 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:
completed = subprocess.run(
command,